From ac6e0f083f08eb466f2a87451d165908da84ee1d Mon Sep 17 00:00:00 2001 From: k-deejah Date: Wed, 29 Jul 2026 03:22:22 -1100 Subject: [PATCH] fix(crypto): implement crypto primitives, hex wire format, and full test suite (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DECISIONS.md documenting ADR-001: hex output format for encryptVote - Implement encryptVote returning EncryptedPayload object with hex-encoded ciphertext, iv, and authTag (fixes base64/hex mismatch with AnonVote/core) - Implement decryptVote accepting EncryptedPayload; auth tag failure throws AnonVoteCryptoError(DECRYPTION_FAILED) — never swallowed silently - Validate key as exactly 64 hex chars; throw AnonVoteCryptoError(INVALID_KEY) before any cipher op - Validate payload fields in decryptVote; throw INVALID_PAYLOAD if missing/empty - hashIdentifier: trim + lowercase normalisation enforced - generateToken: 32-byte CSPRNG, hex output, JSDoc @warning against reuse - hashToken: separate export from hashIdentifier for independent testability - Add EncryptedPayload, Token, Vote, ElectionResult, BallotEvent interfaces and AnonVoteCryptoError class to src/types.ts - Export all canonical types and AnonVoteCryptoError from src/index.ts - Rewrite tests/crypto.test.ts with all 25 cases per issue spec - Update src/client.ts and tests/client.test.ts to use new EncryptedPayload object (serialised as JSON string in VoteReceipt.encryptedPayload) - All 73 tests pass; clean tsc build with no errors --- DECISIONS.md | 31 +++++++ src/client.ts | 11 ++- src/crypto.ts | 149 ++++++++++++++++++++---------- src/index.ts | 16 +++- src/types.ts | 83 ++++++++++++++++- tests/client.test.ts | 15 ++- tests/crypto.test.ts | 211 +++++++++++++++++++++++++++++++++++-------- 7 files changed, 420 insertions(+), 96 deletions(-) create mode 100644 DECISIONS.md diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..96fdef3 --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,31 @@ +# Architecture Decisions + +## ADR-001 — `encryptVote` output format: hex + +**Date:** 2026-07-28 +**Status:** Accepted + +### Context + +`encryptVote` must return an `EncryptedPayload` object with three fields: `ciphertext`, `iv`, and `authTag`. When the original implementation was written, the README documented these as base64-encoded strings. The AnonVote/core backend, however, was written to consume hex-encoded strings for all three fields. This created a silent wire-format mismatch that would cause every tally operation to fail the first time a real ballot was run. + +### Decision + +All three fields of `EncryptedPayload` (`ciphertext`, `iv`, `authTag`) are **lowercase hex strings**. Base64 is not used anywhere in the cryptographic output surface of this package. + +### Rationale + +1. **Consistency with the rest of the package.** `hashIdentifier` and `hashToken` both return lowercase hex strings. Using hex for `encryptVote` output means every value that leaves this package is in the same encoding. A consumer reading stored values can tell immediately what encoding they are in. + +2. **AnonVote/core expects hex.** Changing this package to emit hex requires editing one file (`src/crypto.ts`) and its tests. Changing core to accept base64 would require updating multiple layers of the tally engine, the Stellar audit trail writer, and the storage schema. The smaller change surface is the correct choice. + +3. **Hex is self-describing.** A developer inspecting a stored row in the database can see a 24-character hex string and know it is a 12-byte IV. A base64 string requires knowing the encoding to interpret its length. + +4. **No information density benefit from base64 at this scale.** Vote payloads are small (a UUID option ID). The 33% storage overhead difference between hex and base64 is immaterial at any realistic ballot size. + +### Consequences + +- The README's description of `encryptVote` returning `iv:authTag:ciphertext` as a single base64 string is superseded. The function now returns a structured `EncryptedPayload` object with three hex fields. +- `decryptVote` accepts an `EncryptedPayload` object (not a colon-delimited string) and a hex key. +- Any consumer that was relying on the old base64 colon-delimited format must migrate to the `EncryptedPayload` object interface. +- All existing tests have been rewritten to reflect this format. diff --git a/src/client.ts b/src/client.ts index 73d649f..5e80368 100644 --- a/src/client.ts +++ b/src/client.ts @@ -163,8 +163,11 @@ export class AnonVoteClient { throw new Error("encryptionKey is required either in params or client config"); } - // Encrypt the vote - const encryptedPayload = encryptVote(params.voteOption.trim(), encryptionKey); + // Encrypt the vote — serialise the EncryptedPayload object to a JSON string + // so it fits the VoteReceipt.encryptedPayload string field. + const encryptedPayload = JSON.stringify( + encryptVote(params.voteOption.trim(), encryptionKey), + ); const id = this.generateId("receipt"); const castAt = new Date().toISOString(); @@ -201,7 +204,9 @@ export class AnonVoteClient { } try { - const decrypted = decryptVote(encryptedPayload.trim(), key); + // encryptedPayload is stored as a JSON-serialised EncryptedPayload object + const parsed = JSON.parse(encryptedPayload.trim()); + const decrypted = decryptVote(parsed, key); // If decryption succeeded, the payload is valid return typeof decrypted === "string" && decrypted.length > 0; } catch { diff --git a/src/crypto.ts b/src/crypto.ts index fa59981..6f17870 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -4,30 +4,45 @@ import { createCipheriv, createDecipheriv, } from "crypto"; +import { EncryptedPayload, AnonVoteCryptoError } from "./types"; /** * SHA-256 hash of a voter identifier. * - * Used to store eligibility entries without retaining the original identifier. - * Input is trimmed and lowercased before hashing for consistency. + * Input is normalised — trimmed and lowercased — before hashing so that + * "alice@example.com", "Alice@example.com", and " alice@example.com " + * all produce the same hash. Without this, the one-person-one-vote + * guarantee breaks silently. + * + * An empty string and a whitespace-only string both normalise to "" and + * therefore produce the same hash. This is intentional. + * + * @returns 64-character lowercase hex string (SHA-256 digest) * * @example * const hash = hashIdentifier("alice@example.com"); */ -export function hashIdentifier(id: string): string { - return createHash("sha256").update(id.trim().toLowerCase()).digest("hex"); +export function hashIdentifier(identifier: string): string { + return createHash("sha256") + .update(identifier.trim().toLowerCase()) + .digest("hex"); } /** * Generate a cryptographically secure random voter token. * - * 32 bytes = 256 bits of entropy, hex encoded. - * The raw value is given to the voter — never persisted server-side. - * Use {@link hashToken} to store the server-side reference. + * Uses 32 bytes (256 bits) from Node.js `crypto.randomBytes`. + * The raw value is given to the voter — never persist it. + * Use {@link hashToken} to obtain the server-side reference to store. + * + * @warning Call this function fresh for every token. + * Do not store the return value and reuse it. + * + * @returns 64-character lowercase hex string * * @example * const rawToken = generateToken(); // give to voter - * const storedHash = hashToken(rawToken); // store this + * const storedHash = hashToken(rawToken); // store only this */ export function generateToken(): string { return randomBytes(32).toString("hex"); @@ -36,8 +51,16 @@ export function generateToken(): string { /** * SHA-256 hash of a raw voter token. * - * Only the hash is stored in the database — the raw token is never persisted. - * This enforces structural unlinkability between token issuance and vote submission. + * Only the hash is persisted server-side — the raw token is given to the + * voter and never stored. This enforces structural unlinkability between + * token issuance and vote submission. + * + * Token values are produced by {@link generateToken} and are already in + * canonical form — no normalisation is applied here. This function exists + * as a distinct export from {@link hashIdentifier} to make the two-step + * token design explicit and independently testable. + * + * @returns 64-character lowercase hex string (SHA-256 digest) * * @example * const hash = hashToken(rawToken); @@ -49,69 +72,101 @@ export function hashToken(token: string): string { /** * Encrypt a vote option ID using AES-256-GCM. * - * The encrypted payload stores only the selected option — no voter identity, - * no token value. Authenticated encryption ensures tampering is detectable. + * A fresh 12-byte IV is generated on every call — passing an IV as a + * parameter is intentionally not supported. A reused IV with the same + * key would break AES-GCM security completely and silently. + * + * The key must be exactly 64 hex characters (representing 32 bytes). + * Any other key throws {@link AnonVoteCryptoError} with code `INVALID_KEY` + * before any cipher operation begins. * - * @param optionId - The ballot option UUID to encrypt - * @param ballotKey - 64-char hex string (32 bytes), from BALLOT_ENCRYPTION_KEY env var - * @returns base64 string in format: `iv:authTag:ciphertext` + * All three output fields are lowercase hex strings. + * See DECISIONS.md ADR-001 for the rationale for hex over base64. + * + * @param optionId - The ballot option UUID to encrypt + * @param key - 64-character hex string representing 32 bytes + * @returns {@link EncryptedPayload} with hex-encoded ciphertext, iv, and authTag + * + * @throws {AnonVoteCryptoError} code `INVALID_KEY` if key is not 64 hex characters * * @example - * const encrypted = encryptVote("option-uuid", process.env.BALLOT_ENCRYPTION_KEY!); + * const payload = encryptVote("option-uuid", process.env.BALLOT_ENCRYPTION_KEY!); */ -export function encryptVote(optionId: string, ballotKey: string): string { - if (ballotKey.length !== 64) { - throw new Error( - "BALLOT_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)", +export function encryptVote(optionId: string, key: string): EncryptedPayload { + if (!/^[0-9a-fA-F]{64}$/.test(key)) { + throw new AnonVoteCryptoError( + "INVALID_KEY", + "key must be a 64-character hex string representing 32 bytes", ); } - const key = Buffer.from(ballotKey, "hex"); + const keyBuffer = Buffer.from(key, "hex"); const iv = randomBytes(12); // 96-bit IV for GCM - const cipher = createCipheriv("aes-256-gcm", key, iv); + const cipher = createCipheriv("aes-256-gcm", keyBuffer, iv); - const encrypted = Buffer.concat([ + const ciphertext = Buffer.concat([ cipher.update(optionId, "utf8"), cipher.final(), ]); const authTag = cipher.getAuthTag(); - return [ - iv.toString("base64"), - authTag.toString("base64"), - encrypted.toString("base64"), - ].join(":"); + return { + ciphertext: ciphertext.toString("hex"), + iv: iv.toString("hex"), + authTag: authTag.toString("hex"), + }; } /** - * Decrypt a vote payload encrypted with {@link encryptVote}. + * Decrypt a vote payload produced by {@link encryptVote}. * - * Should only be called by the result tally engine. Any payload tampering - * is detected and rejected by GCM authentication tag verification. + * Should only be called by the result tally engine. * - * @param payload - base64 string in format: `iv:authTag:ciphertext` - * @param ballotKey - 64-char hex string (32 bytes) + * GCM authentication tag verification is mandatory and is never swallowed. + * If `decipher.final()` throws (tampered ciphertext, wrong key, or wrong IV), + * the error propagates as an {@link AnonVoteCryptoError} with code + * `DECRYPTION_FAILED`. There is no silent failure mode — an exception is + * the only outcome when verification fails. + * + * @param payload - {@link EncryptedPayload} produced by {@link encryptVote} + * @param key - 64-character hex string representing 32 bytes * @returns the original optionId * + * @throws {AnonVoteCryptoError} code `INVALID_PAYLOAD` if any payload field is missing or empty + * @throws {AnonVoteCryptoError} code `DECRYPTION_FAILED` if auth tag verification fails + * * @example - * const optionId = decryptVote(encryptedPayload, process.env.BALLOT_ENCRYPTION_KEY!); + * const optionId = decryptVote(payload, process.env.BALLOT_ENCRYPTION_KEY!); */ -export function decryptVote(payload: string, ballotKey: string): string { - const parts = payload.split(":"); - if (parts.length !== 3) { - throw new Error( - "Invalid encrypted payload format. Expected iv:authTag:ciphertext", +export function decryptVote(payload: EncryptedPayload, key: string): string { + if ( + !payload.ciphertext || + !payload.iv || + !payload.authTag + ) { + throw new AnonVoteCryptoError( + "INVALID_PAYLOAD", + "payload must have non-empty ciphertext, iv, and authTag fields", ); } - const [ivB64, authTagB64, ciphertextB64] = parts; - const key = Buffer.from(ballotKey, "hex"); - const iv = Buffer.from(ivB64, "base64"); - const authTag = Buffer.from(authTagB64, "base64"); - const ciphertext = Buffer.from(ciphertextB64, "base64"); + const keyBuffer = Buffer.from(key, "hex"); + const ivBuffer = Buffer.from(payload.iv, "hex"); + const authTagBuffer = Buffer.from(payload.authTag, "hex"); + const ciphertextBuffer = Buffer.from(payload.ciphertext, "hex"); - const decipher = createDecipheriv("aes-256-gcm", key, iv); - decipher.setAuthTag(authTag); + const decipher = createDecipheriv("aes-256-gcm", keyBuffer, ivBuffer); + decipher.setAuthTag(authTagBuffer); - return decipher.update(ciphertext).toString("utf8") + decipher.final("utf8"); + try { + return ( + decipher.update(ciphertextBuffer).toString("utf8") + + decipher.final("utf8") + ); + } catch { + throw new AnonVoteCryptoError( + "DECRYPTION_FAILED", + "decryption failed — ciphertext may have been tampered with", + ); + } } diff --git a/src/index.ts b/src/index.ts index e2e89ca..3f87ff6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,17 @@ export { // Client SDK export { AnonVoteClient } from "./client"; -// Core types +// Crypto-primitive types (canonical, required by the issue) +export type { + EncryptedPayload, + Token, + Vote, + ElectionResult, + BallotEvent, +} from "./types"; +export { AnonVoteCryptoError } from "./types"; + +// Core / ecosystem types export type { BallotStatus, Option, @@ -25,7 +35,7 @@ export type { EligibilityList, EligibilityEntry, VoterToken, - Vote, + VoteRecord, Organization, Result, AuditEventType, @@ -41,4 +51,4 @@ export type { Election, ElectionOption, VoteReceipt, -} from "./types"; \ No newline at end of file +} from "./types"; diff --git a/src/types.ts b/src/types.ts index b1e62fc..26dcbe0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,85 @@ * any future consumer of the AnonVote protocol. */ +// ── Crypto primitives ───────────────────────────────────────────────────────── + +/** + * The output of {@link encryptVote}. + * All three fields are lowercase hex strings. + * See DECISIONS.md ADR-001 for the rationale for hex over base64. + */ +export interface EncryptedPayload { + /** Hex-encoded AES-256-GCM encrypted option ID. */ + ciphertext: string; + /** Hex-encoded 12-byte GCM initialisation vector. */ + iv: string; + /** Hex-encoded 16-byte GCM authentication tag. */ + authTag: string; +} + +/** + * A one-time anonymous voter token pair. + * `value` is given to the voter and never persisted server-side. + * `hash` is the SHA-256 hex hash of `value` — store only this. + */ +export interface Token { + /** Raw token — give to voter, never store. */ + value: string; + /** SHA-256 hash of value — store this. */ + hash: string; +} + +/** + * A ballot vote event. + */ +export interface Vote { + ballotId: string; + optionId: string; + /** Unix timestamp in milliseconds. */ + timestamp: number; +} + +/** + * Tally result for a single ballot. + * Maps each option ID to its vote count. + */ +export interface ElectionResult { + [optionId: string]: number; +} + +/** + * An event emitted to the Stellar audit trail. + */ +export interface BallotEvent { + event_type: "ballot_created" | "token_issued" | "vote_cast" | "result_published"; + ballot_id: string; + stellar_tx_id: string | null; + /** ISO 8601 timestamp. */ + created_at: string; +} + +/** + * Typed error thrown by cryptographic operations in this package. + * + * @example + * try { + * decryptVote(payload, key); + * } catch (err) { + * if (err instanceof AnonVoteCryptoError && err.code === 'DECRYPTION_FAILED') { + * // handle tampered payload + * } + * } + */ +export class AnonVoteCryptoError extends Error { + code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "AnonVoteCryptoError"; + this.code = code; + } +} + // ── Ballot ──────────────────────────────────────────────────────────────────── export type BallotStatus = "OPEN" | "CLOSED"; @@ -76,11 +155,11 @@ export interface VoterToken { // ── Vote ────────────────────────────────────────────────────────────────────── /** - * A submitted vote. + * A submitted vote record stored in the database. * `encryptedPayload` is the AES-256-GCM encrypted option ID. * See {@link encryptVote} and {@link decryptVote}. */ -export interface Vote { +export interface VoteRecord { id: string; ballotId: string; optionId: string; diff --git a/tests/client.test.ts b/tests/client.test.ts index f52da92..ab846ad 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -234,7 +234,12 @@ describe("AnonVoteClient", () => { expect(receipt.id.startsWith("receipt-")).toBe(true); expect(receipt.ballotId).toBe("elec-123"); expect(receipt.electionId).toBe("elec-123"); - expect(receipt.encryptedPayload).toMatch(/^[A-Za-z0-9+/=]+:/); + // encryptedPayload is a JSON-serialised EncryptedPayload object + expect(() => JSON.parse(receipt.encryptedPayload)).not.toThrow(); + const parsed = JSON.parse(receipt.encryptedPayload); + expect(parsed).toHaveProperty("ciphertext"); + expect(parsed).toHaveProperty("iv"); + expect(parsed).toHaveProperty("authTag"); expect(receipt.castAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(receipt.verified).toBe(false); }); @@ -246,9 +251,11 @@ describe("AnonVoteClient", () => { encryptionKey: TEST_KEY, }); - // The payload should have three parts: iv:authTag:ciphertext - const parts = receipt.encryptedPayload.split(":"); - expect(parts).toHaveLength(3); + // encryptedPayload is a JSON-serialised EncryptedPayload — parse and check all three hex fields + const parsed = JSON.parse(receipt.encryptedPayload); + expect(parsed.ciphertext).toMatch(/^[0-9a-f]+$/); + expect(parsed.iv).toMatch(/^[0-9a-f]+$/); + expect(parsed.authTag).toMatch(/^[0-9a-f]+$/); }); it("produces different encrypted payloads for the same vote (random IV)", () => { diff --git a/tests/crypto.test.ts b/tests/crypto.test.ts index e4712a9..5b9c9a6 100644 --- a/tests/crypto.test.ts +++ b/tests/crypto.test.ts @@ -5,93 +5,230 @@ import { encryptVote, decryptVote, } from "../src/crypto"; +import { AnonVoteCryptoError, EncryptedPayload } from "../src/types"; -const TEST_KEY = "a".repeat(64); // 32 bytes hex for tests +/** A valid 64-character hex key representing 32 bytes — used across encrypt/decrypt tests. */ +const TEST_KEY = "a".repeat(64); + +// ── hashIdentifier ──────────────────────────────────────────────────────────── describe("hashIdentifier", () => { - it("returns a 64-char hex string", () => { - expect(hashIdentifier("alice@example.com")).toHaveLength(64); - expect(hashIdentifier("alice@example.com")).toMatch(/^[0-9a-f]+$/); + it("returns a 64-character lowercase hex string", () => { + const result = hashIdentifier("alice@example.com"); + expect(result).toHaveLength(64); + expect(result).toMatch(/^[0-9a-f]{64}$/); }); - it("is deterministic", () => { + it("is deterministic — same input always produces same output", () => { expect(hashIdentifier("alice@example.com")).toBe( hashIdentifier("alice@example.com"), ); }); - it("trims and lowercases before hashing", () => { + it("normalises casing — Alice@example.com equals alice@example.com", () => { + expect(hashIdentifier("Alice@example.com")).toBe( + hashIdentifier("alice@example.com"), + ); + }); + + it("normalises whitespace — leading and trailing spaces are stripped", () => { + expect(hashIdentifier(" alice@example.com ")).toBe( + hashIdentifier("alice@example.com"), + ); + }); + + it("normalises both — uppercase with spaces equals lowercase", () => { expect(hashIdentifier(" Alice@Example.COM ")).toBe( hashIdentifier("alice@example.com"), ); }); - it("produces different hashes for different inputs", () => { + it("empty string and whitespace-only string produce the same hash", () => { + expect(hashIdentifier("")).toBe(hashIdentifier(" ")); + }); + + it("two meaningfully different inputs produce different hashes", () => { expect(hashIdentifier("alice@example.com")).not.toBe( hashIdentifier("bob@example.com"), ); }); }); +// ── generateToken ───────────────────────────────────────────────────────────── + describe("generateToken", () => { - it("returns a 64-char hex string (32 bytes)", () => { + it("returns a 64-character lowercase hex string", () => { const token = generateToken(); expect(token).toHaveLength(64); - expect(token).toMatch(/^[0-9a-f]+$/); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + + it("produces unique values — 1000 consecutive calls produce 1000 distinct tokens", () => { + const tokens = Array.from({ length: 1000 }, () => generateToken()); + const unique = new Set(tokens); + expect(unique.size).toBe(1000); }); - it("returns a different token each call", () => { - expect(generateToken()).not.toBe(generateToken()); + it("output contains only valid hex characters", () => { + for (let i = 0; i < 20; i++) { + expect(generateToken()).toMatch(/^[0-9a-f]+$/); + } }); }); +// ── hashToken ───────────────────────────────────────────────────────────────── + describe("hashToken", () => { - it("returns a 64-char hex string", () => { - expect(hashToken("mytoken")).toHaveLength(64); + it("returns a 64-character lowercase hex string", () => { + const result = hashToken("mytoken"); + expect(result).toHaveLength(64); + expect(result).toMatch(/^[0-9a-f]{64}$/); }); it("is deterministic", () => { expect(hashToken("mytoken")).toBe(hashToken("mytoken")); }); - it("differs from hashIdentifier for the same input", () => { - // hashToken does not trim/lowercase — they should differ + it("different tokens produce different hashes", () => { + expect(hashToken("token-a")).not.toBe(hashToken("token-b")); + }); + + it("output is distinct from hashIdentifier output for the same input", () => { + // hashToken does not normalise — hashIdentifier("ALICE") lowercases first + // so they hash different byte sequences and must differ expect(hashToken("ALICE")).not.toBe(hashIdentifier("ALICE")); }); }); -describe("encryptVote / decryptVote", () => { - it("round-trips correctly", () => { - const optionId = "option-uuid-1234"; - const encrypted = encryptVote(optionId, TEST_KEY); - expect(decryptVote(encrypted, TEST_KEY)).toBe(optionId); +// ── encryptVote ─────────────────────────────────────────────────────────────── + +describe("encryptVote", () => { + it("returns an EncryptedPayload with ciphertext, iv, and authTag as hex strings", () => { + const payload = encryptVote("option-uuid-1234", TEST_KEY); + expect(payload).toHaveProperty("ciphertext"); + expect(payload).toHaveProperty("iv"); + expect(payload).toHaveProperty("authTag"); + expect(payload.ciphertext).toMatch(/^[0-9a-f]+$/); + expect(payload.iv).toMatch(/^[0-9a-f]+$/); + expect(payload.authTag).toMatch(/^[0-9a-f]+$/); }); - it("produces different ciphertexts for the same input (random IV)", () => { + it("generates a unique IV on every call — same option and key produce different ciphertexts", () => { + const a = encryptVote("option-uuid-1234", TEST_KEY); + const b = encryptVote("option-uuid-1234", TEST_KEY); + expect(a.iv).not.toBe(b.iv); + expect(a.ciphertext).not.toBe(b.ciphertext); + }); + + it("throws AnonVoteCryptoError INVALID_KEY for a 32-character key", () => { + expect(() => encryptVote("opt", "a".repeat(32))).toThrow(AnonVoteCryptoError); + try { + encryptVote("opt", "a".repeat(32)); + } catch (err) { + expect((err as AnonVoteCryptoError).code).toBe("INVALID_KEY"); + } + }); + + it("throws AnonVoteCryptoError INVALID_KEY for a 128-character key", () => { + expect(() => encryptVote("opt", "a".repeat(128))).toThrow(AnonVoteCryptoError); + try { + encryptVote("opt", "a".repeat(128)); + } catch (err) { + expect((err as AnonVoteCryptoError).code).toBe("INVALID_KEY"); + } + }); + + it("throws AnonVoteCryptoError INVALID_KEY for a non-hex key", () => { + // 64 characters but contains non-hex chars + const nonHexKey = "z".repeat(64); + expect(() => encryptVote("opt", nonHexKey)).toThrow(AnonVoteCryptoError); + try { + encryptVote("opt", nonHexKey); + } catch (err) { + expect((err as AnonVoteCryptoError).code).toBe("INVALID_KEY"); + } + }); + + it("ciphertext length varies with input length", () => { + const short = encryptVote("a", TEST_KEY); + const long = encryptVote("a".repeat(200), TEST_KEY); + expect(long.ciphertext.length).toBeGreaterThan(short.ciphertext.length); + }); +}); + +// ── decryptVote ─────────────────────────────────────────────────────────────── + +describe("decryptVote", () => { + it("roundtrip — encryptVote then decryptVote returns the original optionId", () => { const optionId = "option-uuid-1234"; - expect(encryptVote(optionId, TEST_KEY)).not.toBe( - encryptVote(optionId, TEST_KEY), - ); + const payload = encryptVote(optionId, TEST_KEY); + expect(decryptVote(payload, TEST_KEY)).toBe(optionId); + }); + + it("roundtrip is stable across multiple encrypt-decrypt cycles", () => { + const optionId = "stable-option-id"; + for (let i = 0; i < 10; i++) { + const payload = encryptVote(optionId, TEST_KEY); + expect(decryptVote(payload, TEST_KEY)).toBe(optionId); + } + }); + + it("throws on a tampered ciphertext — single byte modification", () => { + const payload = encryptVote("option-uuid-1234", TEST_KEY); + // Flip the first byte of ciphertext + const tamperedCiphertext = + (parseInt(payload.ciphertext[0], 16) ^ 1).toString(16) + + payload.ciphertext.slice(1); + const tampered: EncryptedPayload = { + ...payload, + ciphertext: tamperedCiphertext, + }; + expect(() => decryptVote(tampered, TEST_KEY)).toThrow(); + }); + + it("throws on a tampered authTag — single byte modification", () => { + const payload = encryptVote("option-uuid-1234", TEST_KEY); + const tamperedAuthTag = + (parseInt(payload.authTag[0], 16) ^ 1).toString(16) + + payload.authTag.slice(1); + const tampered: EncryptedPayload = { ...payload, authTag: tamperedAuthTag }; + expect(() => decryptVote(tampered, TEST_KEY)).toThrow(); }); - it("encrypted payload has three base64 segments (iv:authTag:ciphertext)", () => { - const parts = encryptVote("opt-1", TEST_KEY).split(":"); - expect(parts).toHaveLength(3); - parts.forEach((p) => expect(p.length).toBeGreaterThan(0)); + it("throws on a tampered iv — single byte modification", () => { + const payload = encryptVote("option-uuid-1234", TEST_KEY); + const tamperedIv = + (parseInt(payload.iv[0], 16) ^ 1).toString(16) + payload.iv.slice(1); + const tampered: EncryptedPayload = { ...payload, iv: tamperedIv }; + expect(() => decryptVote(tampered, TEST_KEY)).toThrow(); }); - it("throws on invalid key length", () => { - expect(() => encryptVote("opt", "tooshort")).toThrow(); + it("throws AnonVoteCryptoError INVALID_PAYLOAD for missing ciphertext field", () => { + const payload = encryptVote("option-uuid-1234", TEST_KEY); + const broken = { ...payload, ciphertext: "" }; + expect(() => decryptVote(broken, TEST_KEY)).toThrow(AnonVoteCryptoError); + try { + decryptVote(broken, TEST_KEY); + } catch (err) { + expect((err as AnonVoteCryptoError).code).toBe("INVALID_PAYLOAD"); + } }); - it("throws on tampered ciphertext", () => { - const encrypted = encryptVote("option-uuid-1234", TEST_KEY); - const parts = encrypted.split(":"); - parts[2] = Buffer.from("tampered").toString("base64"); - expect(() => decryptVote(parts.join(":"), TEST_KEY)).toThrow(); + it("throws AnonVoteCryptoError INVALID_PAYLOAD for empty authTag", () => { + const payload = encryptVote("option-uuid-1234", TEST_KEY); + const broken = { ...payload, authTag: "" }; + expect(() => decryptVote(broken, TEST_KEY)).toThrow(AnonVoteCryptoError); + try { + decryptVote(broken, TEST_KEY); + } catch (err) { + expect((err as AnonVoteCryptoError).code).toBe("INVALID_PAYLOAD"); + } }); - it("throws on malformed payload", () => { - expect(() => decryptVote("notvalid", TEST_KEY)).toThrow(); + it("never returns wrong output silently — all failure modes throw", () => { + // Verify that a wrong key causes a throw, not a wrong decryption result + const payload = encryptVote("option-uuid-1234", TEST_KEY); + const wrongKey = "b".repeat(64); + expect(() => decryptVote(payload, wrongKey)).toThrow(); }); });