diff --git a/DECISIONS.md b/DECISIONS.md index 2d05b6a..88000b8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,34 @@ # 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. ## ADR-001: AnonVoteClient SDK — Subpath Export (Option B) **Status:** Accepted diff --git a/src/client.ts b/src/client.ts index 08c707c..91f7fa5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -247,8 +247,11 @@ export class AnonVoteClient { throw new ValidationError("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(); @@ -299,6 +302,9 @@ export class AnonVoteClient { } try { + // encryptedPayload is stored as a JSON-serialised EncryptedPayload object + const parsed = JSON.parse(encryptedPayload.trim()); + const decrypted = decryptVote(parsed, key); const decrypted = decryptVote(encryptedPayload, key); // If decryption succeeded, the payload is valid return typeof decrypted === "string" && decrypted.length > 0; diff --git a/src/crypto.ts b/src/crypto.ts index c71146a..dfb7d5d 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,3 +1,10 @@ +import { + createHash, + randomBytes, + createCipheriv, + createDecipheriv, +} from "crypto"; +import { EncryptedPayload, AnonVoteCryptoError } from "./types"; import type { EncryptedPayload } from "./types"; import { CryptoError, ValidationError } from "./errors"; @@ -93,6 +100,15 @@ import { EncryptedVote } from "./types"; /** * SHA-256 hash of a voter identifier. * + * 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) * Used to store eligibility entries without retaining the original identifier. * Input is normalized (see {@link normalizeIdentifier}) before hashing — * always normalize before hashing to avoid duplicate entries for the same @@ -113,6 +129,9 @@ import { EncryptedVote } from "./types"; * const hash = hashIdentifier("alice@example.com"); * // hash === "3d0a9f2e..." (deterministic for the same input) */ +export function hashIdentifier(identifier: string): string { + return createHash("sha256") + .update(identifier.trim().toLowerCase()) export function hashIdentifier(id: string): string { return getNodeCrypto() .createHash("sha256") @@ -123,6 +142,17 @@ export function hashIdentifier(id: string): string { /** * Generate a cryptographically secure random voter token. * + * 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 * Produces 32 bytes (256 bits) of entropy via Node.js `crypto.randomBytes`, * encoded as a 64-character hex string. The raw value is given to the voter — * never persisted server-side. Use {@link hashToken} to store the server-side @@ -147,6 +177,16 @@ export function generateToken(): string { /** * SHA-256 hash of a raw voter token. * + * 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) * Only the hash is stored in the database — the raw token is never persisted. * This enforces structural unlinkability between token issuance and vote * submission. The raw token should be discarded after hashing. @@ -172,6 +212,40 @@ export function hashToken(token: string): string { /** * Encrypt a vote option using AES-256-GCM. * + * 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. + * + * 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 payload = encryptVote("option-uuid", process.env.BALLOT_ENCRYPTION_KEY!); + */ +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 keyBuffer = Buffer.from(key, "hex"); + const iv = randomBytes(12); // 96-bit IV for GCM + const cipher = createCipheriv("aes-256-gcm", keyBuffer, iv); + + const ciphertext = Buffer.concat([ + cipher.update(optionId, "utf8"), * The encrypted payload stores only the selected option — no voter identity, * no token value. Authenticated encryption (GCM mode) ensures any tampering * is detectable at decryption time. @@ -212,6 +286,7 @@ export function encryptVote(option: string, key: string): EncryptedPayload { const authTag = cipher.getAuthTag(); return { + ciphertext: ciphertext.toString("hex"), ciphertext: encrypted.toString("hex"), iv: iv.toString("hex"), authTag: authTag.toString("hex"), @@ -219,8 +294,19 @@ export function encryptVote(option: string, key: string): EncryptedPayload { } /** - * 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. + * + * 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 * Should only be called by the result tally engine. GCM authentication tag * verification detects and rejects any payload that has been tampered with. * @@ -231,7 +317,21 @@ export function encryptVote(option: string, key: string): EncryptedPayload { * @param key - 64-char hex string (32 bytes) * @returns the original option string * + * @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(payload, process.env.BALLOT_ENCRYPTION_KEY!); + */ +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 option = decryptVote(encryptedPayload, process.env.BALLOT_ENCRYPTION_KEY!); * // option === "Yes" */ @@ -257,6 +357,25 @@ export function decryptVote(payload: EncryptedPayload, key: string): string { } } + 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", keyBuffer, ivBuffer); + decipher.setAuthTag(authTagBuffer); + + try { + return ( + decipher.update(ciphertextBuffer).toString("utf8") + + decipher.final("utf8") + ); + } catch { + throw new AnonVoteCryptoError( + "DECRYPTION_FAILED", + "decryption failed — ciphertext may have been tampered with", + ); + } /** * Verify that an encrypted vote payload corresponds to a given vote option. * diff --git a/src/index.ts b/src/index.ts index a4c9a0f..0962fe5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,17 @@ export type { RetryConfig } from "./types"; // Client SDK (low-level retry-aware client) export { AnonVoteClient } from "./client"; +// Crypto-primitive types (canonical, required by the issue) +export type { + EncryptedPayload, + Token, + Vote, + ElectionResult, + BallotEvent, +} from "./types"; +export { AnonVoteCryptoError } from "./types"; + +// Core / ecosystem types // AnonVoteClient HTTP SDK export { AnonVoteClient as AnonVoteHttpClient } from "./client/AnonVoteClient"; export type { AnonVoteClientConfig, UploadResult, TokenBatch, VoteResult, BallotResults, OptionResult, VerificationReport } from "./client/AnonVoteClient"; @@ -40,6 +51,7 @@ export type { EligibilityEntry, Token, VoterToken, + VoteRecord, Vote, EncryptedPayload, Organization, @@ -57,4 +69,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 68f0419..b0fe1f5 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"; @@ -86,6 +165,7 @@ export interface VoterToken { // ── Vote ────────────────────────────────────────────────────────────────────── /** + * A submitted vote record stored in the database. * An encrypted vote payload. * * AES-256-GCM produces three outputs: @@ -109,6 +189,8 @@ export interface EncryptedVote { * See {@link encryptVote} and {@link decryptVote}. * A raw vote, prior to encryption. */ +export interface VoteRecord { + id: string; export interface Vote { ballotId: string; option: string; diff --git a/tests/client.test.ts b/tests/client.test.ts index 52203ba..7551f1a 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -240,6 +240,12 @@ describe("AnonVoteClient", () => { expect(receipt.id.startsWith("receipt-")).toBe(true); expect(receipt.ballotId).toBe("elec-123"); expect(receipt.electionId).toBe("elec-123"); + // 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.encryptedPayload).toEqual(ENCRYPTED_PAYLOAD_SHAPE); expect(receipt.castAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(receipt.verified).toBe(false); @@ -252,6 +258,11 @@ describe("AnonVoteClient", () => { encryptionKey: TEST_KEY, }); + // 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]+$/); // The payload should have ciphertext, iv, and authTag as hex strings expect(receipt.encryptedPayload).toEqual(ENCRYPTED_PAYLOAD_SHAPE); }); diff --git a/tests/crypto.test.ts b/tests/crypto.test.ts index 990ac7f..5ab8e5a 100644 --- a/tests/crypto.test.ts +++ b/tests/crypto.test.ts @@ -7,28 +7,53 @@ decryptVote, verifyVoteHash, } 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-character lowercase hex string", () => { + const result = hashIdentifier("alice@example.com"); + expect(result).toHaveLength(64); + expect(result).toMatch(/^[0-9a-f]{64}$/); it("returns a 64-char hex string", () => { const hash = hashIdentifier("alice@example.com"); expect(hash).toHaveLength(64); expect(hash).toMatch(/^[0-9a-f]+$/); }); - 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("empty string and whitespace-only string produce the same hash", () => { + expect(hashIdentifier("")).toBe(hashIdentifier(" ")); + }); + + it("two meaningfully different inputs produce different hashes", () => { it("normalizes case: alice@example.com === Alice@example.com", () => { expect(hashIdentifier("alice@example.com")).toBe( hashIdentifier("Alice@example.com"), @@ -90,15 +115,25 @@ describe("hashIdentifier", () => { }); }); +// ── 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]+$/); + } }); it("produces 1000 unique values across consecutive calls", () => { @@ -155,16 +190,26 @@ describe("generateToken", () => { }); }); +// ── hashToken ───────────────────────────────────────────────────────────────── + describe("hashToken", () => { - it("returns a 64-char hex string", () => { - expect(hashToken("mytoken")).toHaveLength(64); - expect(hashToken("mytoken")).toMatch(/^[0-9a-f]+$/); + 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("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 it("produces different hashes for different tokens", () => { expect(hashToken("token-a")).not.toBe(hashToken("token-b")); }); @@ -175,6 +220,17 @@ describe("hashToken", () => { }); }); +// ── 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]+$/); describe("generateBallotKey", () => { it("returns a 44-char base64 string (32 bytes)", () => { const key = generateBallotKey(); @@ -199,8 +255,123 @@ describe("encryptVote / decryptVote", () => { expect(decryptVote(encrypted, TEST_KEY)).toBe(option); }); - 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"; + 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("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 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 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("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(); const encrypted1 = encryptVote(optionId, TEST_KEY); const encrypted2 = encryptVote(optionId, TEST_KEY); expect(encrypted1.ciphertext).not.toBe(encrypted2.ciphertext);