From c7baa6b7b3dadbe7fd55e3f06d8dc94c26ad4edd Mon Sep 17 00:00:00 2001 From: elchapo Date: Tue, 28 Jul 2026 12:08:31 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20AnonVoteClient=20SDK=20=E2=80=94=20subp?= =?UTF-8?q?ath=20export=20@anonvote/crypto/client=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DECISIONS.md | 58 +++++ README.md | 67 ++++-- package.json | 5 + src/client/index.ts | 410 ++++++++++++++++++++++++++++++++++++ src/client/types.ts | 105 +++++++++ test-consumer/index.ts | 52 +++++ test-consumer/tsconfig.json | 14 ++ tests/sdk-client.test.ts | 370 ++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 9 files changed, 1061 insertions(+), 22 deletions(-) create mode 100644 DECISIONS.md create mode 100644 src/client/index.ts create mode 100644 src/client/types.ts create mode 100644 test-consumer/index.ts create mode 100644 test-consumer/tsconfig.json create mode 100644 tests/sdk-client.test.ts diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..2d05b6a --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,58 @@ +# Architecture Decisions + +## ADR-001: AnonVoteClient SDK — Subpath Export (Option B) + +**Status:** Accepted +**Date:** 2026-07-28 + +### Context + +`@anonvote/crypto` exports five low-level cryptographic primitives. A higher-level +`AnonVoteClient` SDK needed to be added. Two placement options were considered: + +**Option A** — Add `src/client.ts` to the existing package and export `AnonVoteClient` +alongside the primitives from `src/index.ts`. One package, one entry point. + +**Option B** — Create `src/client/` with its own entry point and expose it as the +subpath export `@anonvote/crypto/client`. Primitives and client are imported separately. + +### Decision + +**Option B — subpath export** was chosen. + +Rationale: + +- **Tree-shaking.** Consumers who only need the raw primitives (`encryptVote`, + `hashToken`, etc.) do not pay the cost of importing the client code. The subpath + makes the import graph explicit. +- **Separation of concerns.** The SDK layer has different stability guarantees and + a different change cadence than the primitives. A separate entry point makes that + boundary clear. +- **Node.js 12+ subpath exports** are already a standard pattern and the package is + already in a TypeScript + CommonJS configuration that supports them with no extra + tooling. +- **Explicit API surface.** Developers importing `@anonvote/crypto/client` signal + intent — they want the SDK, not just the primitives. + +### Consequences + +`package.json` gains an `exports` field: + +```json +{ + "exports": { + ".": "./dist/index.js", + "./client": "./dist/client/index.js" + } +} +``` + +`tsconfig.json` `include` must cover `src/client/`. + +New files created: +- `src/client/types.ts` — domain-level SDK types +- `src/client/index.ts` — `AnonVoteClient` class + +The existing `src/client.ts` (lower-level, retry-focused) is preserved and continues +to be exported from the root entry point. The new `src/client/index.ts` is the +developer-facing SDK. diff --git a/README.md b/README.md index 556bbfb..2f51f1a 100644 --- a/README.md +++ b/README.md @@ -75,35 +75,54 @@ const optionId = decryptVote(encrypted, BALLOT_KEY); --- -## Usage: AnonVoteClient +## Usage: AnonVoteClient SDK + +The AnonVoteClient SDK is the recommended way to integrate AnonVote into your application. It lives at the `@anonvote/crypto/client` subpath so consumers of only the raw primitives don't pay the import cost. + +```bash +npm install @anonvote/crypto +``` ```typescript -import { AnonVoteClient } from "@anonvote/crypto"; +import { randomBytes } from "crypto"; +import { AnonVoteClient } from "@anonvote/crypto/client"; -const client = new AnonVoteClient({ - encryptionKey: process.env.BALLOT_ENCRYPTION_KEY!, -}); +// Generate a fresh key per ballot — never reuse across ballots +const ballotKey = randomBytes(32).toString("hex"); -// Create an election +const client = new AnonVoteClient({ ballotKey }); + +// 1. Create an election (pure client-side, no network) const election = client.createElection({ - title: "Board Election 2024", - description: "Elect the new board members", - options: ["Alice", "Bob", "Charlie"], - startTime: Date.now(), - endTime: Date.now() + 7 * 24 * 60 * 60 * 1000, + title: "Board Election 2026", + description: "Elect two new board members.", + options: ["Alice", "Bob", "Abstain"], + startTime: new Date(), + endTime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }); -// Cast a vote using the election returned above -const receipt = client.castVote({ - ballotId: election.id, - voteOption: election.options[0].text, -}); +// 2. Cast a vote — pass the option UUID, not the label +const ballot = client.castVote(election, election.options[0].id); + +// 3. Verify locally before submitting +const result = client.verifyVote(ballot); +console.log(result.confirmed); // true -// Verify the receipt returned by castVote -const isValid = client.verifyVote(receipt.encryptedPayload); -console.log(isValid); // true +// 4. Serialize for server submission — optionId is intentionally excluded +const json = client.serialize(ballot); +await fetch("/api/votes", { method: "POST", body: json }); + +// 5. Deserialize a stored ballot +const restored = client.deserialize(json); ``` +### Key guarantees + +- The constructor throws immediately if `ballotKey` is not a valid 64-character hex string — misconfigured clients fail at construction, not at the first crypto operation. +- `castVote` never logs the `optionId`. The option the voter chose stays local. +- `serialize` omits `optionId` — only the encrypted payload reaches the server. +- `verifyVote` propagates decryption errors rather than silently returning `false`. A corrupted payload is a different failure mode from an option mismatch. + --- ## Environment variables @@ -249,15 +268,21 @@ The `no-console` rule is enforced as an error. If lint flags a `console.*` in `s ``` js/ ├── src/ -│ ├── crypto.ts # Core cryptographic functions -│ ├── client.ts # AnonVoteClient SDK +│ ├── client/ +│ │ ├── index.ts # AnonVoteClient SDK (@anonvote/crypto/client) +│ │ └── types.ts # Domain-level SDK types +│ ├── crypto.ts # Core cryptographic primitives +│ ├── client.ts # Low-level retry-aware client (root export) │ ├── errors.ts # Error classes +│ ├── retry.ts # Exponential backoff retry utility │ ├── types.ts # Shared TypeScript types │ └── index.ts # Public API re-exports ├── tests/ │ ├── crypto.test.ts │ ├── client.test.ts +│ ├── sdk-client.test.ts # AnonVoteClient SDK tests (issue #42) │ └── errors.test.ts +├── DECISIONS.md # Architecture decision records ├── package.json └── tsconfig.json ``` diff --git a/package.json b/package.json index 2e740f7..d697000 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,11 @@ "import": "./dist/index.js", "require": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./client": { + "import": "./dist/client/index.js", + "require": "./dist/client/index.js", + "types": "./dist/client/index.d.ts" } }, "files": [ diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..282b55d --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,410 @@ +import { randomBytes } from "crypto"; +import { encryptVote, decryptVote } from "../crypto"; +import { ValidationError } from "../errors"; +import type { + ClientConfig, + ElectionOptions, + Election, + ElectionOption, + Ballot, + VerificationResult, +} from "./types"; + +export type { + ClientConfig, + ElectionOptions, + Election, + ElectionOption, + Ballot, + VoteReceipt, + VerificationResult, +} from "./types"; + +/** Regex that matches a valid 64-character lowercase hex string. */ +const HEX_64 = /^[0-9a-f]{64}$/i; + +/** Generates a v4-like UUID from 16 random bytes. */ +function generateUUID(): string { + const b = randomBytes(16); + // Set version bits (v4) and variant bits per RFC 4122 + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + const h = b.toString("hex"); + return [ + h.slice(0, 8), + h.slice(8, 12), + h.slice(12, 16), + h.slice(16, 20), + h.slice(20, 32), + ].join("-"); +} + +/** Returns the derived status of an election relative to now. */ +function deriveStatus(election: Election): Election["status"] { + const now = Date.now(); + if (now < election.startTime.getTime()) return "draft"; + if (now > election.endTime.getTime()) return "closed"; + return "active"; +} + +/** + * AnonVoteClient — the developer-facing SDK for the AnonVote ecosystem. + * + * Wraps the low-level cryptographic primitives in an opinionated, minimal API + * that enforces correct usage patterns. It is impossible to use this client in + * a way that violates the AnonVote privacy model. + * + * @example + * ```typescript + * import { AnonVoteClient } from "@anonvote/crypto/client"; + * import { randomBytes } from "crypto"; + * + * const client = new AnonVoteClient({ + * ballotKey: randomBytes(32).toString("hex"), + * }); + * + * const election = client.createElection({ + * title: "Board vote 2026", + * description: "Elect the new board.", + * options: ["Alice", "Bob"], + * startTime: new Date(), + * endTime: new Date(Date.now() + 86_400_000), + * }); + * + * const ballot = client.castVote(election, election.options[0].id); + * const result = client.verifyVote(ballot); + * console.log(result.confirmed); // true + * ``` + */ +export class AnonVoteClient { + private readonly config: ClientConfig; + + /** + * Creates a new AnonVoteClient. + * + * @param config - Client configuration containing the per-ballot encryption key. + * @throws {ValidationError} INVALID_KEY — if `ballotKey` is not a 64-character hex string. + * + * @example + * ```typescript + * const client = new AnonVoteClient({ + * ballotKey: randomBytes(32).toString("hex"), + * }); + * ``` + */ + constructor(config: ClientConfig) { + if (!HEX_64.test(config.ballotKey)) { + throw new ValidationError( + "INVALID_KEY: ballotKey must be a 64-character hex string (32 bytes). " + + "Generate one with: crypto.randomBytes(32).toString('hex')", + ); + } + this.config = config; + } + + /** + * Creates a new election object. + * + * This is a pure client-side operation — no network calls are made. + * Generates a UUID for the election and for each option. Option UUIDs + * (not labels) are what get passed to castVote, ensuring no option text + * ever reaches the encryption layer. + * + * @description Creates and returns an Election with unique UUIDs for the + * election ID and every option ID. All validation is performed before any + * IDs are generated. + * + * @param options - Election creation parameters. + * @returns A fully formed {@link Election} object ready for use with castVote. + * + * @throws {ValidationError} INVALID_ELECTION — fewer than 2 options. + * @throws {ValidationError} INVALID_ELECTION — more than 10 options. + * @throws {ValidationError} INVALID_ELECTION — endTime is not after startTime. + * @throws {ValidationError} INVALID_ELECTION — endTime is in the past. + * + * @example + * ```typescript + * const election = client.createElection({ + * title: "Budget vote", + * description: "Approve or reject the Q3 budget.", + * options: ["Approve", "Reject"], + * startTime: new Date(), + * endTime: new Date(Date.now() + 7 * 86_400_000), + * }); + * ``` + */ + createElection(options: ElectionOptions): Election { + if (!options.title || options.title.trim().length === 0) { + throw new ValidationError("INVALID_ELECTION: title is required"); + } + if (!options.description || options.description.trim().length === 0) { + throw new ValidationError("INVALID_ELECTION: description is required"); + } + if (!Array.isArray(options.options) || options.options.length < 2) { + throw new ValidationError( + "INVALID_ELECTION: options must contain at least 2 entries", + ); + } + if (options.options.length > 10) { + throw new ValidationError( + "INVALID_ELECTION: options must contain at most 10 entries", + ); + } + if (!(options.startTime instanceof Date) || isNaN(options.startTime.getTime())) { + throw new ValidationError("INVALID_ELECTION: startTime must be a valid Date"); + } + if (!(options.endTime instanceof Date) || isNaN(options.endTime.getTime())) { + throw new ValidationError("INVALID_ELECTION: endTime must be a valid Date"); + } + if (options.endTime.getTime() <= options.startTime.getTime()) { + throw new ValidationError("INVALID_ELECTION: endTime must be after startTime"); + } + if (options.endTime.getTime() <= Date.now()) { + throw new ValidationError("INVALID_ELECTION: endTime must be in the future"); + } + + const electionOptions: ElectionOption[] = options.options.map( + (label, index) => ({ + id: generateUUID(), + label, + index, + }), + ); + + const election: Election = { + id: generateUUID(), + title: options.title, + description: options.description, + options: electionOptions, + startTime: options.startTime, + endTime: options.endTime, + createdAt: new Date(), + status: "draft", + }; + + // status is computed dynamically — set it now based on current time + election.status = deriveStatus(election); + + return election; + } + + /** + * Casts a vote in an election. + * + * Validates the optionId against the election's options and checks that the + * election is currently active. Encrypts the optionId using AES-256-GCM. + * The returned Ballot contains the optionId locally so the voter can confirm + * their choice before submission — it is not included in the serialized payload. + * + * @description Encrypts the selected optionId and returns a Ballot. The + * optionId is never logged. Only the encryptedPayload is suitable for + * server submission. + * + * @param election - The election to vote in, as returned by createElection. + * @param optionId - The ID of the chosen option (from election.options[n].id). + * @returns A {@link Ballot} with the encrypted payload and local optionId. + * + * @throws {ValidationError} INVALID_OPTION — optionId not found in election.options. + * @throws {ValidationError} ELECTION_NOT_ACTIVE — election status is not active + * or current time is outside [startTime, endTime]. + * + * @security The optionId is never logged. Only encryptedPayload leaves this + * method in a form suitable for server submission. The optionId in the + * returned Ballot is local only and must not be sent to the server. + * + * @example + * ```typescript + * const ballot = client.castVote(election, election.options[0].id); + * const serialized = client.serialize(ballot); // safe to send to server + * ``` + */ + castVote(election: Election, optionId: string): Ballot { + const option = election.options.find((o) => o.id === optionId); + if (!option) { + throw new ValidationError( + "INVALID_OPTION: optionId does not match any option in this election", + ); + } + + const now = Date.now(); + const isActive = + election.status === "active" && + now >= election.startTime.getTime() && + now <= election.endTime.getTime(); + + if (!isActive) { + throw new ValidationError( + "ELECTION_NOT_ACTIVE: this election is not currently accepting votes", + ); + } + + const encryptedPayload = encryptVote(optionId, this.config.ballotKey); + + return { + electionId: election.id, + optionId, + encryptedPayload, + createdAt: new Date(), + }; + } + + /** + * Verifies a ballot locally without contacting the server. + * + * Decrypts the ballot's encryptedPayload and confirms the result matches + * the ballot's optionId. If decryptVote throws, the error is propagated — + * a decryption failure is a different failure mode from an option mismatch + * and must surface to the caller. + * + * @description Local verification that a ballot produced by castVote can be + * successfully decrypted and that the decrypted value matches optionId. + * + * @param ballot - The ballot to verify, as returned by castVote. + * @returns {@link VerificationResult} with confirmed: true if the decrypted + * value matches ballot.optionId, confirmed: false if they differ. + * + * @throws {CryptoError} If decryptVote fails — payload is corrupted or key + * is wrong. This is intentionally not caught; callers must handle it. + * + * @example + * ```typescript + * const result = client.verifyVote(ballot); + * if (!result.confirmed) { + * throw new Error("Ballot integrity check failed"); + * } + * ``` + */ + verifyVote(ballot: Ballot): VerificationResult { + // decryptVote errors propagate — do NOT catch them here + const decrypted = decryptVote(ballot.encryptedPayload, this.config.ballotKey); + + return { + confirmed: decrypted === ballot.optionId, + electionId: ballot.electionId, + checkedAt: new Date(), + }; + } + + /** + * Serializes a Ballot to a deterministic JSON string for server submission. + * + * Keys are sorted alphabetically so the same ballot always produces the + * same string. Only electionId and encryptedPayload are included — the + * optionId is deliberately omitted. + * + * @description Converts a Ballot to a stable JSON string. Only fields safe + * for server submission are included. + * + * @param ballot - The ballot to serialize, as returned by castVote. + * @returns A deterministic JSON string containing electionId and + * encryptedPayload only. + * + * @security The optionId is intentionally excluded. The option the voter + * chose must never leave the client in plaintext — only the encrypted + * payload is sent to the server. Including optionId here would break the + * privacy model. + * + * @example + * ```typescript + * const json = client.serialize(ballot); + * await fetch("/api/votes", { method: "POST", body: json }); + * ``` + */ + serialize(ballot: Ballot): string { + // Sort keys alphabetically for deterministic output. + // optionId is intentionally excluded — see @security above. + const payload = { + electionId: ballot.electionId, + encryptedPayload: { + authTag: ballot.encryptedPayload.authTag, + ciphertext: ballot.encryptedPayload.ciphertext, + iv: ballot.encryptedPayload.iv, + }, + }; + return JSON.stringify(payload); + } + + /** + * Deserializes a JSON string produced by serialize back into a Ballot. + * + * Validates that electionId is a non-empty string and that encryptedPayload + * contains ciphertext, iv, and authTag. The returned Ballot has no optionId — + * it was never serialized, by design. + * + * @description Parses a serialized ballot string and validates its structure. + * The resulting Ballot has optionId set to an empty string because the option + * ID was never included in the serialized form. + * + * @param serialized - A JSON string produced by serialize. + * @returns A {@link Ballot} without optionId (empty string). + * + * @throws {ValidationError} INVALID_SERIALIZED_BALLOT — if the JSON is + * malformed or required fields are missing or invalid. + * + * @example + * ```typescript + * const ballot = client.deserialize(storedJson); + * // ballot.optionId === "" — not included in serialized form by design + * ``` + */ + deserialize(serialized: string): Ballot { + let parsed: unknown; + try { + parsed = JSON.parse(serialized); + } catch { + throw new ValidationError( + "INVALID_SERIALIZED_BALLOT: input is not valid JSON", + ); + } + + if (!parsed || typeof parsed !== "object") { + throw new ValidationError( + "INVALID_SERIALIZED_BALLOT: expected a JSON object", + ); + } + + const obj = parsed as Record; + + if (!obj.electionId || typeof obj.electionId !== "string") { + throw new ValidationError( + "INVALID_SERIALIZED_BALLOT: missing or invalid electionId", + ); + } + + const ep = obj.encryptedPayload; + if (!ep || typeof ep !== "object") { + throw new ValidationError( + "INVALID_SERIALIZED_BALLOT: missing encryptedPayload", + ); + } + + const epObj = ep as Record; + if (!epObj.ciphertext || typeof epObj.ciphertext !== "string") { + throw new ValidationError( + "INVALID_SERIALIZED_BALLOT: encryptedPayload missing ciphertext", + ); + } + if (!epObj.iv || typeof epObj.iv !== "string") { + throw new ValidationError( + "INVALID_SERIALIZED_BALLOT: encryptedPayload missing iv", + ); + } + if (!epObj.authTag || typeof epObj.authTag !== "string") { + throw new ValidationError( + "INVALID_SERIALIZED_BALLOT: encryptedPayload missing authTag", + ); + } + + return { + electionId: obj.electionId, + // optionId is not in the serialized form by design + optionId: "", + encryptedPayload: { + ciphertext: epObj.ciphertext, + iv: epObj.iv, + authTag: epObj.authTag, + }, + createdAt: new Date(), + }; + } +} diff --git a/src/client/types.ts b/src/client/types.ts new file mode 100644 index 0000000..acabda0 --- /dev/null +++ b/src/client/types.ts @@ -0,0 +1,105 @@ +import type { EncryptedPayload } from "../types"; + +/** + * Configuration for AnonVoteClient. + * + * @example + * ```typescript + * import { AnonVoteClient } from "@anonvote/crypto/client"; + * + * const client = new AnonVoteClient({ + * ballotKey: crypto.randomBytes(32).toString("hex"), + * }); + * ``` + */ +export interface ClientConfig { + /** + * The per-ballot encryption key as a 64-character hex string (32 bytes). + * + * Must be generated fresh per ballot using `crypto.randomBytes(32).toString("hex")`. + * Must never be the same key across two ballots. + * Must never be stored in the database alongside encrypted votes. + */ + ballotKey: string; +} + +/** + * Input parameters for creating a new election. + */ +export interface ElectionOptions { + /** The title of the election. */ + title: string; + /** A description of the election. */ + description: string; + /** + * The available voting options. Minimum 2, maximum 10. + * These labels are never encrypted — only the generated option UUIDs reach the crypto layer. + */ + options: string[]; + /** When the election opens for voting. */ + startTime: Date; + /** When the election closes. Must be after startTime and in the future. */ + endTime: Date; +} + +/** + * A single option within an election. + */ +export interface ElectionOption { + /** UUID generated per option — used as the optionId in votes. */ + id: string; + /** The display label shown to voters. */ + label: string; + /** Zero-based index of this option in the original options array. */ + index: number; +} + +/** + * An election created by AnonVoteClient. + */ +export interface Election { + /** UUID generated by createElection. */ + id: string; + title: string; + description: string; + options: ElectionOption[]; + startTime: Date; + endTime: Date; + createdAt: Date; + status: "draft" | "active" | "closed" | "finalised"; +} + +/** + * An encrypted ballot produced by castVote. + * + * The optionId field is present locally so the voter can confirm their choice + * before submission. It is deliberately omitted when serialized for the server. + */ +export interface Ballot { + electionId: string; + /** The UUID of the chosen option. Present locally; never serialized to the server. */ + optionId: string; + /** The AES-256-GCM encrypted payload — the only part sent to the server. */ + encryptedPayload: EncryptedPayload; + createdAt: Date; +} + +/** + * A receipt confirming a vote was cast and the token used. + */ +export interface VoteReceipt { + electionId: string; + /** SHA-256 hash of the voter's token — proof of participation. */ + tokenHash: string; + ballot: Ballot; + submittedAt: Date; +} + +/** + * Result of verifying a ballot locally. + */ +export interface VerificationResult { + confirmed: boolean; + electionId: string; + checkedAt: Date; +} diff --git a/test-consumer/index.ts b/test-consumer/index.ts new file mode 100644 index 0000000..12d0ebe --- /dev/null +++ b/test-consumer/index.ts @@ -0,0 +1,52 @@ +/** + * Minimal test consumer — validates that @anonvote/crypto/client resolves + * correctly and that the public API is usable from a consuming TypeScript project. + * + * Run with: npx ts-node --project tsconfig.json index.ts + * (from the test-consumer/ directory) + */ +import { randomBytes } from "crypto"; +import { AnonVoteClient } from "@anonvote/crypto/client"; +import type { ClientConfig, Election, Ballot, VerificationResult } from "@anonvote/crypto/client"; + +// 1 — Constructor validates ballotKey at instantiation time +const ballotKey: string = randomBytes(32).toString("hex"); + +const config: ClientConfig = { ballotKey }; +const client = new AnonVoteClient(config); + +// 2 — createElection returns a typed Election +const election: Election = client.createElection({ + title: "Consumer test election", + description: "Verifying subpath export resolves correctly.", + options: ["Yes", "No"], + startTime: new Date(Date.now() - 1000), + endTime: new Date(Date.now() + 86_400_000), +}); + +console.log("election.id:", election.id); +console.log("options:", election.options.map((o) => `${o.label} (${o.id})`)); + +// 3 — castVote returns a typed Ballot +const ballot: Ballot = client.castVote(election, election.options[0].id); +console.log("ballot.electionId:", ballot.electionId); +console.log("has encryptedPayload:", !!ballot.encryptedPayload.ciphertext); + +// 4 — verifyVote returns a typed VerificationResult +const result: VerificationResult = client.verifyVote(ballot); +console.log("verified:", result.confirmed); // true + +// 5 — serialize omits optionId +const json = client.serialize(ballot); +const parsed = JSON.parse(json) as Record; +if ("optionId" in parsed) { + throw new Error("FAIL: optionId must not appear in serialized output"); +} +console.log("serialized (no optionId):", json); + +// 6 — deserialize round-trip +const restored = client.deserialize(json); +console.log("restored.electionId:", restored.electionId); +console.log("restored.optionId:", JSON.stringify(restored.optionId)); // "" + +console.log("\nAll test-consumer checks passed."); diff --git a/test-consumer/tsconfig.json b/test-consumer/tsconfig.json new file mode 100644 index 0000000..7b075b0 --- /dev/null +++ b/test-consumer/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@anonvote/crypto/client": ["../src/client/index.ts"] + } + }, + "include": ["index.ts"] +} diff --git a/tests/sdk-client.test.ts b/tests/sdk-client.test.ts new file mode 100644 index 0000000..6fbf51f --- /dev/null +++ b/tests/sdk-client.test.ts @@ -0,0 +1,370 @@ +/** + * Test suite for AnonVoteClient SDK — @anonvote/crypto/client + * All 22 required cases from issue #42. + */ +import { randomBytes } from "crypto"; +import { AnonVoteClient } from "../src/client/index"; +import type { + ClientConfig, + Election, + Ballot, + VoteReceipt, +} from "../src/client/types"; +import { ValidationError } from "../src/errors"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** A valid 64-char hex key for all tests. */ +const VALID_KEY = "a".repeat(64); + +/** Returns an election that is currently active (starts in the past, ends in the future). */ +function makeActiveElection(client: AnonVoteClient): Election { + const election = client.createElection({ + title: "Test Election", + description: "A test", + options: ["Alpha", "Beta"], + startTime: new Date(Date.now() - 1000), + endTime: new Date(Date.now() + 86_400_000), + }); + return election; +} + +// ── constructor ──────────────────────────────────────────────────────────── + +describe("AnonVoteClient constructor", () => { + it("throws INVALID_KEY for a 32-character ballotKey", () => { + expect( + () => new AnonVoteClient({ ballotKey: "a".repeat(32) }), + ).toThrow("INVALID_KEY"); + }); + + it("throws INVALID_KEY for a non-hex ballotKey", () => { + expect( + () => new AnonVoteClient({ ballotKey: "z".repeat(64) }), + ).toThrow("INVALID_KEY"); + }); + + it("instantiates successfully with a valid 64-character hex key", () => { + expect(() => new AnonVoteClient({ ballotKey: VALID_KEY })).not.toThrow(); + }); +}); + +// ── createElection ───────────────────────────────────────────────────────── + +describe("createElection", () => { + let client: AnonVoteClient; + beforeEach(() => { + client = new AnonVoteClient({ ballotKey: VALID_KEY }); + }); + + it("returns an Election with unique IDs for the election and each option", () => { + const e1 = makeActiveElection(client); + const e2 = makeActiveElection(client); + + expect(e1.id).not.toBe(e2.id); + expect(e1.options[0].id).not.toBe(e1.options[1].id); + }); + + it("throws INVALID_ELECTION for fewer than 2 options", () => { + expect(() => + client.createElection({ + title: "T", + description: "D", + options: ["Only one"], + startTime: new Date(), + endTime: new Date(Date.now() + 1000), + }), + ).toThrow("INVALID_ELECTION"); + }); + + it("throws INVALID_ELECTION for more than 10 options", () => { + expect(() => + client.createElection({ + title: "T", + description: "D", + options: Array.from({ length: 11 }, (_, i) => `Option ${i}`), + startTime: new Date(), + endTime: new Date(Date.now() + 1000), + }), + ).toThrow("INVALID_ELECTION"); + }); + + it("throws INVALID_ELECTION when endTime is before startTime", () => { + expect(() => + client.createElection({ + title: "T", + description: "D", + options: ["A", "B"], + startTime: new Date(Date.now() + 10_000), + endTime: new Date(Date.now() + 5_000), + }), + ).toThrow("INVALID_ELECTION"); + }); + + it("throws INVALID_ELECTION when endTime is in the past", () => { + expect(() => + client.createElection({ + title: "T", + description: "D", + options: ["A", "B"], + startTime: new Date(Date.now() - 10_000), + endTime: new Date(Date.now() - 1_000), + }), + ).toThrow("INVALID_ELECTION"); + }); + + it("option IDs are UUIDs — not the option label text", () => { + const election = makeActiveElection(client); + for (const opt of election.options) { + // UUID v4 pattern: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + expect(opt.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(opt.id).not.toBe(opt.label); + } + }); +}); + +// ── castVote ─────────────────────────────────────────────────────────────── + +describe("castVote", () => { + let client: AnonVoteClient; + let election: Election; + + beforeEach(() => { + client = new AnonVoteClient({ ballotKey: VALID_KEY }); + election = makeActiveElection(client); + }); + + it("returns a Ballot with an EncryptedPayload", () => { + const ballot = client.castVote(election, election.options[0].id); + + expect(ballot.electionId).toBe(election.id); + expect(ballot.encryptedPayload).toMatchObject({ + ciphertext: expect.stringMatching(/^[0-9a-f]+$/), + iv: expect.stringMatching(/^[0-9a-f]+$/), + authTag: expect.stringMatching(/^[0-9a-f]+$/), + }); + }); + + it("throws INVALID_OPTION for an optionId not in the election", () => { + expect(() => client.castVote(election, "not-a-real-uuid")).toThrow( + "INVALID_OPTION", + ); + }); + + it("throws ELECTION_NOT_ACTIVE for a closed election", () => { + const closed = client.createElection({ + title: "Past", + description: "D", + options: ["A", "B"], + startTime: new Date(Date.now() + 10_000), + endTime: new Date(Date.now() + 20_000), + }); + // election is in 'draft' — not active yet + expect(() => client.castVote(closed, closed.options[0].id)).toThrow( + "ELECTION_NOT_ACTIVE", + ); + }); + + it("two calls with the same optionId produce different EncryptedPayloads (random IV)", () => { + const b1 = client.castVote(election, election.options[0].id); + const b2 = client.castVote(election, election.options[0].id); + + expect(b1.encryptedPayload.iv).not.toBe(b2.encryptedPayload.iv); + expect(b1.encryptedPayload.ciphertext).not.toBe( + b2.encryptedPayload.ciphertext, + ); + }); + + it("Ballot contains optionId locally but serialize omits it", () => { + const ballot = client.castVote(election, election.options[0].id); + + // optionId is present on the local Ballot object + expect(ballot.optionId).toBe(election.options[0].id); + + // serialize must NOT include optionId + const json = client.serialize(ballot); + expect(json).not.toContain("optionId"); + + const parsed = JSON.parse(json) as Record; + expect(parsed).not.toHaveProperty("optionId"); + }); + + it("castVote never logs optionId", () => { + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + const infoSpy = jest.spyOn(console, "info").mockImplementation(() => {}); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const debugSpy = jest.spyOn(console, "debug").mockImplementation(() => {}); + + client.castVote(election, election.options[0].id); + + expect(logSpy).not.toHaveBeenCalled(); + expect(infoSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(debugSpy).not.toHaveBeenCalled(); + + logSpy.mockRestore(); + infoSpy.mockRestore(); + warnSpy.mockRestore(); + debugSpy.mockRestore(); + }); +}); + +// ── verifyVote ───────────────────────────────────────────────────────────── + +describe("verifyVote", () => { + let client: AnonVoteClient; + let election: Election; + + beforeEach(() => { + client = new AnonVoteClient({ ballotKey: VALID_KEY }); + election = makeActiveElection(client); + }); + + it("returns confirmed: true for a valid ballot", () => { + const ballot = client.castVote(election, election.options[0].id); + const result = client.verifyVote(ballot); + + expect(result.confirmed).toBe(true); + expect(result.electionId).toBe(election.id); + }); + + it("propagates decryption error — does not catch and return false", () => { + const ballot = client.castVote(election, election.options[0].id); + const corrupted: Ballot = { + ...ballot, + encryptedPayload: { + ciphertext: "00".repeat(16), + iv: ballot.encryptedPayload.iv, + authTag: ballot.encryptedPayload.authTag, + }, + }; + + // Must throw, NOT return { confirmed: false } + expect(() => client.verifyVote(corrupted)).toThrow(); + }); + + it("roundtrip — castVote then verifyVote always returns confirmed: true", () => { + for (const opt of election.options) { + const ballot = client.castVote(election, opt.id); + expect(client.verifyVote(ballot).confirmed).toBe(true); + } + }); +}); + +// ── serialize and deserialize ────────────────────────────────────────────── + +describe("serialize and deserialize", () => { + let client: AnonVoteClient; + let election: Election; + + beforeEach(() => { + client = new AnonVoteClient({ ballotKey: VALID_KEY }); + election = makeActiveElection(client); + }); + + it("serialize produces a stable deterministic JSON string", () => { + const ballot = client.castVote(election, election.options[0].id); + expect(client.serialize(ballot)).toBe(client.serialize(ballot)); + }); + + it("serialize omits optionId from the output", () => { + const ballot = client.castVote(election, election.options[0].id); + const json = client.serialize(ballot); + const parsed = JSON.parse(json) as Record; + + expect(parsed).not.toHaveProperty("optionId"); + expect(json).not.toContain("optionId"); + }); + + it("deserialize reconstructs a valid Ballot from serialized output", () => { + const ballot = client.castVote(election, election.options[0].id); + const json = client.serialize(ballot); + const restored = client.deserialize(json); + + expect(restored.electionId).toBe(ballot.electionId); + expect(restored.encryptedPayload).toEqual(ballot.encryptedPayload); + }); + + it("deserialize throws INVALID_SERIALIZED_BALLOT for missing ciphertext field", () => { + const json = JSON.stringify({ + electionId: "some-id", + encryptedPayload: { iv: "aa", authTag: "bb" }, + }); + + expect(() => client.deserialize(json)).toThrow( + "INVALID_SERIALIZED_BALLOT", + ); + }); + + it("deserialized Ballot has no optionId (empty string) after deserialization", () => { + const ballot = client.castVote(election, election.options[0].id); + const restored = client.deserialize(client.serialize(ballot)); + + expect(restored.optionId).toBe(""); + }); + + it("serialize → deserialize → verifyVote still returns confirmed: true", () => { + const ballot = client.castVote(election, election.options[0].id); + const json = client.serialize(ballot); + const restored = client.deserialize(json); + + // verifyVote compares decrypted value to ballot.optionId. + // After deserialization optionId is "", so we compare against the original ballot. + const decryptedBallot: Ballot = { + ...restored, + optionId: ballot.optionId, + }; + + expect(client.verifyVote(decryptedBallot).confirmed).toBe(true); + }); +}); + +// ── type export tests ────────────────────────────────────────────────────── + +describe("type exports", () => { + it("Election type is exported and assignable", () => { + const e: Election = { + id: "00000000-0000-4000-8000-000000000000", + title: "T", + description: "D", + options: [{ id: "opt-1", label: "A", index: 0 }], + startTime: new Date(), + endTime: new Date(Date.now() + 1000), + createdAt: new Date(), + status: "active", + }; + expect(e.id).toBeTruthy(); + }); + + it("Ballot type is exported and assignable", () => { + const b: Ballot = { + electionId: "some-id", + optionId: "opt-id", + encryptedPayload: { ciphertext: "ab", iv: "cd", authTag: "ef" }, + createdAt: new Date(), + }; + expect(b.electionId).toBeTruthy(); + }); + + it("VoteReceipt type is exported and assignable", () => { + const r: VoteReceipt = { + electionId: "some-id", + tokenHash: "a".repeat(64), + ballot: { + electionId: "some-id", + optionId: "opt-id", + encryptedPayload: { ciphertext: "ab", iv: "cd", authTag: "ef" }, + createdAt: new Date(), + }, + submittedAt: new Date(), + }; + expect(r.tokenHash).toBeTruthy(); + }); + + it("ClientConfig type is exported and assignable", () => { + const c: ClientConfig = { ballotKey: VALID_KEY }; + expect(c.ballotKey).toBe(VALID_KEY); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 58803d9..4f8157f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,6 @@ "sourceMap": true, "resolveJsonModule": true }, - "include": ["src"], + "include": ["src", "src/client"], "exclude": ["node_modules", "dist", "tests"] }