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
+23
View File
@@ -0,0 +1,23 @@
-- 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);
+87
View File
@@ -0,0 +1,87 @@
//! Unified server-nonce (challenge) store.
//!
//! Backs every server-issued single-use nonce for signed actions (admin login,
//! first-run setup, self-service erasure). Issuance mints a random nonce bound to
//! a `purpose` (and an optional `subject`); consumption is a single atomic
//! `DELETE ... RETURNING`, so a nonce is honoured at most once even under
//! concurrent or multi-node races. The caller pairs this with
//! [`crate::core::crypto::nip98::verify_signed_action`], which proves the signed
//! event commits to the same nonce + purpose.
use rand::RngCore;
use tracing::instrument;
use crate::error::AppError;
use super::Database;
/// Nonce length in bytes (hex-encoded to 64 chars). 256 bits — unguessable.
const NONCE_BYTES: usize = 32;
/// A successfully consumed challenge. `subject` is whatever was bound at issue
/// (e.g. a target pubkey or erasure id), or `None` if none was bound.
#[derive(Debug, Clone)]
pub struct ConsumedChallenge {
pub subject: Option<String>,
}
impl Database {
/// Mint a single-use nonce for `purpose`, valid for `ttl_secs`. Returns the
/// nonce (hex) to hand to the client; `subject` optionally binds it to a
/// target the verify step can cross-check.
#[instrument(skip(self, subject))]
pub async fn issue_challenge(
&self,
purpose: &str,
subject: Option<&str>,
ttl_secs: i64,
) -> Result<String, AppError> {
let mut bytes = [0u8; NONCE_BYTES];
rand::rngs::OsRng.fill_bytes(&mut bytes);
let nonce = hex::encode(bytes);
sqlx::query(
"INSERT INTO challenges (nonce, purpose, subject, expires_at) \
VALUES ($1, $2, $3, NOW() + make_interval(secs => $4))",
)
.bind(&nonce)
.bind(purpose)
.bind(subject)
.bind(ttl_secs as f64)
.execute(self.pool())
.await?;
Ok(nonce)
}
/// Atomically consume `(nonce, purpose)` if present and unexpired. Returns
/// the consumed challenge, or `None` for an unknown / wrong-purpose / expired
/// / already-used nonce — all of which the caller must treat as rejection.
#[instrument(skip(self, nonce))]
pub async fn consume_challenge(
&self,
nonce: &str,
purpose: &str,
) -> Result<Option<ConsumedChallenge>, AppError> {
// One atomic step: the delete IS the check, so two racers cannot both win.
let row: Option<(Option<String>,)> = sqlx::query_as(
"DELETE FROM challenges \
WHERE nonce = $1 AND purpose = $2 AND expires_at > NOW() \
RETURNING subject",
)
.bind(nonce)
.bind(purpose)
.fetch_optional(self.pool())
.await?;
Ok(row.map(|(subject,)| ConsumedChallenge { subject }))
}
/// Delete expired challenges. Run periodically to bound the table; the
/// consume query already ignores expired rows, so this is hygiene only.
#[instrument(skip(self))]
pub async fn delete_expired_challenges(&self) -> Result<u64, AppError> {
let res = sqlx::query("DELETE FROM challenges WHERE expires_at <= NOW()")
.execute(self.pool())
.await?;
Ok(res.rows_affected())
}
}
+2
View File
@@ -7,6 +7,7 @@
//! reaches a column and the peppering cannot drift between call sites.
mod audit;
mod challenges;
mod grin_slatepacks;
mod login_events;
mod restore_attempts;
@@ -14,6 +15,7 @@ mod sessions;
mod user_keys;
mod users;
pub use challenges::ConsumedChallenge;
pub use login_events::LoginStats;
use sqlx::PgPool;
+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
+76
View File
@@ -0,0 +1,76 @@
//! L1 integration: the unified challenge (server-nonce) store. Validates atomic
//! single-use consumption, purpose binding, and expiry against real Postgres.
mod common;
#[tokio::test]
async fn issue_then_consume_once() {
let app = require_app!();
let db = &app.state.db;
let nonce = db
.issue_challenge("admin_login", Some("pubkey-x"), 120)
.await
.unwrap();
assert_eq!(nonce.len(), 64, "32-byte hex nonce");
// First consume succeeds and returns the bound subject.
let consumed = db
.consume_challenge(&nonce, "admin_login")
.await
.unwrap()
.expect("nonce consumes once");
assert_eq!(consumed.subject.as_deref(), Some("pubkey-x"));
// Second consume (replay) is rejected.
assert!(
db.consume_challenge(&nonce, "admin_login")
.await
.unwrap()
.is_none(),
"replay rejected"
);
}
#[tokio::test]
async fn consume_rejects_wrong_purpose() {
let app = require_app!();
let db = &app.state.db;
let nonce = db.issue_challenge("setup", None, 120).await.unwrap();
// A nonce minted for one purpose cannot be consumed under another.
assert!(
db.consume_challenge(&nonce, "admin_login")
.await
.unwrap()
.is_none(),
"purpose mismatch rejected"
);
// The original purpose still works (the failed attempt didn't consume it).
assert!(db
.consume_challenge(&nonce, "setup")
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn expired_nonce_is_not_consumable() {
let app = require_app!();
let db = &app.state.db;
// TTL 0 => already expired by the time we consume.
let nonce = db
.issue_challenge("erasure_request", None, 0)
.await
.unwrap();
assert!(
db.consume_challenge(&nonce, "erasure_request")
.await
.unwrap()
.is_none(),
"expired nonce rejected"
);
// Sweeper removes it.
let _ = db.delete_expired_challenges().await.unwrap();
}