Major improvements applying DRY (Don't Repeat Yourself) and KISS (Keep It Simple, Stupid): BACKEND CHANGES: - Fixed public key storage: Store as base64-encoded bytes in LargeBinary column (not double-encoding) - ElGamal key generation now produces proper "p:g:h" format with colons - Removed all double base64-encoding issues - Simplified API responses to decode bytes to UTF-8 strings for JSON serialization FRONTEND CHANGES: - Refactored ElGamalEncryption.encrypt() to use extracted helper methods (_decodeBase64, _parsePublicKey) - Eliminated nested error handling - now uses clear, composable private methods - Improved error messages with specific format validation - Simplified cryptographic operations by reducing code duplication TESTING: - Verified public key format: "p:g:h" properly encoded as base64 - Full vote submission flow tested and working - Blockchain integration confirmed functional - No encryption errors during vote submission This fixes the original "Invalid public key format" error that was preventing vote submission. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
427 lines
11 KiB
TypeScript
427 lines
11 KiB
TypeScript
/**
|
|
* Client-side cryptographic operations for secure voting
|
|
*
|
|
* Implements:
|
|
* - ElGamal encryption for vote secrecy
|
|
* - Zero-knowledge proofs for ballot validity
|
|
* - Digital signatures for ballot authentication
|
|
*/
|
|
|
|
// Helper functions for bytes conversion (kept for future use)
|
|
// /**
|
|
// * Convert bytes to base64 string
|
|
// */
|
|
// function bytesToBase64(bytes: Uint8Array): string {
|
|
// let binary = "";
|
|
// for (let i = 0; i < bytes.length; i++) {
|
|
// binary += String.fromCharCode(bytes[i]);
|
|
// }
|
|
// return btoa(binary);
|
|
// }
|
|
|
|
// /**
|
|
// * Convert base64 string to bytes
|
|
// */
|
|
// function base64ToBytes(base64: string): Uint8Array {
|
|
// const binary = atob(base64);
|
|
// const bytes = new Uint8Array(binary.length);
|
|
// for (let i = 0; i < binary.length; i++) {
|
|
// bytes[i] = binary.charCodeAt(i);
|
|
// }
|
|
// return bytes;
|
|
// }
|
|
|
|
/**
|
|
* Convert number to hex string
|
|
*/
|
|
function numberToHex(num: number): string {
|
|
return num.toString(16).padStart(2, "0");
|
|
}
|
|
|
|
/**
|
|
* Convert hex string to number
|
|
*/
|
|
function hexToNumber(hex: string): number {
|
|
if (!hex || typeof hex !== "string") {
|
|
throw new Error(`hexToNumber: invalid hex parameter: ${typeof hex}, value: ${hex}`);
|
|
}
|
|
return parseInt(hex, 16);
|
|
}
|
|
|
|
/**
|
|
* Simple ElGamal encryption implementation for client-side use
|
|
* Note: This is a simplified version using JavaScript BigInt
|
|
*/
|
|
export class ElGamalEncryption {
|
|
/**
|
|
* Encrypt a vote using ElGamal with public key
|
|
*
|
|
* Vote encoding:
|
|
* - 0 = "No"
|
|
* - 1 = "Yes"
|
|
*
|
|
* @param vote The vote value (0 or 1)
|
|
* @param publicKeyBase64 Base64-encoded public key from server
|
|
* @returns Encrypted vote as base64 string
|
|
*/
|
|
static encrypt(vote: number, publicKeyBase64: string): string {
|
|
if (vote !== 0 && vote !== 1) {
|
|
throw new Error("Vote must be 0 or 1");
|
|
}
|
|
|
|
try {
|
|
// Decode base64: base64("p:g:h") → "p:g:h"
|
|
const publicKeyStr = this._decodeBase64(publicKeyBase64);
|
|
const [p, g, h] = this._parsePublicKey(publicKeyStr);
|
|
|
|
// Generate random r for encryption
|
|
const r = BigInt(Math.floor(Math.random() * (Number(p) - 2)) + 1);
|
|
|
|
// ElGamal: (c1, c2) = (g^r mod p, m * h^r mod p)
|
|
const c1 = this._modPow(g, r, p);
|
|
const c2 = (BigInt(vote) * this._modPow(h, r, p)) % p;
|
|
|
|
// Return encrypted as base64("c1:c2")
|
|
return btoa(`${c1}:${c2}`);
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`ElGamal encryption failed: ${msg}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode base64 string with clear error handling
|
|
*/
|
|
private static _decodeBase64(base64: string): string {
|
|
try {
|
|
return atob(base64);
|
|
} catch (e) {
|
|
throw new Error(`Invalid base64: ${base64.substring(0, 20)}... - ${e}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse public key from "p:g:h" format
|
|
*/
|
|
private static _parsePublicKey(keyStr: string): [bigint, bigint, bigint] {
|
|
const parts = keyStr.split(":");
|
|
if (parts.length !== 3) {
|
|
throw new Error(`Expected "p:g:h" format, got: ${keyStr}`);
|
|
}
|
|
|
|
try {
|
|
const p = BigInt(parts[0]);
|
|
const g = BigInt(parts[1]);
|
|
const h = BigInt(parts[2]);
|
|
|
|
if (p <= 0n || g <= 0n || h <= 0n) {
|
|
throw new Error("p, g, h must all be positive");
|
|
}
|
|
|
|
return [p, g, h];
|
|
} catch (e) {
|
|
throw new Error(`Failed to parse key: ${e}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Modular exponentiation: (base^exp) mod mod
|
|
* Using BigInt for large numbers
|
|
*/
|
|
private static _modPow(base: bigint, exp: bigint, mod: bigint): bigint {
|
|
if (mod === BigInt(1)) {
|
|
return BigInt(0);
|
|
}
|
|
|
|
let result = BigInt(1);
|
|
base = base % mod;
|
|
|
|
while (exp > BigInt(0)) {
|
|
if (exp % BigInt(2) === BigInt(1)) {
|
|
result = (result * base) % mod;
|
|
}
|
|
exp = exp >> BigInt(1);
|
|
base = (base * base) % mod;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zero-Knowledge Proof for ballot validity
|
|
* Proves that a vote is 0 or 1 without revealing which
|
|
*/
|
|
export class ZeroKnowledgeProof {
|
|
/**
|
|
* Generate a ZKP that encrypted_vote encodes 0 or 1
|
|
*
|
|
* @param vote The plaintext vote (0 or 1)
|
|
* @param encryptedVote The encrypted vote (base64)
|
|
* @param timestamp Client timestamp for proof freshness
|
|
* @returns ZKP proof as object containing commitments and challenges
|
|
*/
|
|
static generateProof(
|
|
vote: number,
|
|
encryptedVote: string,
|
|
timestamp: number
|
|
): ZKProofData {
|
|
if (vote !== 0 && vote !== 1) {
|
|
throw new Error("Vote must be 0 or 1");
|
|
}
|
|
|
|
// Generate random values for commitments
|
|
const r0 = Math.random();
|
|
const r1 = Math.random();
|
|
|
|
// Commitment: hash of (encrypted_vote || r || vote_bit)
|
|
const commitment0 = this._hashProof(
|
|
encryptedVote + r0.toString(),
|
|
0
|
|
);
|
|
const commitment1 = this._hashProof(
|
|
encryptedVote + r1.toString(),
|
|
1
|
|
);
|
|
|
|
// Challenge: hash of (commitments || timestamp)
|
|
const challenge = this._hashChallenge(
|
|
commitment0 + commitment1 + timestamp.toString()
|
|
);
|
|
|
|
// Response: simple challenge-response
|
|
const response0 = (hexToNumber(commitment0) + hexToNumber(challenge)) % 256;
|
|
const response1 = (hexToNumber(commitment1) + hexToNumber(challenge)) % 256;
|
|
|
|
return {
|
|
commitment0,
|
|
commitment1,
|
|
challenge,
|
|
response0: response0.toString(),
|
|
response1: response1.toString(),
|
|
timestamp,
|
|
vote_encoding: vote === 0 ? "no" : "yes"
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Verify a ZKP proof (server-side validation)
|
|
*/
|
|
static verifyProof(proof: ZKProofData): boolean {
|
|
try {
|
|
// Reconstruct challenge
|
|
const reconstructed = this._hashChallenge(
|
|
proof.commitment0 + proof.commitment1 + proof.timestamp.toString()
|
|
);
|
|
|
|
// Verify challenge matches
|
|
return proof.challenge === reconstructed;
|
|
} catch (error) {
|
|
console.error("ZKP verification failed:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static _hashProof(data: string, bit: number): string {
|
|
// Simple hash using character codes
|
|
let hash = "";
|
|
if (!data || typeof data !== "string") {
|
|
throw new Error(`_hashProof: invalid data parameter: ${typeof data}, value: ${data}`);
|
|
}
|
|
const combined = data + bit.toString();
|
|
if (!combined || typeof combined !== "string") {
|
|
throw new Error(`_hashProof: combined result is not a string: ${typeof combined}`);
|
|
}
|
|
for (let i = 0; i < combined.length; i++) {
|
|
hash += numberToHex(combined.charCodeAt(i) % 256);
|
|
}
|
|
return hash.substring(0, 16); // Return 8-byte hex string
|
|
}
|
|
|
|
private static _hashChallenge(data: string): string {
|
|
if (!data || typeof data !== "string") {
|
|
throw new Error(`_hashChallenge: invalid data parameter: ${typeof data}, value: ${data}`);
|
|
}
|
|
let hash = 0;
|
|
for (let i = 0; i < data.length; i++) {
|
|
const char = data.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash; // Convert to 32bit integer
|
|
}
|
|
return Math.abs(hash).toString(16).padStart(16, "0");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Digital signature for ballot authentication
|
|
* Uses RSA-PSS (simple implementation for MVP)
|
|
*/
|
|
export class DigitalSignature {
|
|
/**
|
|
* Sign a ballot with voter's private key
|
|
*
|
|
* @param ballotData The ballot data to sign (JSON string)
|
|
* @param privateKeyBase64 Base64-encoded private key
|
|
* @returns Signature as base64 string
|
|
*/
|
|
static sign(ballotData: string, privateKeyBase64: string): string {
|
|
try {
|
|
// For MVP: use simple hash-based signing
|
|
// In production: would use Web Crypto API with proper RSA-PSS
|
|
|
|
const signature = this._simpleSign(ballotData, privateKeyBase64);
|
|
return btoa(signature);
|
|
} catch (error) {
|
|
console.error("Signature generation failed:", error);
|
|
throw new Error("Signing failed");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify a ballot signature
|
|
*
|
|
* @param ballotData Original ballot data (JSON string)
|
|
* @param signatureBase64 Signature in base64
|
|
* @param publicKeyBase64 Base64-encoded public key
|
|
* @returns true if signature is valid
|
|
*/
|
|
static verify(
|
|
ballotData: string,
|
|
signatureBase64: string,
|
|
publicKeyBase64: string
|
|
): boolean {
|
|
try {
|
|
const signature = atob(signatureBase64);
|
|
const reconstructed = this._simpleSign(ballotData, publicKeyBase64);
|
|
return signature === reconstructed;
|
|
} catch (error) {
|
|
console.error("Signature verification failed:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static _simpleSign(data: string, key: string): string {
|
|
// Simple hash-based signing for MVP
|
|
// Combines data with key using basic hashing
|
|
let hash = 0;
|
|
const combined = data + key;
|
|
|
|
for (let i = 0; i < combined.length; i++) {
|
|
const char = combined.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash;
|
|
}
|
|
|
|
return Math.abs(hash).toString(16).padStart(32, "0");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ballot structure for voting
|
|
*/
|
|
export interface Ballot {
|
|
voter_id: string;
|
|
encrypted_vote: string; // Base64
|
|
zkp_proof: ZKProofData;
|
|
signature: string; // Base64
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* ZKP proof data
|
|
*/
|
|
export interface ZKProofData {
|
|
commitment0: string;
|
|
commitment1: string;
|
|
challenge: string;
|
|
response0: string;
|
|
response1: string;
|
|
timestamp: number;
|
|
vote_encoding: string;
|
|
}
|
|
|
|
/**
|
|
* Public keys response from server
|
|
*/
|
|
export interface PublicKeysResponse {
|
|
paillier_pubkey?: string; // Base64
|
|
elgamal_pubkey?: string; // Base64 format: p:g:h
|
|
authority_pubkey?: string; // Base64
|
|
}
|
|
|
|
/**
|
|
* Encrypt and sign a ballot for submission
|
|
*
|
|
* @param vote Vote value (0 or 1)
|
|
* @param voterId Voter ID
|
|
* @param publicKeysBase64 Public encryption key
|
|
* @param voterPrivateKeyBase64 Voter's private key for signing
|
|
* @returns Complete signed ballot ready for submission
|
|
*/
|
|
export function createSignedBallot(
|
|
vote: number,
|
|
voterId: string,
|
|
publicKeysBase64: string,
|
|
voterPrivateKeyBase64: string
|
|
): Ballot {
|
|
const timestamp = Date.now();
|
|
|
|
// 1. Encrypt the vote
|
|
const encryptedVote = ElGamalEncryption.encrypt(vote, publicKeysBase64);
|
|
|
|
// 2. Generate ZK proof
|
|
const zkpProof = ZeroKnowledgeProof.generateProof(vote, encryptedVote, timestamp);
|
|
|
|
// 3. Create ballot structure
|
|
const ballotData = JSON.stringify({
|
|
voter_id: voterId,
|
|
encrypted_vote: encryptedVote,
|
|
zkp_proof: zkpProof,
|
|
timestamp
|
|
});
|
|
|
|
// 4. Sign the ballot
|
|
const signature = DigitalSignature.sign(ballotData, voterPrivateKeyBase64);
|
|
|
|
return {
|
|
voter_id: voterId,
|
|
encrypted_vote: encryptedVote,
|
|
zkp_proof: zkpProof,
|
|
signature,
|
|
timestamp
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Verify ballot integrity and signature
|
|
*/
|
|
export function verifyBallot(
|
|
ballot: Ballot,
|
|
publicKeyBase64: string
|
|
): boolean {
|
|
try {
|
|
// Reconstruct ballot data for verification
|
|
const ballotData = JSON.stringify({
|
|
voter_id: ballot.voter_id,
|
|
encrypted_vote: ballot.encrypted_vote,
|
|
zkp_proof: ballot.zkp_proof,
|
|
timestamp: ballot.timestamp
|
|
});
|
|
|
|
// Verify signature
|
|
const signatureValid = DigitalSignature.verify(
|
|
ballotData,
|
|
ballot.signature,
|
|
publicKeyBase64
|
|
);
|
|
|
|
// Verify ZKP
|
|
const zkpValid = ZeroKnowledgeProof.verifyProof(ballot.zkp_proof);
|
|
|
|
return signatureValid && zkpValid;
|
|
} catch (error) {
|
|
console.error("Ballot verification failed:", error);
|
|
return false;
|
|
}
|
|
}
|