Skip to content

AnonVoteClient SDK #42

Description

@Just-Bamford

Summary

@anonvote/crypto exports five low-level cryptographic primitives. They are correct and auditable at the function level but they are not an SDK. A
developer who wants to build an integration on top of AnonVote — a custom voting interface, a Stellar-native election tool, a mobile client — has no unified entry point. They must manually understand the composition order of five functions, manage key scoping themselves, serialise payloads correctly, and handle every error case individually. This creates a high integration barrier and a high probability of misuse.

The AnonVoteClient SDK is the public developer interface for the AnonVote ecosystem. It sits above the raw primitives and exposes a small, opinionated API that hides the cryptographic composition details while enforcing the correct usage patterns. It must be impossible to use AnonVoteClient in a way that violates the privacy model — the SDK must make correct usage the only usage.

This is a design problem as much as an implementation problem. The API surface must be minimal enough that a developer can understand it in 10 minutes, typed well enough that TypeScript prevents misuse at compile time, and documented well enough that a developer integrating it for the first time never needs to read the underlying src/crypto.ts source.


What Needs to Be Built

Architecture Decision — New File or New Package

The first decision is where AnonVoteClient lives. Two options:

Option A — Add src/client.ts to the existing js repo and export AnonVoteClient alongside the existing crypto primitives from src/index.ts.
This keeps everything in one package — @anonvote/crypto exports both the primitives and the client. Simple, one package to install.

Option B — Create a separate src/client/ directory with its own entry point, and export it as a separate subpath export @anonvote/crypto/client. This allows tree-shaking — consumers who only want the raw primitives do not import the client code.

The decision must be made and documented in DECISIONS.md before any implementation begins. The recommended approach is Option B — subpath exports are a Node.js 12+ feature, the package.json already has TypeScript configured, and keeping the client in a subpath makes the package surface explicit. Update package.json exports field:

{
  "exports": {
    ".": "./dist/index.js",
    "./client": "./dist/client/index.js"
  }
}

src/client/types.ts — Client Type Definitions

Define the types that the client API exposes. These are higher-level than the types in src/types.ts — they represent domain concepts, not crypto wire formats:

export interface ClientConfig {
  // The per-ballot encryption key as a 64-character hex string.
  // 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
}

export interface ElectionOptions {
  title: string
  description: string
  options: string[]       // Array of option labels — minimum 2, maximum 10
  startTime: Date
  endTime: Date
}

export interface Election {
  id: string              // UUID generated by createElection
  title: string
  description: string
  options: ElectionOption[]
  startTime: Date
  endTime: Date
  createdAt: Date
  status: 'draft' | 'active' | 'closed' | 'finalised'
}

export interface ElectionOption {
  id: string              // UUID generated per option — used as the optionId in votes
  label: string
  index: number
}

export interface Ballot {
  electionId: string
  optionId: string        // Must be one of the Election's option IDs
  encryptedPayload: EncryptedPayload  // Imported from src/types.ts
  createdAt: Date
}

export interface VoteReceipt {
  electionId: string
  tokenHash: string       // SHA-256 hash of the voter's token — proof of participation
  ballot: Ballot
  submittedAt: Date
}

export interface VerificationResult {
  confirmed: boolean
  electionId: string
  checkedAt: Date
}

src/client/index.ts — AnonVoteClient Class

The client is a class that takes a ClientConfig in its constructor and exposes five methods. The constructor must validate the ballotKey immediately on instantiation — throw AnonVoteCryptoError INVALID_KEY if it is not a 64-character hex string. A misconfigured client must fail loudly at construction time, not silently at the first crypto operation.

import { AnonVoteCryptoError } from '../types'
import { encryptVote, decryptVote, generateToken, hashToken } from '../crypto'
import type { ClientConfig, Election, ElectionOptions, Ballot, VoteReceipt, VerificationResult } from './types'

export class AnonVoteClient {
  private readonly config: ClientConfig

  constructor(config: ClientConfig) {
    // Validate ballotKey immediately
    // Throw AnonVoteCryptoError INVALID_KEY if not 64-char hex
  }

createElection(options: ElectionOptions): Election

Creates and returns an Election object. This is a pure client-side operation — it does not make any network calls. It generates a UUID for the election ID and a UUID for each option ID. Validates that options.options has between 2 and 10 entries. Validates that startTime is before endTime. Validates that endTime is in the future. Throws AnonVoteCryptoError INVALID_ELECTION with a descriptive message for any validation failure.

The option IDs generated here are what get passed to castVote — they are the values that end up as encrypted payloads in the database. The client must ensure they are UUIDs, not option labels, so no option text ever reaches the encryption layer.

castVote(election: Election, optionId: string): Ballot

Validates that optionId exists in election.options — throw AnonVoteCryptoError INVALID_OPTION if not. Validates that the election status is active and that new Date() is between startTime and endTime — throw AnonVoteCryptoError ELECTION_NOT_ACTIVE if not.

Calls encryptVote(optionId, this.config.ballotKey) from src/crypto.ts. Returns a Ballot object with the electionId, optionId, and encryptedPayload. The optionId in the returned Ballot is included so the voter can verify what they voted for before submission — it is not sent
to the server.

The method must never log the optionId. The encryptedPayload must be the only thing that leaves this method in a form suitable for server submission.

verifyVote(ballot: Ballot): VerificationResult

Verifies that a ballot produced by castVote can be successfully decrypted and that the decrypted optionId matches the ballot's optionId. This is a local verification step — it does not contact the server.

Calls decryptVote(ballot.encryptedPayload, this.config.ballotKey). Compares the result to ballot.optionId. Returns a VerificationResult with confirmed: true if they match, confirmed: false if they do not. If decryptVote throws, propagate the error — do not catch and return
confirmed: false. A decryption error means something is wrong with the payload or key, not just an option mismatch.

serialize(ballot: Ballot): string

Converts a Ballot to a JSON string suitable for sending to the AnonVote API or storing for later submission. The serialised form must be a stable, deterministic JSON string — use JSON.stringify with keys sorted alphabetically so the same ballot always produces the same string.

The serialised string must not include the raw optionId from the Ballot object — only electionId and encryptedPayload are serialised. The optionId stays local. Add a @security JSDoc note explaining why.

deserialize(serialized: string): Ballot

Parses a JSON string produced by serialize back into a Ballot object. Validates the structure — electionId must be a non-empty string, encryptedPayload must have ciphertext, iv, and authTag fields. Throw AnonVoteCryptoError INVALID_SERIALIZED_BALLOT if validation fails.
The deserialized Ballot has no optionId — that field is undefined after deserialization by design, because the option ID was never serialised.

JSDoc Documentation

Every public method must have a complete JSDoc block:

  • @description — what the method does and why
  • @param — every parameter with type and constraints
  • @returns — return type and what each field means
  • @throws — every AnonVoteCryptoError code the method can throw and the condition that triggers it
  • @example — a minimal working code example
  • @security — for any method where there is a privacy-relevant constraint the caller must know about

tests/client.test.ts — Full Test Suite

createElection tests

  • createElection returns an Election with unique IDs for the election and each option
  • createElection throws INVALID_ELECTION for fewer than 2 options
  • createElection throws INVALID_ELECTION for more than 10 options
  • createElection throws INVALID_ELECTION when endTime is before startTime
  • createElection throws INVALID_ELECTION when endTime is in the past
  • createElection option IDs are UUIDs — not the option label text

castVote tests

  • castVote returns a Ballot with an EncryptedPayload
  • castVote throws INVALID_OPTION for an optionId not in the election
  • castVote throws ELECTION_NOT_ACTIVE for a closed election
  • castVote two calls with the same optionId produce different EncryptedPayloads
  • castVote Ballot contains optionId locally but serialize omits it

verifyVote tests

  • verifyVote returns confirmed: true for a valid ballot
  • verifyVote propagates decryption error — does not catch and return false
  • verifyVote roundtrip — castVote then verifyVote always returns confirmed: true

serialize and deserialize tests

  • serialize produces a stable deterministic JSON string
  • serialize omits optionId from the output
  • deserialize reconstructs a valid Ballot from serialized output
  • deserialize throws INVALID_SERIALIZED_BALLOT for missing ciphertext field
  • deserialize Ballot has no optionId field after deserialization
  • serialize then deserialize then verifyVote still returns confirmed: true

constructor tests

  • AnonVoteClient throws INVALID_KEY for a 32-character ballotKey
  • AnonVoteClient throws INVALID_KEY for a non-hex ballotKey
  • AnonVoteClient instantiates successfully with a valid 64-character hex key

type export tests

  • Election type is exported and assignable
  • Ballot type is exported and assignable
  • VoteReceipt type is exported and assignable
  • ClientConfig type is exported and assignable

Relevant Files

New files to create:

  • src/client/types.ts
  • src/client/index.ts
  • tests/client.test.ts

Existing files to modify:

  • package.json — add exports field with subpath for ./client
  • tsconfig.json — ensure src/client/ is included in compilation
  • DECISIONS.md — document the subpath export architecture decision
  • README.md — add AnonVoteClient section with installation and quick start

Acceptance Criteria

  • DECISIONS.md documents the subpath export architecture before any implementation begins
  • AnonVoteClient exported from @anonvote/crypto/client
  • Constructor throws INVALID_KEY for any invalid ballotKey at instantiation time
  • castVote never logs optionId — add a test that spies on console and asserts nothing is logged during a castVote call
  • serialize omits optionId — confirmed by test
  • verifyVote propagates decryption errors — does not catch and return confirmed: false
  • All four types exported from src/client/types.ts
  • All 22 test cases pass with no warnings
  • npm run build produces dist/client/index.js and dist/client/index.d.ts
  • @anonvote/crypto/client resolves correctly in a consuming TypeScript project — test this with a minimal test-consumer/ directory

Note for Contributors

The serialize method's decision to omit optionId is a deliberate security choice, not an oversight. The option ID is what the voter voted for. Once the ballot is serialised for server submission, the option ID must not leave the client in plaintext — only the encrypted payload goes to the server. A PR that includes optionId in the serialised output will be closed.

The verifyVote method must propagate decryption errors. Do not write a try/catch that catches the decryption error and returns { confirmed: false }. A decryption error means the payload is corrupted or the key is wrong — these are different failure modes from "option ID does not match" and they must surface as exceptions so the caller can handle them correctly.

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26enhancementNew feature or requestspikeissue requiring deep engineering work across multiple layers.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions