mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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);
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user