diff --git a/migrations/20260625000001_challenges.sql b/migrations/20260625000001_challenges.sql new file mode 100644 index 0000000..eb191cc --- /dev/null +++ b/migrations/20260625000001_challenges.sql @@ -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); diff --git a/src/infra/db/challenges.rs b/src/infra/db/challenges.rs new file mode 100644 index 0000000..5c120ca --- /dev/null +++ b/src/infra/db/challenges.rs @@ -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, +} + +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 { + 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, AppError> { + // One atomic step: the delete IS the check, so two racers cannot both win. + let row: Option<(Option,)> = 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 { + let res = sqlx::query("DELETE FROM challenges WHERE expires_at <= NOW()") + .execute(self.pool()) + .await?; + Ok(res.rows_affected()) + } +} diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index 1ba0c43..186fb5b 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 96319f7..709d084 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,6 +80,21 @@ async fn main() -> Result<(), Box> { }); } + // 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 diff --git a/tests/challenges.rs b/tests/challenges.rs new file mode 100644 index 0000000..28607ef --- /dev/null +++ b/tests/challenges.rs @@ -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(); +}