mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 23:41:33 +02:00
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).
77 lines
2.1 KiB
Rust
77 lines
2.1 KiB
Rust
//! 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();
|
|
}
|