Files
smirk-backend-core/migrations/20260625000001_challenges.sql
jwinterm 4f9d00f1d2 feat(db): unified single-use challenge store [operator §0.2]
The shared server-nonce store every signed action consumes (admin login,
first-run setup, self-service erasure). DB-backed so consumption is atomic and
correct under concurrent requests and a future load-balanced fleet:

  DELETE FROM challenges WHERE nonce=$1 AND purpose=$2 AND expires_at > NOW()
                         RETURNING subject

Winning the race returns one row; zero rows = replay/expired/wrong-purpose =>
reject (no TOCTOU). issue_challenge mints a 256-bit nonce bound to a purpose and
optional subject; consume_challenge is the atomic single-use step that pairs with
verify_signed_action (already built, §0.1). Periodic expiry sweep bounds the
table; the table holds no secret and is documented as backup-excluded
(ephemeral, links pubkey->time).

migration 20260625000001; ConsumedChallenge exported; 3 L1 tests (consume-once /
replay-rejected, wrong-purpose rejected, expired rejected).
2026-06-25 17:33:59 -04:00

24 lines
1.3 KiB
SQL

-- Unified server-nonce (challenge) store for signed actions.
--
-- One mechanism for every server-issued single-use nonce — admin login, first-run
-- setup, and self-service erasure — consumed atomically so a nonce can be used at
-- most once even across concurrent requests / fleet nodes:
-- DELETE FROM challenges WHERE nonce=$1 AND purpose=$2 AND expires_at > NOW()
-- RETURNING subject;
-- Winning the race returns exactly one row; zero rows = replay/expired ⇒ reject.
-- This is the DB-backed variant (correct under a shared database and a future
-- load-balanced fleet); it carries no long-lived secret.
--
-- Operational note: this table is ephemeral (it links "pubkey acted at time T")
-- and should be EXCLUDED from backups.
CREATE TABLE challenges (
nonce TEXT PRIMARY KEY, -- 32 random bytes, hex
purpose TEXT NOT NULL, -- 'admin_login' | 'setup' | 'erasure_request' | ...
subject TEXT, -- optional bound subject (e.g. target pubkey/id)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
-- Supports the periodic expiry sweep (the consume query is keyed by the PK).
CREATE INDEX idx_challenges_expires_at ON challenges (expires_at);