packages/shared/src/crypto/index.ts:159–166
The timingSafeEqual function short-circuits on length mismatch with an early return, leaking information about the expected signature length through response timing. An attacker can measure response times to learn the length of valid HMAC signatures, reducing the search space for brute-force attacks.
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false; // ← timing leak
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
Fix: Pad both strings to equal length before comparison, or use a constant-time comparison that always iterates over the longer of the two strings.
apps/api/src/routes/auth.ts:58–70
In mock mode, the DID is derived deterministically from the user-supplied handle parameter: did:plc:mock${handle.replace(/\./g, '')}. Any caller can forge arbitrary DIDs by choosing the right handle string. This allows impersonating the poll host and gaining admin access to any poll. If mock mode leaks to production (the ATPROTO_MOCK_MODE env var defaults to "true" in wrangler.toml), the entire auth system is bypassed.
Fix: Default ATPROTO_MOCK_MODE to "false". Add a startup check that panics if mock mode is enabled in a production environment. Never derive DIDs from user input.
apps/api/src/routes/polls.ts:86–87
If the CREDENTIAL_SIGNING_KEY environment variable is not set, the code silently falls back to crypto.randomUUID() as the HMAC key. This means each poll creation generates a different ephemeral key. Credentials issued under one key cannot be verified under another. The signing key is passed to the Durable Object at initialization, so if the DO restarts and the key was ephemeral, all outstanding credentials become unverifiable.
const signingKey = env.CREDENTIAL_SIGNING_KEY || crypto.randomUUID();
Fix: Fail loudly if CREDENTIAL_SIGNING_KEY is not set. Never fall back to random keys for cryptographic operations.
apps/api/src/durable-objects/poll-coordinator.ts:53–75
The DO stores nullifiers, consumedDids, and lastAuditHash in its storage. If the DO's storage is lost or evicted, these sets reinitialize as empty. This means:
D1 has the permanent records, but there is no recovery path that rebuilds DO state from D1. The DO simply starts fresh with empty sets.
Fix: On cold start, rebuild consumedDids and nullifiers sets from D1's eligibility and ballots tables. Recover lastAuditHash from the most recent audit_events row.
apps/api/src/durable-objects/poll-coordinator.ts:77–83
Related to finding #4: the saveState method serializes Sets to Durable Object storage, but the loadState method tries to reconstruct Sets from the stored data. However, JavaScript Sets serialize as objects (not arrays) in structured clone, and the restoration code does new Set(stored.nullifiers) which, if stored.nullifiers survived serialization as a Set, would produce a Set containing one Set — not the original values.
stored.nullifiers = new Set(stored.nullifiers); stored.consumedDids = new Set(stored.consumedDids);
Fix: Convert Sets to arrays before storage ([...set]) and reconstruct from arrays on load. Or use DO's built-in structured clone which handles Sets correctly in newer runtimes — but test explicitly.
apps/api/src/durable-objects/poll-coordinator.ts:286–293
The ballot submission handler checks state.poll.status !== 'open' but does not verify that the current time falls within opensAt...closesAt. The eligibility handler does check the time window, but the ballot endpoint does not — and since ballot submission is credential-based (no session), there is no guarantee the ballot is submitted during the authorized window. A credential issued at T=1 can be submitted at T=∞ as long as the poll status hasn't been manually changed.
Fix: Add new Date() > new Date(state.poll.closesAt) check in handleBallot.
apps/api/src/index.ts:73–88
CORS is configured with Access-Control-Allow-Credentials: true, and session cookies are set with HttpOnly and SameSite=Lax. However, SameSite=Lax allows cookies to be sent on top-level GET navigations from cross-origin sites. While POST requests from forms are blocked by Lax, the ballot submission endpoint could be exploited via a cross-origin fetch() with credentials if the attacker's origin somehow matches the CORS allowlist. More practically, there is no CSRF token on any state-changing endpoint.
Fix: Add a CSRF token pattern or use SameSite=Strict. For the credential-based ballot endpoint, CSRF is less relevant (the credential itself is the auth), but poll creation and status changes should be protected.
apps/web/src/pages/Vote.tsx:74–76
Ballot credentials (secret, tokenMessage, issuerSignature, nullifier) are stored in localStorage as plain JSON. Any XSS vulnerability on the same origin exposes all pending credentials. An attacker could exfiltrate credentials and submit ballots on behalf of other users. Credentials are not cleared on logout — only on successful submission.
Fix: Use sessionStorage (clears on tab close) instead of localStorage. Consider encrypting credentials with a key derived from the session. Clear credentials on logout.
apps/api/src/routes/polls.ts, apps/api/src/durable-objects/poll-coordinator.ts
No rate limiting exists on any endpoint. An attacker can:
Fix: Add rate limiting via Cloudflare's built-in rate limiting rules, or implement a token bucket in the Worker using KV or DO state.
apps/api/src/durable-objects/poll-coordinator.ts:152–185
The state machine allows draft → open and open → closed, but doesn't prevent closed → open (re-opening a closed poll) or finalized → *. The handleOpen only checks status !== 'draft', and handleClose only checks status !== 'open'. There's no finalized transition at all. A malicious host could close a poll, see unfavorable results, re-open it for more votes (by calling open on a different status, which would fail — but the lack of explicit state machine enforcement is a design gap).
Fix: Implement an explicit state machine: draft → open → closed → finalized. Reject all other transitions. Add a finalize action that makes the tally permanent.
packages/shared/src/crypto/index.ts, apps/api/src/routes/polls.ts:89–93
The publicVerificationKey stored in the poll definition is a SHA-256 hash of the HMAC signing key — not the signing key itself and not a public key. Since HMAC is a symmetric algorithm, no external party can verify ballot signatures without the secret key. The "public verification" claim is architecturally broken in v1 mode. In v2 mode, the public key field should hold an RSA public key, but the scaffold doesn't generate one.
Fix: For v1, acknowledge that public signature verification is impossible (only the host can verify). For v2, generate and publish the RSA public key in the poll definition. The audit page's "recompute tally" feature works (counting ballots), but signature verification doesn't.
apps/api/src/durable-objects/poll-coordinator.ts:226–256
In v2 mode, the eligibility handler returns empty secret and nullifier fields. The ballot submission endpoint then receives an empty nullifier, which passes the uniqueness check (empty string is a valid Set entry but shared by all v2 voters). The blind signature stub signs the blinded message directly without actual blinding. End-to-end, v2 mode silently produces broken credentials.
Fix: Either: (a) disable v2 mode with a clear error until blind signatures are implemented, or (b) have the v2 path generate secret and nullifier client-side and only send the blinded message to the host.
apps/api/src/routes/ballots.ts:53–86
When a ballot is accepted by the DO but the subsequent ATProto publish fails, the ballot is marked as accepted in D1 without a published record URI. The voter sees "ballot accepted" but the ballot is not publicly verifiable on ATProto. There is no retry mechanism. The publishWarning field is returned in the response but there's no background retry or alert system.
Fix: Add a background queue or cron job that retries unpublished ballots (rows in D1 where accepted = 1 and published_record_uri IS NULL).
packages/shared/src/schemas/index.ts:6–12
The CreatePollSchema validates that opensAt and closesAt are valid datetime strings, but doesn't verify that closesAt > opensAt, that opensAt is in the future, or that options are unique. A poll can be created with closesAt before opensAt, or with duplicate options.
Fix: Add a Zod .refine() that checks temporal ordering and option uniqueness.
apps/api/src/durable-objects/poll-coordinator.ts:53–75
The rolling audit hash chain starts at '0'.repeat(64). If the DO restarts, the chain restarts from zero, diverging from the chain stored in D1. Future events will have hashes that don't chain from the last D1 event. An auditor comparing the DO's claimed chain to D1's recorded chain will see a break — but there's no code that detects or reports this.
Fix: On cold start, query SELECT rolling_hash FROM audit_events WHERE poll_id = ? ORDER BY created_at DESC LIMIT 1 and resume from there.
apps/api/migrations/0001_init.sql:35
The choice column in the ballots table has no CHECK constraint. The DO validates choice range in-memory, but if the poll's option count changes between validation and D1 insert (or if a future code path bypasses the DO), invalid choices can be persisted.
Fix: While dynamic CHECK constraints aren't practical in SQLite, add a trigger or application-level post-insert validation.
apps/api/src/routes/auth.ts:152–165
Expired sessions are filtered out by the WHERE expires_at > datetime('now') clause in lookups, but are never deleted. Over time, the sessions table grows unboundedly. There's no cleanup cron or TTL mechanism.
Fix: Add a periodic cleanup: DELETE FROM sessions WHERE expires_at < datetime('now', '-1 hour'). Can be triggered opportunistically on auth requests or via a scheduled Worker.
apps/api/src/routes/auth.ts:167–170
The createSession function uses a template literal inside the SQL string: datetime('now', '+${SESSION_TTL_HOURS} hours'). While SESSION_TTL_HOURS is a constant (24), this pattern is dangerous. If the value were ever derived from user input, it would be a direct SQL injection vector. The D1 prepare/bind pattern should be used exclusively.
await env.DB.prepare(
`INSERT INTO sessions ... VALUES (?, ?, ?, datetime('now'), datetime('now', '+${SESSION_TTL_HOURS} hours'))`
).bind(sessionId, did, handle).run();
Fix: Use datetime('now', '+24 hours') as a literal string constant, or use datetime('now', ? || ' hours') with a bind parameter.
apps/web/src/pages/Vote.tsx:34–36
Credentials are held in React component state. If a user navigates away from the vote page between credential issuance and ballot submission, the credential is lost. They'd need to request a new one, but the DO has already marked their DID as consumed — they're locked out. The localStorage fallback on page load partially mitigates this, but only if the credential was successfully stored before navigation.
packages/shared/src/atproto/index.ts:81–107
The PdsPublisher.createRecord method calls res.json() without checking that the response body is valid JSON. A malformed PDS response causes an uncaught promise rejection that propagates as a 500 error with no useful context.
apps/api/src/durable-objects/poll-coordinator.ts (various)
Audit event types ('poll_initialized', 'eligibility_consumed', 'ballot_accepted', etc.) are scattered as magic strings. No central enum ensures consistency. A typo in an event type would silently produce an uncategorized audit entry.
apps/api/src/routes/polls.ts:75–130
A poll can be created with opensAt in the past. The frontend sets opensAt to new Date().toISOString(), but the API doesn't enforce it. A crafted API call can backdate a poll's opening time.
| Property | v1 (trusted host) | v2 (with real blind sigs) |
|---|---|---|
| One vote per DID | Enforced (DO) — if DO state survives | Same |
| Ballot anonymity | Trust-based (host knows link) | Cryptographic |
| Public tally verification | Ballot counting works; signature verification does not | Full verification possible |
| Nullifier replay prevention | Enforced (DO) — if DO state survives | Same |
| Host cannot stuff ballots | Detectable via audit chain — if chain is intact | Same + unforgeable credentials |
| Coercion resistance | Weak | Moderate |
The conditional caveats ("if DO state survives", "if chain is intact") represent the gap between design intent and implementation. The system's security guarantees are only as strong as the Durable Object's state persistence — and there is no recovery path from D1 if that state is lost.
consumedDids, nullifiers, ballotCount, and lastAuditHash from D1 on cold start. This is the single highest-impact fix.CREDENTIAL_SIGNING_KEY is not configured.ATPROTO_MOCK_MODE default to "false" in wrangler.toml.opensAt...closesAt./api/polls/*/eligibility/* and /api/polls/*/ballots/*.Files examined:
Evaluation performed March 2026 against commit on branch claude/bluesky-anonymous-polls-DcGuR.