Skip to content

Station signup: station-passcode.ts lifecycle module (encrypt, verify, rotate, cooldown) #2359

Description

@jakebromberg

Problem

The station-signup epic needs a single module owning the passcode's whole lifecycle: generation, encryption, verification, rotation, revocation, cooldown evaluation, and the attempt log. Four downstream issues consume its exported surface, so it must land before them and its API must be settled here rather than assumed by each caller.

Desired end state

shared/authentication/src/station-passcode.ts exists, exporting a named surface from the package barrel.

This is the repo's first encryption-at-rest code — no createCipheriv exists in Backend-Service today. Put the key handling and its threat model in a comment at the top of the module.

Encryption

AES-256-GCM, key from STATION_PASSCODE_KEY, stored as iv:tag:ciphertext base64 in one column.

Be precise about what this buys: the key ships to the EC2 host's .env via set-ec2-env-var.yml, the same file holding DB_PASSWORD. It protects a leaked dump or RDS snapshot; it does not protect against host compromise, where an attacker has both halves. Worth having — dumps outlive and travel further than host access — but not the broader guarantee the phrase usually implies.

Encrypted rather than hashed because the code is meant to be read back: a stationManager reveals the current code and reads it to a stranded DJ by phone, without rotating. Rotating under the two-row cap can invalidate the note everyone else in the room is using.

Key rotation is free and needs no re-encryption: rows live <=14 days and there are <=2, so set a new key and rotate the passcode. Old ciphertext becomes undecryptable garbage, harmless. A decrypt failure must fail closed — refuse — never fall through to a "no match" that would silently disable the gate if the key were wrong.

Generation

Eight characters from a 32-character unambiguous alphabet (no 0/O, 1/l/I): ~10^12, still legible on a sticky note. Generated, never manager-chosen — a manager-typed code will be wxyc2026.

Verification

Both active rows decrypted and compared in constant time, with no early exit; results combined at the end. Returning on first match leaks through response timing which of the two codes was used.

Rotation and the two-row cap

At most two rows active at once (revoked_at IS NULL AND expires_at > now()), enforced at rotation under a lock. Rotation is read-then-insert, so it must be serialized on a station-global constant pg_advisory_xact_lock key inside the rotate transaction. The key must be distinct from the repo's existing advisory-lock keys. Two concurrent rotations would otherwise yield three or four active rows.

The lock is station-global rather than per-passcode because the cap counts rows matching a predicate: the row being inserted does not exist yet, so there is nothing per-row to key on, and two rotations locking different existing rows would both count 2 and proceed.

SELECT ... FOR UPDATE was previously listed here as an alternative. It is not one, and has been removed: row locks cannot lock the absence of rows. With one active row, two rotations both SELECT ... FOR UPDATE; the loser blocks, the winner inserts its new row and commits, the loser then re-evaluates only the row it matched, never sees the phantom insert, counts one active row, and inserts. Three active rows. With zero active rows nothing is locked at all and N concurrent rotations yield N rows. Such an implementation passes every non-concurrent test while failing exactly the acceptance criterion this section states.

Note this is rotation's mechanism only. The use-claim is not lock-based: it is a single conditional UPDATE ... SET use_count = use_count + 1 WHERE id = $1 AND use_count < max_uses RETURNING id, which is atomic on its own and never touches the advisory-lock space. Rotation's lock is therefore the module's only advisory lock, with no deadlock surface.

The cap's rationale is not CPU — decryption is microseconds. It is that every active code is a live credential written on paper somewhere in the building. Two is a rotation overlap window; five is having lost track.

Cooldown

A failed attempt matches no passcode row, so there is nothing to attribute the failure to. Do not increment a per-row counter — incrementing every active row would let a few wrong guesses from anywhere on the internet revoke both live codes and lock out the control room, which is the outage this whole design exists to prevent.

Instead: count failures in station_signup_attempt within a window — but refusal and alerting count different row sets.

  • Refusal: only genuine no-match failures (passcode_fail) count. More than 20 in 10 minutes enters a 15-minute cooldown.
  • Alerting: all failure outcomes count toward the digest email.

This split is load-bearing. The cooldown check runs before verification, so if a stale-sticky-note failure (a real code that is expired, revoked, or exhausted) counted toward refusal, a room of legitimate DJs would trip a station-global cooldown — and the manager's phoned-in correct code would then be refused too, requiring a second manager action (clear-cooldown) to undo a lockout that legitimate users caused. That is the failure shape #2365 forbids. Decoupling keeps the stale-note alert — the mechanism's primary designed use case — fully intact while never refusing service over it. Excluding these rows costs nothing against attackers: a guesser's random codes match nothing and still feed refusal exactly as before.

evaluateSignupCooldown must therefore return both counts (in-window no-match failures, and in-window all-failures). Settle that return shape here: #2361, #2362, #2363 and #2364 all consume this surface, which is why this module lands first. #2362's status endpoint should surface both so a manager can distinguish "the room is failing on a stale code" from "we are under a guessing flood".

The cooldown is self-healing, needs no manager, and never revokes anything. Revocation stays manual.

A manager clearing the cooldown writes a cooldown_cleared row, and evaluation counts failures only at or after the most recent one — the clear is a floor on the window, not a deletion. Clearing must never delete attempt rows: they are the detection record the cooldown derives from and the 30-day audit the prune job assumes.

Barrel exports

Name the surface in shared/authentication/src/index.ts — not export *, so lifecycle internals stay private, with a line saying why (the barrel already annotates why particular symbols are exported; see the grantsAdminFlag BS#2282 note):

generateStationPasscode, verifyStationPasscode, revealStationPasscode, rotateStationPasscode, revokeStationPasscode, evaluateSignupCooldown, clearSignupCooldown, readRecentSignupAttempts, pruneSignupAttempts

The last three have consumers outside this module: the admin API clears the cooldown and reads counts, and the prune runs from a job.

Tests — split by what they can actually prove

jest.unit.config.ts maps @wxyc/database and any shared/database/src/client path to a chainable stub with canned values, so no atomicity claim can be tested there — a mock enforces nothing and every concurrency case would pass vacuously.

  • tests/unit/authentication/station-passcode.test.ts (TypeScript, per jest.unit.config.ts:6): encrypt/decrypt round-trip, a wrong key failing closed, constant-time comparison, cooldown arithmetic, the clear-as-window-floor calculation, code generation and alphabet, no-early-exit combination logic, expiry and revocation predicates.
  • tests/integration/station-passcode.spec.js (plain JS, .spec.jsjest.config.json's testMatch is **/tests/integration/?(*.)+(spec).js and all 119 existing files there are .js): the two-row cap under concurrent rotation, and that a failed attempt mutates no passcode row.

Acceptance criteria

  • Module exists with the full lifecycle surface
  • Barrel exports named individually, with rationale comment
  • Decrypt failure fails closed, with a test
  • Verification is constant-time and does not short-circuit, with a test
  • Two-row cap holds under concurrent rotation (integration test, real Postgres)
  • Cooldown never revokes a passcode, with a test
  • Cooldown clear acts as a window floor and deletes no rows, with a test

Context

Plan: ~/Downloads/wxyc-station-passcode-signup-plan.md.

Related

Blocked by the schema issue. Blocks the endpoint, admin API, and prune job.

Blocked by

Outcome vocabulary (settled)

passcode_fail must not cover two materially different events. The vocabulary is:

outcome meaning passcode_id feeds refusal feeds alert
passcode_ok valid code, use claimed set no no
passcode_fail matched no row at all NULL yes yes
passcode_expired matched a real row, past expires_at set no yes
passcode_revoked matched a real row, revoked_at set set no yes
passcode_exhausted matched a real row at its use cap, or lost the claim race set no yes
cooldown_refused refused while in cooldown NULL no no
cooldown_cleared manager cleared the cooldown NULL no no
passcode_revealed manager revealed the code set no no

passcode_exhausted also covers the claim-race loser — a correct code whose conditional UPDATE returned zero rows. It is a correct code, so it must never feed refusal.

Enforce the vocabulary with a TypeScript union in this module, not a DB CHECK. The table has exactly one writer by design; a CHECK costs a migration now and another on every future token, and compile-time enforcement catches the same misspelling class.

Classification requires reading inactive rows

Verification compares against the two active rows, so a stale code is otherwise indistinguishable from garbage. Classification must additionally decrypt recently-inactive rows over a bounded horizon (e.g. expires_at/revoked_at within the last 30 days, matching the audit horizon) — unbounded, the scan grows forever, since nothing prunes station_passcode.

Two decrypt-failure policies, not one:

  • Active row decrypt failure fails closed — gate integrity.
  • Inactive row decrypt failure is silently skipped — after a key rotation, old rows are undecryptable by design, and failing closed on them would break the endpoint for the entire classification horizon.

Classification must run identically on every failure path so failure kinds stay timing-indistinguishable to the client, and the client response stays generic per #2361. The split lives only in the log.

use_count concurrency (settled)

Single conditional statement, no advisory lock and no CHECK constraint:

UPDATE station_passcode SET use_count = use_count + 1
WHERE id = $1 AND use_count < max_uses
RETURNING id;

Under READ COMMITTED, concurrent statements on the same row serialize on the row lock and the loser re-evaluates the predicate against the winner's committed value, so over-issue is impossible by construction. Zero rows returned means the cap was hit → passcode_exhausted.

Validation still runs strictly before the claim — the ordering constraint from #2365 stands. An earlier design that claimed first let fumbled usernames burn the code.

No CHECK (use_count <= max_uses): the invariant that actually matters (two active rows) cannot be expressed as a CHECK anyway, the conditional UPDATE makes the constraint unreachable except through a future second writer, and the acceptance criteria already require a real-Postgres concurrent-claim test.

Production enablement

STATION_PASSCODE_KEY and STATION_SIGNUP_IP_HMAC_KEY are absent from .github/workflows/set-ec2-env-var.yml's secret allowlist (the resolve-step env block and the case "$SECRET_NAME", which hard-fails with "Unsupported secret_name"). Both must be added there in this issue, or the feature cannot be enabled in production.

The ip_hash HMAC derivation is specified in the ip_hash column comment in shared/database/src/schema.ts — reference it, do not restate it. Duplicated spec prose across files is unfixable drift once a migration freezes one copy.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions