Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
121 changes: 120 additions & 1 deletion src/crypto.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -93,6 +100,15 @@
/**
* 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
Expand All @@ -113,6 +129,9 @@
* 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")
Expand All @@ -123,6 +142,17 @@
/**
* 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
Expand All @@ -147,6 +177,16 @@
/**
* 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.
Expand All @@ -172,7 +212,41 @@
/**
* 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,

Check failure on line 249 in src/crypto.ts

View workflow job for this annotation

GitHub Actions / benchmark

Invalid character.

Check failure on line 249 in src/crypto.ts

View workflow job for this annotation

GitHub Actions / benchmark

',' expected.

Check failure on line 249 in src/crypto.ts

View workflow job for this annotation

GitHub Actions / benchmark

',' expected.

Check failure on line 249 in src/crypto.ts

View workflow job for this annotation

GitHub Actions / benchmark

',' expected.

Check failure on line 249 in src/crypto.ts

View workflow job for this annotation

GitHub Actions / benchmark

',' expected.

Check failure on line 249 in src/crypto.ts

View workflow job for this annotation

GitHub Actions / benchmark

',' expected.

Check failure on line 249 in src/crypto.ts

View workflow job for this annotation

GitHub Actions / benchmark

',' expected.

Check failure on line 249 in src/crypto.ts

View workflow job for this annotation

GitHub Actions / benchmark

',' expected.

Check failure on line 249 in src/crypto.ts

View workflow job for this annotation

GitHub Actions / benchmark

',' expected.

Check failure on line 249 in src/crypto.ts

View workflow job for this annotation

GitHub Actions / benchmark

Expression expected.
* no token value. Authenticated encryption (GCM mode) ensures any tampering
* is detectable at decryption time.
*
Expand Down Expand Up @@ -212,15 +286,27 @@
const authTag = cipher.getAuthTag();

return {
ciphertext: ciphertext.toString("hex"),
ciphertext: encrypted.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.
*
* 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.
*
Expand All @@ -231,7 +317,21 @@
* @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"
*/
Expand All @@ -257,6 +357,25 @@
}
}

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.
*
Expand Down
14 changes: 13 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -40,6 +51,7 @@ export type {
EligibilityEntry,
Token,
VoterToken,
VoteRecord,
Vote,
EncryptedPayload,
Organization,
Expand All @@ -57,4 +69,4 @@ export type {
Election,
ElectionOption,
VoteReceipt,
} from "./types";
} from "./types";
82 changes: 82 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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:
Expand All @@ -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;
Expand Down
Loading
Loading