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).
This commit is contained in:
jwinterm
2026-06-25 17:33:59 -04:00
parent 771a69b85e
commit 4f9d00f1d2
5 changed files with 203 additions and 0 deletions
+15
View File
@@ -80,6 +80,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Periodic expiry sweep for the unified challenge store. The consume query
// already ignores expired rows; this just bounds the table.
{
let db = state.db.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(600));
loop {
tick.tick().await;
if let Err(e) = db.delete_expired_challenges().await {
tracing::warn!(error = %e, "challenge expiry sweep failed");
}
}
});
}
// Background price refresh (only when the feed is enabled). On each tick we
// fetch the configured feeds and replace the snapshot; a failure logs and
// keeps the last good values rather than blanking them. The first interval