From 1e2888e75ad8a0f78247723546b7852434260851 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:06:47 -0400 Subject: [PATCH 01/20] style(db): wrap test assertion to satisfy rustfmt cargo fmt --all --check on main flags the active_key assertion in the rotate-rollback test; this is the mechanical rustfmt wrap, no behavior change. --- src/db.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/db.rs b/src/db.rs index 5238c04..8ace052 100644 --- a/src/db.rs +++ b/src/db.rs @@ -441,7 +441,10 @@ mod tests { let r = db.rotate_key_sealed("g1", b"spki-2", |_id| Err("seal boom".to_string())); assert!(r.is_err()); - let after = db.active_key("g1").unwrap().expect("original key still active"); + let after = db + .active_key("g1") + .unwrap() + .expect("original key still active"); assert_eq!(after.key_id, before.key_id, "the original key stays active"); let opened = kek.open("g1", after.key_id, &after.sealed_pkcs8).unwrap(); assert_eq!(opened, b"secret-1"); From 14f9473243d1dc31350340ef204bbe8939ce0c9e Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:06:47 -0400 Subject: [PATCH 02/20] test(keygen_dos): raise poll ceiling to 120s to match other suites The two concurrency tests (distinct-group bound, same-group dedup) fail on shared CI runners: poll_until_ready capped eventual-readiness at 600 x 25ms (~15s), but the test binary runs its tests in parallel, so dozens of 1024-bit safe-prime keygens contend for a few vCPUs and a group can take well over 15s to come ready. at_rest and issuance already poll at 1200 x 100ms (~120s) and pass on the same runners. Align the ceiling to the same 1200 x 100ms. The DoS bound the suite exists for is asserted by the immediate 200/202 enqueue responses and the single-stable-key-id dedup check, not by this eventual-readiness poll, so the longer ceiling weakens no security property. --- tests/keygen_dos.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/keygen_dos.rs b/tests/keygen_dos.rs index fee44d6..742b29b 100644 --- a/tests/keygen_dos.rs +++ b/tests/keygen_dos.rs @@ -25,8 +25,15 @@ use std::time::Duration; const FAST_BITS: usize = 1024; /// Poll `GET /key` until ready (200) or timeout. Returns the final status code. +/// +/// The ceiling (1200 x 100ms ~ 120s) matches the other integration suites +/// (at_rest, issuance): on shared CI runners the test binary runs its tests in +/// parallel, so many safe-prime keygens contend for a few vCPUs and a single +/// group can legitimately take well over 15s to come ready. The DoS bound is +/// asserted by the immediate 200/202 responses, not by this eventual-readiness +/// poll, so a generous ceiling weakens nothing. async fn poll_until_ready(client: &reqwest::Client, base: &str, group: &str) -> bool { - for _ in 0..600 { + for _ in 0..1200 { let res = client .get(format!("{base}/key?group_id={group}")) .send() @@ -37,7 +44,7 @@ async fn poll_until_ready(client: &reqwest::Client, base: &str, group: &str) -> assert_eq!(body["status"], "ready"); return true; } - tokio::time::sleep(Duration::from_millis(25)).await; + tokio::time::sleep(Duration::from_millis(100)).await; } false } From 52cb1d32b019928726ba99de11c5ac1d82f0e42c Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:31:20 -0400 Subject: [PATCH 03/20] test: raise keygen poll ceilings to 360s across integration suites The first fix raised only keygen_dos to the 120s ceiling the other suites used, but the PR's CI run then failed in issuance on a slower runner: three of its five tests panicked with 'key never became ready' at the 120s ceiling. Root cause is shared by all three suites: each test spawns its own server and 2048-bit safe-prime keygen (the pbrsa crate rejects moduli under 2048, so a smaller test modulus is not an option on the signing path), the binary runs its tests in parallel, and safe-prime generation is high-variance. On that runner a single uncontended keygen took ~50-95s, so five concurrent ones cannot all fit inside 120s windows on ~4 vCPUs. Raise the eventual-readiness ceilings in issuance, at_rest, and keygen_dos to 3600 x 100ms (~360s). The polls return as soon as the key is ready, so a healthy run pays nothing; a degraded runner spends wall clock instead of failing. No assertion is weakened and no test is skipped. --- tests/at_rest.rs | 6 ++++-- tests/issuance.rs | 8 +++++++- tests/keygen_dos.rs | 15 ++++++++------- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/tests/at_rest.rs b/tests/at_rest.rs index a85cb2c..589864f 100644 --- a/tests/at_rest.rs +++ b/tests/at_rest.rs @@ -37,9 +37,11 @@ async fn private_key_is_ciphertext_at_rest() { res.status() == 202 || res.status() == 200, "POST /key should enqueue (202) or be already-ready (200)" ); - // Poll until ready (generous ceiling for slow release keygen). + // Poll until ready. Ceiling 3600 x 100ms ~ 360s: 2048-bit safe-prime + // keygen is high-variance and a single key has taken ~50s on a slow + // shared CI runner; the poll exits as soon as the key is ready. let mut ready = false; - for _ in 0..1200 { + for _ in 0..3600 { let res = client .get(format!("{base}/key?group_id={g}")) .send() diff --git a/tests/issuance.rs b/tests/issuance.rs index 5533573..3f1ce0c 100644 --- a/tests/issuance.rs +++ b/tests/issuance.rs @@ -26,8 +26,14 @@ fn info(version: &str) -> Vec { /// Fetch the active public key, polling `GET /key` until the async keygen /// reports the key ready. The first call typically returns 202 pending. +/// +/// Ceiling 3600 x 100ms ~ 360s: all five tests here run in parallel, each with +/// its own 2048-bit safe-prime keygen (the scheme rejects smaller moduli), so +/// on a slow shared CI runner a single key can take minutes to come ready. The +/// poll exits as soon as the key is ready; the ceiling only spends wall clock +/// on degraded runners instead of failing. async fn fetch_pubkey(client: &reqwest::Client, base: &str, group: &str) -> PubKey { - for _ in 0..1200 { + for _ in 0..3600 { let res = client .get(format!("{base}/key?group_id={group}")) .send() diff --git a/tests/keygen_dos.rs b/tests/keygen_dos.rs index 742b29b..bdb0c28 100644 --- a/tests/keygen_dos.rs +++ b/tests/keygen_dos.rs @@ -26,14 +26,15 @@ const FAST_BITS: usize = 1024; /// Poll `GET /key` until ready (200) or timeout. Returns the final status code. /// -/// The ceiling (1200 x 100ms ~ 120s) matches the other integration suites -/// (at_rest, issuance): on shared CI runners the test binary runs its tests in -/// parallel, so many safe-prime keygens contend for a few vCPUs and a single -/// group can legitimately take well over 15s to come ready. The DoS bound is -/// asserted by the immediate 200/202 responses, not by this eventual-readiness -/// poll, so a generous ceiling weakens nothing. +/// The ceiling (3600 x 100ms ~ 360s) matches the other integration suites +/// (at_rest, issuance): safe-prime keygen is high-variance, the binary runs its +/// tests in parallel, and shared CI runners are slow, so many concurrent +/// keygens contending for a few vCPUs can legitimately take minutes. The poll +/// exits as soon as the key is ready, so the ceiling costs nothing on a healthy +/// run. The DoS bound is asserted by the immediate 200/202 responses, not by +/// this eventual-readiness poll, so a generous ceiling weakens nothing. async fn poll_until_ready(client: &reqwest::Client, base: &str, group: &str) -> bool { - for _ in 0..1200 { + for _ in 0..3600 { let res = client .get(format!("{base}/key?group_id={group}")) .send() From bea2662ede5569e46484a18c1a4a646e7d24b8cc Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:22:20 -0400 Subject: [PATCH 04/20] fix(main): consume secret env vars before the async runtime starts Audit L1: SIGNET_KEK was parsed and removed from the environment inside an async fn running on an already-started multi-thread tokio runtime, so the remove_var call raced any worker thread reading the environment and the SAFETY comment claiming single-threaded execution was false. Config loading now happens in a synchronous main() before the runtime is built, making the env mutation genuinely single-threaded; the SAFETY comment states the real invariant. FLAG: this touches the startup path of the existing deployed /sign surface (behavior on the wire is unchanged; gated by the existing test suite and the blind-RSA interop harness). --- src/config.rs | 7 ++++--- src/main.rs | 33 +++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/config.rs b/src/config.rs index 545af2f..ad1664e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -94,9 +94,10 @@ impl Config { // crash dump that walks the environment block. The in-memory `Kek` is // the only remaining copy and is itself zeroized on drop. // - // SAFETY: `remove_var` is sound here because config loading happens once - // at startup, before any worker threads that might read the environment - // are spawned (see `main::run`), so there is no concurrent env access. + // SAFETY: `remove_var` is sound here because `Config::from_env` is + // called from `main` BEFORE the tokio runtime is built (audit L1), so + // the process is still single-threaded and there is no concurrent env + // access. Callers must preserve that ordering. std::env::remove_var("SIGNET_KEK"); let kek = kek_result.map_err(|e| format!("SIGNET_KEK is invalid: {e}"))?; diff --git a/src/main.rs b/src/main.rs index 84fbdc8..5f099d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,7 @@ use signet::state::AppState; use signet::{router, serve, tls}; use std::sync::Arc; -#[tokio::main] -async fn main() { +fn main() { tracing_subscriber::fmt() .json() .with_env_filter( @@ -24,20 +23,42 @@ async fn main() { ) .init(); - if let Err(e) = run().await { + // Parse configuration BEFORE the async runtime exists (audit L1). Config + // loading consumes secret environment variables (`SIGNET_KEK`) via + // `std::env::remove_var`, which is only sound while the process is still + // single-threaded. A `#[tokio::main]` entrypoint would spawn the runtime's + // worker threads first and make that env mutation a data race. + let cfg = match Config::from_env() { + Ok(cfg) => cfg, + Err(e) => { + tracing::error!(error = %e, "fatal"); + std::process::exit(1); + } + }; + + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + tracing::error!(error = %e, "fatal: failed to build the tokio runtime"); + std::process::exit(1); + } + }; + + if let Err(e) = runtime.block_on(run(cfg)) { tracing::error!(error = %e, "fatal"); std::process::exit(1); } } -async fn run() -> Result<(), String> { +async fn run(cfg: Config) -> Result<(), String> { // Install the ring-based default crypto provider for rustls 0.23. rustls::crypto::ring::default_provider() .install_default() .map_err(|_| "failed to install rustls crypto provider".to_string())?; - let cfg = Config::from_env()?; - let db = Arc::new(Db::open(&cfg.db_path)?); let keygen = KeygenService::new( db.clone(), From 66cbf5df9fc5116c5c55a4746adf09442cea0be8 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:24:44 -0400 Subject: [PATCH 05/20] feat(db): service_keys and dedup_entries storage with record-first register New PRF-surface tables, disjoint from the blind-RSA tables: - service_keys(purpose PK, sealed, created_at): KEK-sealed service key material, write-once per purpose (never silently overwritten). - dedup_entries(entry_ref BLOB PK 16B random, value BLOB UNIQUE, owner_tag, badge_type, created_at): the credential dedup ledger. UNIQUE(value) is the dedup comparison over deterministic VOPRF outputs; entry_ref is the opaque handle Minister stores; owner_tag is an opaque per-user handle, never a raw Minister userId. Operations mirror the proven reserve_issuance pattern: register is record-first (INSERT, then classify the UNIQUE conflict under the same connection lock into already_yours / taken), release is owner-checked and idempotent, and reassign is per-ref owner-checked, all-or-nothing in one transaction (merge / reverse-merge support). Tests: write-once service keys, register/already_yours/taken, a 16-way concurrent register race with exactly one winner, owner-checked release round-trip, and atomic per-ref reassign incl. rollback on mismatch. --- src/db.rs | 439 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 435 insertions(+), 4 deletions(-) diff --git a/src/db.rs b/src/db.rs index 8ace052..8d51bad 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,14 +1,20 @@ -//! SQLite persistence: group keys (encrypted private key at rest) and the +//! SQLite persistence: group keys (encrypted private key at rest), the //! issuance ledger that enforces one-signature-per-(group, participant, -//! version) and feeds rate limiting / audit. +//! version) and feeds rate limiting / audit, plus the PRF-surface tables — +//! `service_keys` (KEK-sealed service key material) and `dedup_entries` +//! (the credential dedup ledger, UNIQUE over the deterministic VOPRF output). //! //! Concurrency: a single write-serialized connection behind a mutex is the //! simplest race-safe design for this low-throughput service. The uniqueness -//! invariant is additionally backed by a UNIQUE index, so even if the -//! application logic were bypassed the database refuses a second issuance. +//! invariants are additionally backed by UNIQUE indexes, so even if the +//! application logic were bypassed the database refuses a second issuance / +//! a second registration of the same dedup value. //! //! NEVER stored: the unblinded nonce, the blinded message, or any signature. //! The issuance row holds only (group_id, participant_id, version_id, ts). +//! The dedup ledger stores only (entry_ref, value, owner_tag, badge_type, ts): +//! `value` is a PRF output (never the raw anchor) and `owner_tag` is an opaque +//! per-user handle minted by Minister (never a raw Minister userId). use crate::keystore::Kek; use rusqlite::{params, Connection, OptionalExtension}; @@ -35,6 +41,53 @@ pub struct GroupKey { pub sealed_pkcs8: Vec, } +/// A row in the credential dedup ledger. +pub struct DedupEntry { + /// Opaque 16-byte random primary key; the handle Minister stores as + /// `Badge.nullifierRef`. + pub entry_ref: Vec, + /// The deterministic stage-1 VOPRF output (`N_dedup`, 64 bytes). UNIQUE — + /// byte equality of this column IS the dedup comparison. + pub value: Vec, + /// Opaque per-user owner handle minted by Minister (never a raw userId). + pub owner_tag: String, + pub badge_type: String, +} + +/// Outcome of a record-first dedup registration. +pub enum DedupRegister { + /// The value was new; a fresh entry was recorded. + Registered { entry_ref: Vec }, + /// The value already exists and is owned by the SAME owner tag + /// (re-issue / renewal); the existing entry ref is returned. + AlreadyYours { entry_ref: Vec }, + /// The value already exists under a DIFFERENT owner tag: refused + /// (one-credential-one-account). + Taken, +} + +/// Outcome of an owner-checked release. +pub enum DedupRelease { + Released, + /// No such entry — treated as success by callers (idempotent retry). + NotFound, + /// The entry exists but is owned by a different tag: refused. + OwnerMismatch, +} + +/// Outcome of an owner-checked, all-or-nothing batch reassign. +pub enum DedupReassign { + /// Every listed ref is now owned by the target tag; `moved` counts the + /// rows whose owner actually changed in this call (refs already owned by + /// the target are idempotent no-ops). + Reassigned { moved: usize }, + /// A listed ref does not exist; nothing was changed. + NotFound, + /// A listed ref is owned by neither the source nor the target tag; + /// nothing was changed. + OwnerMismatch, +} + /// Current unix time in seconds. /// /// `SystemTime::now()` can only be before the unix epoch if the host clock is @@ -115,6 +168,25 @@ impl Db { ON issuances(participant_id, issued_at); CREATE INDEX IF NOT EXISTS idx_issuance_time ON issuances(issued_at); + + -- PRF-surface service keys, KEK-sealed (AES-GCM, AAD-bound to the + -- purpose string). NEVER plaintext key material. + CREATE TABLE IF NOT EXISTS service_keys ( + purpose TEXT PRIMARY KEY, + sealed BLOB NOT NULL, + created_at INTEGER NOT NULL + ); + + -- Credential dedup ledger: UNIQUE(value) is the dedup comparison + -- (byte equality of deterministic VOPRF outputs). entry_ref is an + -- opaque random handle; owner_tag an opaque per-user handle. + CREATE TABLE IF NOT EXISTS dedup_entries ( + entry_ref BLOB PRIMARY KEY, + value BLOB NOT NULL UNIQUE, + owner_tag TEXT NOT NULL, + badge_type TEXT NOT NULL, + created_at INTEGER NOT NULL + ); "#, ) .map_err(|e| e.to_string())?; @@ -268,6 +340,186 @@ impl Db { .map_err(|e| e.to_string()) } + // ----------------------------------------------------------------------- + // PRF surface: service_keys + // ----------------------------------------------------------------------- + + /// Fetch the sealed blob for a service-key purpose, if present. + pub fn get_service_key(&self, purpose: &str) -> Result>, String> { + let conn = self.lock_conn(); + conn.query_row( + "SELECT sealed FROM service_keys WHERE purpose = ?1", + params![purpose], + |row| row.get::<_, Vec>(0), + ) + .optional() + .map_err(|e| e.to_string()) + } + + /// Insert a sealed service key. Returns `false` (and stores nothing) if a + /// row for this purpose already exists — service keys are never silently + /// overwritten (key-fork prevention). + pub fn insert_service_key(&self, purpose: &str, sealed: &[u8]) -> Result { + let conn = self.lock_conn(); + let res = conn.execute( + "INSERT INTO service_keys (purpose, sealed, created_at) VALUES (?1, ?2, ?3)", + params![purpose, sealed, now_secs()], + ); + match res { + Ok(_) => Ok(true), + Err(rusqlite::Error::SqliteFailure(e, _)) + if e.code == rusqlite::ErrorCode::ConstraintViolation => + { + Ok(false) + } + Err(e) => Err(e.to_string()), + } + } + + // ----------------------------------------------------------------------- + // PRF surface: dedup ledger + // ----------------------------------------------------------------------- + + /// Record-first dedup registration, mirroring [`Db::reserve_issuance`]: + /// INSERT first and let `UNIQUE(value)` decide the race. On conflict the + /// existing row is fetched UNDER THE SAME CONNECTION LOCK and classified + /// by owner tag, so a concurrent register/release cannot interleave + /// between the insert attempt and the classification. + pub fn register_dedup( + &self, + entry_ref: &[u8], + value: &[u8], + owner_tag: &str, + badge_type: &str, + ) -> Result { + let conn = self.lock_conn(); + let res = conn.execute( + "INSERT INTO dedup_entries (entry_ref, value, owner_tag, badge_type, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![entry_ref, value, owner_tag, badge_type, now_secs()], + ); + match res { + Ok(_) => Ok(DedupRegister::Registered { + entry_ref: entry_ref.to_vec(), + }), + Err(rusqlite::Error::SqliteFailure(e, _)) + if e.code == rusqlite::ErrorCode::ConstraintViolation => + { + // UNIQUE(value) fired (an entry_ref PK collision is a 2^-128 + // event; the None arm below fails it closed as an internal + // error rather than guessing). + let existing = conn + .query_row( + "SELECT entry_ref, owner_tag FROM dedup_entries WHERE value = ?1", + params![value], + |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + match existing { + Some((existing_ref, existing_owner)) if existing_owner == owner_tag => { + Ok(DedupRegister::AlreadyYours { + entry_ref: existing_ref, + }) + } + Some(_) => Ok(DedupRegister::Taken), + None => Err( + "dedup register hit a constraint but no row exists for the value \ + (entry_ref collision?)" + .to_string(), + ), + } + } + Err(e) => Err(e.to_string()), + } + } + + /// Fetch a dedup entry by its opaque ref. + pub fn dedup_entry_by_ref(&self, entry_ref: &[u8]) -> Result, String> { + let conn = self.lock_conn(); + conn.query_row( + "SELECT entry_ref, value, owner_tag, badge_type FROM dedup_entries \ + WHERE entry_ref = ?1", + params![entry_ref], + |row| { + Ok(DedupEntry { + entry_ref: row.get(0)?, + value: row.get(1)?, + owner_tag: row.get(2)?, + badge_type: row.get(3)?, + }) + }, + ) + .optional() + .map_err(|e| e.to_string()) + } + + /// Owner-checked release. The lookup and delete run under one connection + /// lock, so the owner check cannot race a concurrent re-registration. + pub fn release_dedup(&self, entry_ref: &[u8], owner_tag: &str) -> Result { + let conn = self.lock_conn(); + let existing = conn + .query_row( + "SELECT owner_tag FROM dedup_entries WHERE entry_ref = ?1", + params![entry_ref], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| e.to_string())?; + match existing { + None => Ok(DedupRelease::NotFound), + Some(owner) if owner != owner_tag => Ok(DedupRelease::OwnerMismatch), + Some(_) => { + conn.execute( + "DELETE FROM dedup_entries WHERE entry_ref = ?1", + params![entry_ref], + ) + .map_err(|e| e.to_string())?; + Ok(DedupRelease::Released) + } + } + } + + /// Owner-checked batch reassign over an EXPLICIT ref list (merge / reverse + /// merge), all-or-nothing in one transaction. Each ref must be owned by + /// `from` (moved) or already by `to` (idempotent-retry no-op); any other + /// state rolls the whole batch back. + pub fn reassign_dedup( + &self, + entry_refs: &[Vec], + from: &str, + to: &str, + ) -> Result { + let mut conn = self.lock_conn(); + let tx = conn.transaction().map_err(|e| e.to_string())?; + let mut moved = 0usize; + for entry_ref in entry_refs { + let owner = tx + .query_row( + "SELECT owner_tag FROM dedup_entries WHERE entry_ref = ?1", + params![entry_ref], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| e.to_string())?; + match owner { + None => return Ok(DedupReassign::NotFound), // tx drops -> rollback + Some(owner) if owner == from => { + tx.execute( + "UPDATE dedup_entries SET owner_tag = ?1 WHERE entry_ref = ?2", + params![to, entry_ref], + ) + .map_err(|e| e.to_string())?; + moved += 1; + } + Some(owner) if owner == to => {} // already moved (retry) — no-op + Some(_) => return Ok(DedupReassign::OwnerMismatch), // rollback + } + } + tx.commit().map_err(|e| e.to_string())?; + Ok(DedupReassign::Reassigned { moved }) + } + /// Assert that no plaintext PKCS#8 is present in any stored key blob. Used /// by the at-rest test. A real PKCS#8 RSA private key DER begins with the /// SEQUENCE/INTEGER(version=0) prefix `30 82 .. .. 02 01 00`; AES-GCM @@ -428,6 +680,185 @@ mod tests { assert_eq!(opened, b"secret-2"); } + #[test] + fn service_key_insert_is_write_once() { + let db = Db::open_in_memory().unwrap(); + assert!(db.get_service_key("master-seed-v1").unwrap().is_none()); + assert!(db + .insert_service_key("master-seed-v1", b"sealed-1") + .unwrap()); + // A second insert for the same purpose must be refused, leaving the + // original blob untouched (never silently overwrite key material). + assert!(!db + .insert_service_key("master-seed-v1", b"sealed-2") + .unwrap()); + assert_eq!( + db.get_service_key("master-seed-v1").unwrap().unwrap(), + b"sealed-1" + ); + } + + #[test] + fn dedup_register_already_yours_and_taken() { + let db = Db::open_in_memory().unwrap(); + let value = [0xabu8; 64]; + let r1 = db + .register_dedup(&[1u8; 16], &value, "owner-a", "email-domain") + .unwrap(); + let ref1 = match r1 { + DedupRegister::Registered { entry_ref } => entry_ref, + _ => panic!("first register must be Registered"), + }; + // Same value, same owner: already_yours with the SAME entry ref (the + // fresh candidate ref [2;16] must be discarded). + match db + .register_dedup(&[2u8; 16], &value, "owner-a", "email-domain") + .unwrap() + { + DedupRegister::AlreadyYours { entry_ref } => assert_eq!(entry_ref, ref1), + _ => panic!("same owner re-register must be AlreadyYours"), + } + // Same value, different owner: taken. + assert!(matches!( + db.register_dedup(&[3u8; 16], &value, "owner-b", "email-domain") + .unwrap(), + DedupRegister::Taken + )); + } + + #[test] + fn dedup_register_race_has_exactly_one_winner() { + use std::sync::Arc; + let db = Arc::new(Db::open_in_memory().unwrap()); + let value = Arc::new([0x11u8; 64]); + let mut handles = Vec::new(); + for i in 0..16u8 { + let db = db.clone(); + let value = value.clone(); + handles.push(std::thread::spawn(move || { + let mut entry_ref = [0u8; 16]; + entry_ref[0] = i; + let owner = format!("owner-{i}"); + db.register_dedup(&entry_ref, value.as_ref(), &owner, "oauth-account") + .unwrap() + })); + } + let mut registered = 0; + let mut taken = 0; + for h in handles { + match h.join().unwrap() { + DedupRegister::Registered { .. } => registered += 1, + DedupRegister::Taken => taken += 1, + DedupRegister::AlreadyYours { .. } => panic!("distinct owners cannot own it"), + } + } + assert_eq!(registered, 1, "exactly one concurrent register may win"); + assert_eq!(taken, 15, "all losers must see Taken"); + } + + #[test] + fn dedup_release_owner_checked_and_idempotent() { + let db = Db::open_in_memory().unwrap(); + db.register_dedup(&[1u8; 16], &[0x22u8; 64], "owner-a", "email-domain") + .unwrap(); + // Wrong owner: refused, row intact. + assert!(matches!( + db.release_dedup(&[1u8; 16], "owner-b").unwrap(), + DedupRelease::OwnerMismatch + )); + assert!(db.dedup_entry_by_ref(&[1u8; 16]).unwrap().is_some()); + // Right owner: released. + assert!(matches!( + db.release_dedup(&[1u8; 16], "owner-a").unwrap(), + DedupRelease::Released + )); + assert!(db.dedup_entry_by_ref(&[1u8; 16]).unwrap().is_none()); + // Releasing again: NotFound (idempotent retry surface). + assert!(matches!( + db.release_dedup(&[1u8; 16], "owner-a").unwrap(), + DedupRelease::NotFound + )); + // The value is registrable again after release. + assert!(matches!( + db.register_dedup(&[9u8; 16], &[0x22u8; 64], "owner-b", "email-domain") + .unwrap(), + DedupRegister::Registered { .. } + )); + } + + #[test] + fn dedup_reassign_is_per_ref_owner_checked_and_atomic() { + let db = Db::open_in_memory().unwrap(); + db.register_dedup(&[1u8; 16], &[1u8; 64], "donor", "email-domain") + .unwrap(); + db.register_dedup(&[2u8; 16], &[2u8; 64], "donor", "oauth-account") + .unwrap(); + db.register_dedup(&[3u8; 16], &[3u8; 64], "bystander", "email-domain") + .unwrap(); + + // A batch containing a ref owned by a third party must change NOTHING. + let refs: Vec> = vec![vec![1u8; 16], vec![3u8; 16]]; + assert!(matches!( + db.reassign_dedup(&refs, "donor", "survivor").unwrap(), + DedupReassign::OwnerMismatch + )); + assert_eq!( + db.dedup_entry_by_ref(&[1u8; 16]) + .unwrap() + .unwrap() + .owner_tag, + "donor", + "atomicity: the valid ref in a failed batch must not move" + ); + + // A batch with an unknown ref must also change nothing. + let refs: Vec> = vec![vec![1u8; 16], vec![0xffu8; 16]]; + assert!(matches!( + db.reassign_dedup(&refs, "donor", "survivor").unwrap(), + DedupReassign::NotFound + )); + + // The explicit donor refs move; the bystander's entry is untouched. + let refs: Vec> = vec![vec![1u8; 16], vec![2u8; 16]]; + match db.reassign_dedup(&refs, "donor", "survivor").unwrap() { + DedupReassign::Reassigned { moved } => assert_eq!(moved, 2), + _ => panic!("reassign must succeed"), + } + assert_eq!( + db.dedup_entry_by_ref(&[1u8; 16]) + .unwrap() + .unwrap() + .owner_tag, + "survivor" + ); + assert_eq!( + db.dedup_entry_by_ref(&[3u8; 16]) + .unwrap() + .unwrap() + .owner_tag, + "bystander" + ); + + // Retry after full success: idempotent (0 moved, still Reassigned). + match db.reassign_dedup(&refs, "donor", "survivor").unwrap() { + DedupReassign::Reassigned { moved } => assert_eq!(moved, 0), + _ => panic!("idempotent retry must succeed"), + } + + // Reverse merge: exactly the recorded refs move back. + match db.reassign_dedup(&refs, "survivor", "donor").unwrap() { + DedupReassign::Reassigned { moved } => assert_eq!(moved, 2), + _ => panic!("reverse reassign must succeed"), + } + assert_eq!( + db.dedup_entry_by_ref(&[2u8; 16]) + .unwrap() + .unwrap() + .owner_tag, + "donor" + ); + } + #[test] fn rotate_key_sealed_rolls_back_when_reseal_fails() { // A failed reseal during rotation must roll back BOTH the retire and the From 341429eaef7dca0dd4febb0a3df6f7289f74526f Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:30:31 -0400 Subject: [PATCH 06/20] feat(prf): RFC 9497 VOPRF core, nullifier key schedule, pairwise oracle src/prf.rs implements the crypto core of the PRF surface: - Stage-1 dedup VOPRF: RFC 9497 mode 0x01, ciphersuite ristretto255-SHA512 via the voprf crate (curve25519-dalek backend), blind evaluation with a DLEQ proof against the pinned public key. - Key schedule: master_seed -> seed_null = HKDF-SHA512(seed, '', 'minister/v1/nullifier') -> (skS,pkS) = DeriveKeyPair(seed_null, 'minister/v1/nullifier/dedup'); per-RP disclose keys k_disc(clientId) = HKDF-SHA512(seed, '', 'minister/v1/nullifier/disclose'||LP(cid)). - Stage-2 disclose: N_rp = 'mnv1:' || b64url(HMAC-SHA256(k_disc(cid), LP('minister/null/v1')||LP('rp')||LP(N_dedup)||LP(cid))), computed only over the STORED dedup output. - Pairwise oracle: b64url(HMAC-SHA256(imported secret, input)), byte-identical to Minister's live Node derivation. - LP(x) = 2-byte big-endian length prefix everywhere; never bare concatenation. Tests: RFC 9497 Appendix A.1.2 vectors byte-exact (DeriveKeyPair pkSm, BlindedElement, EvaluationElement, Output for both vectors; the randomized DLEQ proof is verified via finalize, plus a wrong-key negative); the four Minister Phase 0 golden pairwise vectors asserted byte-equal; frozen ecosystem vectors (interop/prf-vectors.json, shared with the interop harness and Minister CI); per-RP disclosure unlinkability; malformed/identity group-element rejection. New deps, pinned exact: voprf =0.5.0 (the RFC 9497 implementation; 'danger' feature dev-only for deterministic test blinds), hkdf =0.12.4 (key schedule), hmac =0.12.1 + sha2 =0.10.9 (stage-2/pairwise HMAC), rand_core =0.6.4 (OsRng for voprf's rand_core-0.6 RNG bound). --- Cargo.lock | 235 +++++++++++++++-- Cargo.toml | 17 ++ interop/prf-vectors.json | 39 +++ src/lib.rs | 1 + src/prf.rs | 544 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 820 insertions(+), 16 deletions(-) create mode 100644 interop/prf-vectors.json create mode 100644 src/prf.rs diff --git a/Cargo.lock b/Cargo.lock index cc8c1c0..e66357c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,6 +200,12 @@ dependencies = [ "tower-service", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base16ct" version = "1.0.0" @@ -230,12 +236,12 @@ version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7c8e1ec3966bafbe115ad484420b260f5fabf88528e4ef8cd3024ffedb50e46" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-primes", "ct-codecs", "derive-new", "derive_more", - "digest", + "digest 0.11.3", "hmac-sha256", "hmac-sha512", "rand 0.10.1", @@ -244,6 +250,15 @@ dependencies = [ "serde", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -325,6 +340,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -364,6 +385,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-bigint" version = "0.7.5" @@ -404,7 +437,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "libm", "rand_core 0.10.1", ] @@ -433,19 +466,56 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rand_core 0.6.4", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "der" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "const-oid", + "const-oid 0.10.2", "pem-rfc7468", "zeroize", ] @@ -481,6 +551,17 @@ dependencies = [ "syn", ] +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -504,14 +585,25 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "const-oid", + "block-buffer 0.12.1", + "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -532,6 +624,24 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -566,6 +676,22 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -656,6 +782,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -707,6 +834,17 @@ dependencies = [ "polyval", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.15" @@ -756,13 +894,31 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac-sha256" version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -771,7 +927,7 @@ version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -1251,7 +1407,7 @@ version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ - "der", + "der 0.8.0", "spki", ] @@ -1261,7 +1417,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", + "der 0.8.0", "spki", ] @@ -1534,10 +1690,10 @@ version = "0.10.0-rc.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" dependencies = [ - "const-oid", - "crypto-bigint", + "const-oid 0.10.2", + "crypto-bigint 0.7.5", "crypto-primes", - "digest", + "digest 0.11.3", "pkcs1", "pkcs8", "rand_core 0.10.1", @@ -1658,6 +1814,19 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -1736,10 +1905,21 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct", + "base16ct 1.0.0", "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1771,7 +1951,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest", + "digest 0.11.3", "rand_core 0.10.1", ] @@ -1785,7 +1965,10 @@ dependencies = [ "base64", "blind-rsa-signatures", "hex", + "hkdf", + "hmac", "rand 0.9.4", + "rand_core 0.6.4", "rcgen", "reqwest", "rusqlite", @@ -1793,12 +1976,14 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "sha2", "tempfile", "tokio", "tokio-rustls", "tower", "tracing", "tracing-subscriber", + "voprf", "x509-parser", "zeroize", ] @@ -1832,7 +2017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.0", ] [[package]] @@ -2228,6 +2413,24 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "voprf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28f59c30c76e2fea54cdece6a054e2662feffa7ab19658a7887524265ee39470" +dependencies = [ + "curve25519-dalek", + "derive-where", + "digest 0.10.7", + "displaydoc", + "elliptic-curve", + "generic-array", + "rand_core 0.6.4", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index a623f3a..79600b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,19 @@ path = "src/main.rs" # Interop-proven against @cloudflare/blindrsa-ts RSAPBSSA.SHA384.PSS.Randomized. blind-rsa-signatures = "=0.17.2" +# PRF surface (disjoint from the blind-RSA surface): RFC 9497 VOPRF, mode 0x01, +# ciphersuite ristretto255-SHA512 (curve25519-dalek backend). Interop-proven +# against @cloudflare/voprf-ts (see interop/prf.mjs). Crypto deps pinned exact. +voprf = { version = "=0.5.0", default-features = false, features = ["ristretto255-ciphersuite", "std"] } +# Nullifier key schedule (HKDF-SHA512) and stage-2 / pairwise HMAC-SHA256. +hkdf = "=0.12.4" +hmac = "=0.12.1" +sha2 = "=0.10.9" +# rand_core 0.6 OsRng for voprf's blind_evaluate: voprf 0.5 consumes rand_core +# 0.6 RNG traits, which the crate's own `rand` 0.9 (rand_core 0.9) does not +# implement. +rand_core = { version = "=0.6.4", features = ["getrandom"] } + # HTTP + TLS axum = { version = "0.8", default-features = false, features = ["json", "tokio", "http1", "query"] } axum-server = { version = "0.7", features = ["tls-rustls"] } @@ -60,6 +73,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-manual-roots"] } tempfile = "3" rcgen = "0.13" +# `danger` exposes deterministic_blind_unchecked, needed ONLY to drive the +# RFC 9497 Appendix A test vectors (fixed blinds). Dev-only: the release +# binary is built without dev-dependency feature unification. +voprf = { version = "=0.5.0", default-features = false, features = ["ristretto255-ciphersuite", "std", "danger"] } [profile.release] opt-level = 3 diff --git a/interop/prf-vectors.json b/interop/prf-vectors.json new file mode 100644 index 0000000..51ff2c6 --- /dev/null +++ b/interop/prf-vectors.json @@ -0,0 +1,39 @@ +{ + "comment": "Minister-ecosystem frozen PRF vectors. Cross-repo byte-equality fixtures: Signet's unit tests (src/prf.rs), the cross-language interop harness (interop/prf.mjs), and Minister's CI fixture job all assert these exact bytes. Value-stable FOREVER — a change here means the nullifier construction drifted. The master seed is a TEST fixture, never a production key.", + "suite": "ristretto255-SHA512", + "voprf_mode": 1, + "master_seed_hex": "4d494e49535445522d544553542d564543544f522d534545442d303030312121", + "public_key_b64url": "8uMuBaBUTsZb-btCd6BMV_NdqYdyXqOkoh5NepCesAg", + "dedup": { + "sybil_id": "gh:1234567", + "badge_type": "oauth-account", + "input_hex": "00106d696e69737465722f6e756c6c2f763100056465647570000a67683a31323334353637000d6f617574682d6163636f756e74", + "n_dedup_hex": "bf13858616d54fc8d0268cc46a7998d60f12e6e15b2c6f17d63e708a5a2b937e65eee45487732818e9542260afb7e7abb3deaab5a021a44d0b03e76b37bb2a21" + }, + "disclose": { + "client_id": "mc_golden_client_0001", + "n_rp": "mnv1:b1Er88B8RZaAeBIMLBpKCBfk7zuF5O3JZv75aSZQbmI" + }, + "pairwise": { + "comment": "Minister Phase 0 golden pairwise vectors, frozen in Minister's oidc-claims.pairwise.test.ts. output = base64url(HMAC-SHA256(secret, input)), no padding.", + "secret_utf8": "minister-golden-vector-secret-v1-do-not-change!!", + "vectors": [ + { + "input": "user_golden_0001:mc_golden_client_0001", + "output": "xOfT05jnZI0r8hweyDLf7GnlAlPoUhHHoUsKH49Olm0" + }, + { + "input": "jti:badge_golden_0001:mc_golden_client_0001", + "output": "5fIc0YcinsYRBEf1J6aZXcoKuxmDStXGch6Rk_bDylM" + }, + { + "input": "sharelink:user_golden_0001:share_golden_0001", + "output": "3Wfr4iEXijtFDIQ9JkYamk6r427jpcY4ApbNbShi9sY" + }, + { + "input": "jti:sharelink:badge_golden_0001:share_golden_0001", + "output": "8ITdmHQXFlAukLUGdhAOqexFVwIEbnQFKvOnHy3LoOo" + } + ] + } +} diff --git a/src/lib.rs b/src/lib.rs index 9dff2b1..9f7ae01 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod handlers; pub mod identity; pub mod keygen; pub mod keystore; +pub mod prf; pub mod ratelimit; pub mod state; pub mod tls; diff --git a/src/prf.rs b/src/prf.rs new file mode 100644 index 0000000..f1b803e --- /dev/null +++ b/src/prf.rs @@ -0,0 +1,544 @@ +//! PRF core for the Minister nullifier + pairwise surface. +//! +//! Three primitives, all keyed from material sealed in `service_keys`: +//! +//! 1. **Stage-1 dedup VOPRF** — RFC 9497, VOPRF mode (0x01), ciphersuite +//! ristretto255-SHA512 (`voprf` crate, curve25519-dalek backend). Minister +//! blinds the credential anchor; Signet evaluates BLIND (it never sees the +//! anchor) and returns the evaluation element plus a DLEQ proof that the +//! evaluation used the pinned key; Minister finalizes to the deterministic +//! 64-byte `N_dedup`, which it registers in the dedup ledger. +//! 2. **Stage-2 disclose HMAC** — computed INSIDE Signet over the STORED +//! `N_dedup`: `N_rp = "mnv1:" || base64url(HMAC-SHA256(k_disc(clientId), +//! LP("minister/null/v1") || LP("rp") || LP(N_dedup) || LP(clientId)))`. +//! Per-RP distinct keys; the clientId appears in BOTH the key derivation +//! and the message (belt and braces against a derivation bug collapsing +//! RPs). Deliberately not blinded and not proof-carrying: the input is a +//! PRF output Signet already stores, and Minister could not DLEQ-verify it +//! without holding `N_dedup` (which would recreate the equality oracle the +//! ledger was moved here to avoid). +//! 3. **Pairwise HMAC oracle** — keyed HMAC-SHA256 over an opaque input with +//! the IMPORTED live `OIDC_PAIRWISE_SECRET` bytes, preserving byte-for-byte +//! output stability with Minister's live Node path (which is what makes the +//! pairwise cutover provable and its local fallback safe). +//! +//! Key schedule (two roots, never rotated): +//! +//! ```text +//! master_seed (32B OS RNG, explicit one-shot init only) +//! ├─ seed_null = HKDF-SHA512(ikm=master_seed, salt="", info="minister/v1/nullifier", L=32) +//! │ └─ (skS, pkS) = DeriveKeyPair(seed_null, info="minister/v1/nullifier/dedup") [RFC 9497 §3.2.1] +//! └─ k_disc(clientId) = HKDF-SHA512(ikm=master_seed, salt="", +//! info="minister/v1/nullifier/disclose" || LP(clientId), L=32) +//! pairwise secret (imported once, sealed; exact UTF-8 bytes of the live secret) +//! ``` +//! +//! Input encoding: `LP(x)` = 2-byte big-endian byte length of x followed by +//! the bytes — NEVER bare concatenation (a variable-length attacker-influenced +//! field could otherwise collide two distinct tuples into one PRF input). +//! Handlers cap every field (anchor <= 512 is enforced Minister-side before +//! blinding; badge_type <= 64, clientId <= 256 here), so lengths always fit. +//! +//! Wire encoding note: every binary field on the PRF/dedup HTTP surface uses +//! base64url WITHOUT padding (matching the base64url outputs Minister's +//! pairwise path produces). The blind-RSA surface keeps standard base64. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL; +use base64::Engine; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use sha2::{Sha256, Sha512}; +use voprf::{BlindedElement, Group, Ristretto255, VoprfServer}; +use zeroize::Zeroizing; + +type HmacSha256 = Hmac; + +/// `service_keys.purpose` of the sealed 32-byte nullifier master seed. +pub const MASTER_SEED_PURPOSE: &str = "master-seed-v1"; +/// `service_keys.purpose` of the sealed imported pairwise HMAC secret. +pub const PAIRWISE_HMAC_PURPOSE: &str = "pairwise-hmac-v1"; +/// Master seed length (bytes). +pub const MASTER_SEED_LEN: usize = 32; +/// The VOPRF ciphersuite identifier served on /prf/public-key. +pub const SUITE: &str = "ristretto255-SHA512"; +/// Serialized ristretto255 group element length (blinded element, evaluation +/// element, public key). +pub const ELEMENT_LEN: usize = 32; +/// Serialized DLEQ proof length (two 32-byte scalars, c || s). +pub const PROOF_LEN: usize = 64; +/// Stage-1 output (`N_dedup`) length: the SHA-512 Finalize output. +pub const DEDUP_VALUE_LEN: usize = 64; +/// Version prefix stamped on every disclosed nullifier, forever. +pub const NULLIFIER_PREFIX: &str = "mnv1:"; + +const INFO_NULLIFIER_SEED: &[u8] = b"minister/v1/nullifier"; +const INFO_DEDUP_KEYPAIR: &[u8] = b"minister/v1/nullifier/dedup"; +const INFO_DISCLOSE: &[u8] = b"minister/v1/nullifier/disclose"; +const TAG_PROTOCOL: &str = "minister/null/v1"; +const TAG_DEDUP: &str = "dedup"; +const TAG_RP: &str = "rp"; + +/// Append `LP(bytes)`: 2-byte big-endian length, then the bytes. +/// +/// Panics if `bytes` exceeds `u16::MAX` — callers cap every field orders of +/// magnitude below that, so a panic here is an internal invariant violation, +/// not a reachable input path. +fn lp(out: &mut Vec, bytes: &[u8]) { + let len = u16::try_from(bytes.len()).expect("LP input exceeds u16::MAX; caller must cap"); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(bytes); +} + +/// The stage-1 dedup PRF input for a credential anchor: +/// `LP("minister/null/v1") || LP("dedup") || LP(sybil_id) || LP(badge_type)`. +/// +/// Production Signet NEVER computes this — Minister builds it, blinds it, and +/// sends only the blinded element. It lives here as the single frozen +/// definition shared by the golden-vector tests and the cross-language +/// interop harness. +pub fn dedup_input(sybil_id: &str, badge_type: &str) -> Vec { + let mut out = Vec::with_capacity( + 8 + TAG_PROTOCOL.len() + TAG_DEDUP.len() + sybil_id.len() + badge_type.len(), + ); + lp(&mut out, TAG_PROTOCOL.as_bytes()); + lp(&mut out, TAG_DEDUP.as_bytes()); + lp(&mut out, sybil_id.as_bytes()); + lp(&mut out, badge_type.as_bytes()); + out +} + +/// Errors surfaced to handlers. Deliberately carries no detail: a bad group +/// element is a 400, never a 500, and never echoes input bytes. +#[derive(Debug, PartialEq, Eq)] +pub enum PrfError { + /// The supplied bytes are not a valid ristretto255 group element. + BadElement, +} + +/// Result of a blind evaluation: both fields serialized, ready for the wire. +/// (Both are public wire values; Debug is derived for test ergonomics.) +#[derive(Debug)] +pub struct EvaluateOutput { + /// Serialized evaluation element (32 bytes). + pub evaluation_element: Vec, + /// Serialized DLEQ proof (64 bytes, c || s). + pub proof: Vec, +} + +/// The in-memory PRF key material for an enabled PRF surface. +/// +/// Holds the VOPRF secret scalar, the master seed (for per-RP disclose-key +/// derivation), and the imported pairwise secret — the operating keys of the +/// service, same sensitivity class as the process-held KEK. The seed and +/// pairwise secret are zeroized on drop. +pub struct PrfKeys { + server: VoprfServer, + master_seed: Zeroizing<[u8; MASTER_SEED_LEN]>, + pairwise: Option>>, +} + +impl PrfKeys { + /// Derive the full key schedule from the master seed (and optionally the + /// imported pairwise secret). Deterministic: the same seed always yields + /// the same `(skS, pkS)` — the property the boot-time public-key pin + /// check relies on. + pub fn from_seed( + master_seed: [u8; MASTER_SEED_LEN], + pairwise: Option>>, + ) -> Result { + let master_seed = Zeroizing::new(master_seed); + let hk = Hkdf::::new(None, master_seed.as_ref()); + let mut seed_null = Zeroizing::new([0u8; 32]); + hk.expand(INFO_NULLIFIER_SEED, seed_null.as_mut()) + .map_err(|_| "HKDF expand for the nullifier seed failed".to_string())?; + let server = + VoprfServer::::new_from_seed(seed_null.as_ref(), INFO_DEDUP_KEYPAIR) + .map_err(|e| format!("VOPRF DeriveKeyPair failed: {e:?}"))?; + Ok(Self { + server, + master_seed, + pairwise, + }) + } + + /// The serialized VOPRF public key `pkS` (32 bytes). + pub fn public_key_bytes(&self) -> Vec { + ::serialize_elem(self.server.get_public_key()).to_vec() + } + + /// `pkS` in the pin encoding (base64url, no padding) — the exact string + /// `init-service-keys` prints and `SIGNET_DEDUP_PUBKEY_PIN` must equal. + pub fn public_key_b64(&self) -> String { + B64URL.encode(self.public_key_bytes()) + } + + /// Blind-evaluate a serialized blinded element, returning the evaluation + /// element and a DLEQ proof against `pkS`. The element is validated by + /// deserialization (a non-canonical or identity encoding is rejected). + pub fn evaluate(&self, blinded_element: &[u8]) -> Result { + let element = BlindedElement::::deserialize(blinded_element) + .map_err(|_| PrfError::BadElement)?; + let result = self.server.blind_evaluate(&mut rand_core::OsRng, &element); + Ok(EvaluateOutput { + evaluation_element: result.message.serialize().to_vec(), + proof: result.proof.serialize().to_vec(), + }) + } + + /// Full unblinded VOPRF evaluation. Tests and fixture generation ONLY: + /// the HTTP surface never accepts an unblinded input (production anchors + /// reach Signet only as blinded elements). Not routed. + pub fn evaluate_unblinded(&self, input: &[u8]) -> Result, String> { + self.server + .evaluate(input) + .map(|o| o.to_vec()) + .map_err(|e| format!("VOPRF evaluate failed: {e:?}")) + } + + /// Stage-2 per-RP disclosure over the STORED `N_dedup`. + pub fn disclose(&self, n_dedup: &[u8], client_id: &str) -> String { + // k_disc(clientId) = HKDF-SHA512(master_seed, "", INFO_DISCLOSE || LP(clientId), 32) + let mut info = Vec::with_capacity(INFO_DISCLOSE.len() + 2 + client_id.len()); + info.extend_from_slice(INFO_DISCLOSE); + lp(&mut info, client_id.as_bytes()); + let hk = Hkdf::::new(None, self.master_seed.as_ref()); + let mut k_disc = Zeroizing::new([0u8; 32]); + hk.expand(&info, k_disc.as_mut()) + .expect("32 bytes is a valid HKDF-SHA512 output length"); + + let mut msg = Vec::with_capacity( + 8 + TAG_PROTOCOL.len() + TAG_RP.len() + n_dedup.len() + client_id.len(), + ); + lp(&mut msg, TAG_PROTOCOL.as_bytes()); + lp(&mut msg, TAG_RP.as_bytes()); + lp(&mut msg, n_dedup); + lp(&mut msg, client_id.as_bytes()); + + let mut mac = + HmacSha256::new_from_slice(k_disc.as_ref()).expect("HMAC accepts any key length"); + mac.update(&msg); + format!( + "{NULLIFIER_PREFIX}{}", + B64URL.encode(mac.finalize().into_bytes()) + ) + } + + /// Whether the pairwise secret has been imported. + pub fn has_pairwise(&self) -> bool { + self.pairwise.is_some() + } + + /// The pairwise HMAC oracle: `base64url(HMAC-SHA256(secret, input))`, no + /// padding — byte-identical to Minister's live Node derivation. Returns + /// `None` when no pairwise secret has been imported (the caller maps that + /// to a fail-closed 404). + pub fn pairwise(&self, input: &[u8]) -> Option { + let key = self.pairwise.as_ref()?; + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(input); + Some(B64URL.encode(mac.finalize().into_bytes())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use voprf::VoprfClient; + + fn unhex(s: &str) -> Vec { + hex::decode(s).unwrap() + } + + #[test] + fn lp_is_two_byte_big_endian_length_prefixed() { + let mut out = Vec::new(); + lp(&mut out, b""); + lp(&mut out, b"ab"); + assert_eq!(out, [0x00, 0x00, 0x00, 0x02, b'a', b'b']); + let mut long = Vec::new(); + lp(&mut long, &[0x7f; 300]); + assert_eq!(&long[..2], &[0x01, 0x2c], "300 as 2-byte big-endian"); + assert_eq!(long.len(), 302); + } + + #[test] + fn dedup_input_is_lp_framed_and_collision_free_across_field_splits() { + let a = dedup_input("ab", "c"); + let b = dedup_input("a", "bc"); + assert_ne!(a, b, "LP framing must separate (ab,c) from (a,bc)"); + let expected = { + let mut v = Vec::new(); + lp(&mut v, b"minister/null/v1"); + lp(&mut v, b"dedup"); + lp(&mut v, b"ab"); + lp(&mut v, b"c"); + v + }; + assert_eq!(a, expected); + } + + // ----------------------------------------------------------------------- + // RFC 9497 Appendix A.1.2 — VOPRF mode, ristretto255-SHA512. + // The decision gate for the ciphersuite: these vectors passing (together + // with the cross-language interop harness) is what keeps ristretto255; + // a failure here means falling back to P256-SHA256 per the build plan. + // ----------------------------------------------------------------------- + + const RFC_SEED: &str = "a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3"; + const RFC_KEY_INFO: &[u8] = b"test key"; + const RFC_PKSM: &str = "c803e2cc6b05fc15064549b5920659ca4a77b2cca6f04f6b357009335476ad4e"; + + fn rfc_server() -> VoprfServer { + VoprfServer::::new_from_seed(&unhex(RFC_SEED), RFC_KEY_INFO).unwrap() + } + + #[test] + fn rfc9497_derive_key_pair_matches_pksm() { + let server = rfc_server(); + let pk = ::serialize_elem(server.get_public_key()); + assert_eq!(hex::encode(pk), RFC_PKSM); + } + + struct RfcVector { + input: &'static str, + blind: &'static str, + blinded_element: &'static str, + evaluation_element: &'static str, + output: &'static str, + } + + const RFC_VECTORS: [RfcVector; 2] = [ + RfcVector { + input: "00", + blind: "64d37aed22a27f5191de1c1d69fadb899d8862b58eb4220029e036ec4c1f6706", + blinded_element: "863f330cc1a1259ed5a5998a23acfd37fb4351a793a5b3c090b642ddc439b945", + evaluation_element: "aa8fa048764d5623868679402ff6108d2521884fa138cd7f9c7669a9a014267e", + output: "b58cfbe118e0cb94d79b5fd6a6dafb98764dff49c14e1770b566e42402da1a7da4d8527693914139caee5bd03903af43a491351d23b430948dd50cde10d32b3c", + }, + RfcVector { + input: "5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a", + blind: "64d37aed22a27f5191de1c1d69fadb899d8862b58eb4220029e036ec4c1f6706", + blinded_element: "cc0b2a350101881d8a4cba4c80241d74fb7dcbfde4a61fde2f91443c2bf9ef0c", + evaluation_element: "60a59a57208d48aca71e9e850d22674b611f752bed48b36f7a91b372bd7ad468", + output: "8a9a2f3c7f085b65933594309041fc1898d42d0858e59f90814ae90571a6df60356f4610bf816f27afdd84f47719e480906d27ecd994985890e5f539e7ea74b6", + }, + ]; + + #[test] + fn rfc9497_vectors_roundtrip_byte_exact() { + let server = rfc_server(); + let pk = server.get_public_key(); + for v in &RFC_VECTORS { + let input = unhex(v.input); + let blind = ::deserialize_scalar(&unhex(v.blind)).unwrap(); + // Deterministic blind (danger feature) to reproduce the vector. + let blind_result = + VoprfClient::::deterministic_blind_unchecked(&input, blind).unwrap(); + assert_eq!( + hex::encode(blind_result.message.serialize()), + v.blinded_element, + "BlindedElement" + ); + // The evaluation element is deterministic (skS * blinded); the DLEQ + // proof is randomized, so it is checked cryptographically via + // finalize rather than byte-compared. + let eval = server.blind_evaluate(&mut rand_core::OsRng, &blind_result.message); + assert_eq!( + hex::encode(eval.message.serialize()), + v.evaluation_element, + "EvaluationElement" + ); + let output = blind_result + .state + .finalize(&input, &eval.message, &eval.proof, pk) + .expect("finalize (incl. DLEQ verification) must succeed"); + assert_eq!(hex::encode(output), v.output, "Output"); + // The unblinded server-side evaluation must agree byte-for-byte. + assert_eq!(hex::encode(server.evaluate(&input).unwrap()), v.output); + } + } + + #[test] + fn dleq_proof_from_wrong_key_fails_finalize() { + let server = rfc_server(); + let other = + VoprfServer::::new_from_seed(&[0x11u8; 32], RFC_KEY_INFO).unwrap(); + let input = b"input"; + let blind_result = + VoprfClient::::blind(input, &mut rand_core::OsRng).unwrap(); + let eval = other.blind_evaluate(&mut rand_core::OsRng, &blind_result.message); + // Finalizing against the RFC server's public key with an evaluation + // from a DIFFERENT key must fail the DLEQ check. + assert!(blind_result + .state + .finalize(input, &eval.message, &eval.proof, server.get_public_key()) + .is_err()); + } + + // ----------------------------------------------------------------------- + // Minister ecosystem frozen vectors (fixed test master seed). These are + // cross-repo fixtures: the interop harness (interop/prf-vectors.json) and + // the Minister-side CI job assert the same bytes. Value-stable forever. + // ----------------------------------------------------------------------- + + /// 32 bytes, ASCII. Test fixture only — never a production seed. + pub const TEST_MASTER_SEED: &[u8; 32] = b"MINISTER-TEST-VECTOR-SEED-0001!!"; + + fn test_keys() -> PrfKeys { + PrfKeys::from_seed(*TEST_MASTER_SEED, None).unwrap() + } + + #[test] + fn frozen_ecosystem_vectors() { + let keys = test_keys(); + let vectors: serde_json::Value = + serde_json::from_str(include_str!("../interop/prf-vectors.json")).unwrap(); + assert_eq!( + hex::encode(TEST_MASTER_SEED), + vectors["master_seed_hex"].as_str().unwrap() + ); + assert_eq!( + keys.public_key_b64(), + vectors["public_key_b64url"].as_str().unwrap(), + "pkS derived from the frozen test seed drifted" + ); + let sybil_id = vectors["dedup"]["sybil_id"].as_str().unwrap(); + let badge_type = vectors["dedup"]["badge_type"].as_str().unwrap(); + let input = dedup_input(sybil_id, badge_type); + assert_eq!( + hex::encode(&input), + vectors["dedup"]["input_hex"].as_str().unwrap(), + "stage-1 LP input encoding drifted" + ); + let n_dedup = keys.evaluate_unblinded(&input).unwrap(); + assert_eq!(n_dedup.len(), DEDUP_VALUE_LEN); + assert_eq!( + hex::encode(&n_dedup), + vectors["dedup"]["n_dedup_hex"].as_str().unwrap(), + "N_dedup drifted — this value is forever" + ); + let client_id = vectors["disclose"]["client_id"].as_str().unwrap(); + assert_eq!( + keys.disclose(&n_dedup, client_id), + vectors["disclose"]["n_rp"].as_str().unwrap(), + "N_rp drifted — this value is forever" + ); + } + + #[test] + fn disclose_is_per_rp_and_versioned() { + let keys = test_keys(); + let n_dedup = [0x42u8; DEDUP_VALUE_LEN]; + let a = keys.disclose(&n_dedup, "mc_client_a"); + let b = keys.disclose(&n_dedup, "mc_client_b"); + assert_ne!(a, b, "different RPs must receive unlinkable nullifiers"); + assert_eq!(a, keys.disclose(&n_dedup, "mc_client_a"), "deterministic"); + for v in [&a, &b] { + assert!(v.starts_with(NULLIFIER_PREFIX)); + let tail = &v[NULLIFIER_PREFIX.len()..]; + assert_eq!(tail.len(), 43, "base64url(32 bytes), no padding"); + assert!(tail + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')); + } + // Different N_dedup under the same RP: different value. + assert_ne!(a, keys.disclose(&[0x43u8; DEDUP_VALUE_LEN], "mc_client_a")); + } + + // ----------------------------------------------------------------------- + // Minister golden pairwise vectors (Phase 0, frozen in Minister's + // oidc-claims.pairwise.test.ts). Cross-repo byte-equality fixtures. + // ----------------------------------------------------------------------- + + pub const GOLDEN_PAIRWISE_SECRET: &[u8] = b"minister-golden-vector-secret-v1-do-not-change!!"; + + pub const GOLDEN_PAIRWISE_VECTORS: [(&str, &str); 4] = [ + ( + "user_golden_0001:mc_golden_client_0001", + "xOfT05jnZI0r8hweyDLf7GnlAlPoUhHHoUsKH49Olm0", + ), + ( + "jti:badge_golden_0001:mc_golden_client_0001", + "5fIc0YcinsYRBEf1J6aZXcoKuxmDStXGch6Rk_bDylM", + ), + ( + "sharelink:user_golden_0001:share_golden_0001", + "3Wfr4iEXijtFDIQ9JkYamk6r427jpcY4ApbNbShi9sY", + ), + ( + "jti:sharelink:badge_golden_0001:share_golden_0001", + "8ITdmHQXFlAukLUGdhAOqexFVwIEbnQFKvOnHy3LoOo", + ), + ]; + + #[test] + fn minister_golden_pairwise_vectors_byte_equal() { + assert_eq!(GOLDEN_PAIRWISE_SECRET.len(), 48); + let keys = PrfKeys::from_seed( + *TEST_MASTER_SEED, + Some(Zeroizing::new(GOLDEN_PAIRWISE_SECRET.to_vec())), + ) + .unwrap(); + for (input, expected) in GOLDEN_PAIRWISE_VECTORS { + assert_eq!( + keys.pairwise(input.as_bytes()).unwrap(), + expected, + "pairwise vector for {input:?} drifted — cutover byte-stability broken" + ); + } + } + + #[test] + fn pairwise_without_imported_secret_is_none() { + let keys = test_keys(); + assert!(!keys.has_pairwise()); + assert!(keys.pairwise(b"anything").is_none()); + } + + #[test] + fn evaluate_rejects_garbage_elements() { + let keys = test_keys(); + // Wrong length. + assert_eq!(keys.evaluate(&[0u8; 31]).unwrap_err(), PrfError::BadElement); + // 32 bytes that are not a canonical ristretto255 encoding. + assert_eq!( + keys.evaluate(&[0xffu8; 32]).unwrap_err(), + PrfError::BadElement + ); + // The identity element must be rejected (RFC 9497 requires it). + assert_eq!(keys.evaluate(&[0u8; 32]).unwrap_err(), PrfError::BadElement); + } + + #[test] + fn evaluate_roundtrip_matches_unblinded_and_verifies_dleq() { + let keys = test_keys(); + let input = dedup_input("gh:987654321", "oauth-account"); + let blind_result = + VoprfClient::::blind(&input, &mut rand_core::OsRng).unwrap(); + let out = keys + .evaluate(&blind_result.message.serialize()) + .expect("valid blinded element evaluates"); + assert_eq!(out.evaluation_element.len(), ELEMENT_LEN); + assert_eq!(out.proof.len(), PROOF_LEN); + let eval_elt = + voprf::EvaluationElement::::deserialize(&out.evaluation_element).unwrap(); + let proof = voprf::Proof::::deserialize(&out.proof).unwrap(); + let pk = voprf::VoprfServer::::new_from_seed( + // reconstruct pk from the same seed path to prove pin stability + &{ + let hk = Hkdf::::new(None, TEST_MASTER_SEED); + let mut s = [0u8; 32]; + hk.expand(INFO_NULLIFIER_SEED, &mut s).unwrap(); + s + }, + INFO_DEDUP_KEYPAIR, + ) + .unwrap() + .get_public_key(); + let output = blind_result + .state + .finalize(&input, &eval_elt, &proof, pk) + .expect("client-side finalize incl. DLEQ verification"); + assert_eq!(output.to_vec(), keys.evaluate_unblinded(&input).unwrap()); + } +} From 2f28a75028d18b5dfa6933dd9a9891160606624c Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:33:45 -0400 Subject: [PATCH 07/20] feat(dedup): service-key init/import lifecycle and fail-closed boot policy src/dedup.rs owns the never-rotate service-key lifecycle: - init_service_keys: one-shot mint of the 32-byte master seed from OS randomness, KEK-sealed into service_keys (AAD-bound to the purpose string), returning ONLY the derived pkS for pinning. Refuses if keys already exist. - prepare_prf_boot: the fail-closed boot matrix. Ordinary boot NEVER generates: PRF clients configured + seed absent -> refuse (a replica racing its restore must hard-fail, never mint); seed present + empty SIGNET_PRF_CLIENT_IDS -> refuse; seed present + configured -> derived pkS must equal SIGNET_DEDUP_PUBKEY_PIN or refuse (key-fork guard). - Pairwise import: one-shot seal of the consumed SIGNET_IMPORT_PAIRWISE_HMAC bytes; refused if a sealed copy exists; ordinary boots load the sealed copy. Tests cover the full boot matrix (disabled / seed-absent refusal / empty-list refusal / missing-pin refusal / pin-mismatch refusal / enabled), one-shot init, and the import-once-then-load round trip with byte-identical pairwise output. --- src/dedup.rs | 310 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 311 insertions(+) create mode 100644 src/dedup.rs diff --git a/src/dedup.rs b/src/dedup.rs new file mode 100644 index 0000000..adfdb67 --- /dev/null +++ b/src/dedup.rs @@ -0,0 +1,310 @@ +//! Service-key lifecycle and the fail-closed PRF boot policy. +//! +//! The nullifier keys are NEVER-ROTATE: anchors are discarded after +//! nullification, so there is no re-derivation path and a silent key fork +//! (e.g. generate-if-absent racing a replica restore) would be an +//! unrecoverable split of the dedup namespace. Every rule here exists to make +//! that structurally impossible: +//! +//! - **Explicit one-shot init.** The master seed is minted ONLY by +//! `signet init-service-keys` (or `SIGNET_INIT_SERVICE_KEYS=1`), which +//! seals it into `service_keys`, prints the derived public key `pkS` (and +//! ONLY `pkS` — never seed bytes) for pinning, and exits. +//! - **Ordinary boot never generates.** PRF surface configured + seed absent +//! → refuse to start, on every node (a replica that boots before its +//! keystore restore completes must hard-fail, never mint a fresh seed). +//! - **Public-key pin.** Seed present → the derived `pkS` MUST equal +//! `SIGNET_DEDUP_PUBKEY_PIN`, else refuse to start. Minister pins the same +//! value, so a forked key can never serve `/prf/evaluate` from either side. +//! - **Fail-closed allow-list.** Keys initialized + empty +//! `SIGNET_PRF_CLIENT_IDS` → refuse startup (the admin-list posture, not +//! the open-client-list one). Keys absent + no PRF config → the PRF routes +//! are simply not mounted and the existing /sign deployment is unchanged. +//! - **One-shot pairwise import.** `SIGNET_IMPORT_PAIRWISE_HMAC` is consumed +//! at config load (zeroize + remove_var, the SIGNET_KEK pattern) and sealed +//! here on first boot; a second import while a sealed copy exists refuses +//! startup rather than silently overwriting. + +use crate::db::Db; +use crate::keystore::Kek; +use crate::prf::{PrfKeys, MASTER_SEED_LEN, MASTER_SEED_PURPOSE, PAIRWISE_HMAC_PURPOSE}; +use rand::TryRngCore; +use zeroize::Zeroizing; + +/// AAD key-id used when sealing service keys. There is exactly one row per +/// purpose; the purpose string is the AAD group identity, so a sealed blob +/// cannot be replayed under a different purpose. +const SERVICE_KEY_ID: i64 = 0; + +/// One-shot service-key initialization. Mints a fresh 32-byte master seed +/// from OS randomness, seals it into `service_keys`, and returns the derived +/// public key `pkS` in the pin encoding (base64url, no padding). Refuses if +/// service keys are already initialized. NEVER returns or logs seed bytes. +pub fn init_service_keys(db: &Db, kek: &Kek) -> Result { + if db.get_service_key(MASTER_SEED_PURPOSE)?.is_some() { + return Err( + "service keys are already initialized; refusing to overwrite (the nullifier \ + master seed is never-rotate)" + .to_string(), + ); + } + let mut seed = Zeroizing::new([0u8; MASTER_SEED_LEN]); + rand::rngs::OsRng + .try_fill_bytes(seed.as_mut()) + .map_err(|e| format!("OS RNG failure: {e}"))?; + // Derive first: if the seed were somehow unusable, nothing is persisted. + let keys = PrfKeys::from_seed(*seed, None)?; + let pk = keys.public_key_b64(); + let sealed = kek.seal(MASTER_SEED_PURPOSE, SERVICE_KEY_ID, seed.as_ref())?; + if !db.insert_service_key(MASTER_SEED_PURPOSE, &sealed)? { + return Err("service keys were initialized concurrently; refusing".to_string()); + } + Ok(pk) +} + +/// Inputs to the boot policy, extracted from [`crate::config::Config`]. +pub struct PrfBootArgs<'a> { + /// Whether `SIGNET_PRF_CLIENT_IDS` is non-empty (the PRF surface is + /// configured to be enabled). + pub prf_clients_configured: bool, + /// The pinned `pkS` (`SIGNET_DEDUP_PUBKEY_PIN`), required when enabled. + pub dedup_pubkey_pin: Option<&'a str>, + /// The consumed `SIGNET_IMPORT_PAIRWISE_HMAC` bytes, if set on this boot. + pub import_pairwise: Option>>, +} + +/// Outcome of the boot policy. +pub enum PrfBoot { + /// PRF surface not configured and no keys present: routes are not + /// mounted; the existing /sign deployment behavior is unchanged. + Disabled, + /// PRF surface enabled: keys loaded, pin verified. + Enabled(Box), +} + +/// Evaluate the fail-closed boot matrix and load/import the service keys. +/// Any `Err` from this function must abort startup. +pub fn prepare_prf_boot(db: &Db, kek: &Kek, args: PrfBootArgs<'_>) -> Result { + let sealed_seed = db.get_service_key(MASTER_SEED_PURPOSE)?; + + match (sealed_seed, args.prf_clients_configured) { + (None, false) => { + if args.import_pairwise.is_some() { + return Err( + "SIGNET_IMPORT_PAIRWISE_HMAC is set but the PRF surface is not enabled \ + (no service keys, no SIGNET_PRF_CLIENT_IDS); refusing to start rather \ + than sealing a secret into a surface that is not configured" + .to_string(), + ); + } + Ok(PrfBoot::Disabled) + } + (Some(_), false) => Err( + "service keys are initialized but SIGNET_PRF_CLIENT_IDS is empty; refusing to \ + start (fail-closed: an initialized PRF keystore with no allow-list would \ + otherwise be one config slip away from an open HMAC oracle)" + .to_string(), + ), + (None, true) => Err( + "SIGNET_PRF_CLIENT_IDS is configured but the service keys are not initialized; \ + refusing to start. Run `signet init-service-keys` exactly once on the primary. \ + A replica must wait for its keystore restore to complete — NEVER initialize a \ + fresh seed on a node that should be serving an existing one (key-fork guard)" + .to_string(), + ), + (Some(sealed), true) => { + let pin = args.dedup_pubkey_pin.map(str::trim).ok_or( + "SIGNET_DEDUP_PUBKEY_PIN is required when the PRF surface is enabled; pin \ + the public key printed by `signet init-service-keys`", + )?; + let seed_bytes = + Zeroizing::new(kek.open(MASTER_SEED_PURPOSE, SERVICE_KEY_ID, &sealed)?); + if seed_bytes.len() != MASTER_SEED_LEN { + return Err(format!( + "sealed master seed has unexpected length {} (expected {MASTER_SEED_LEN})", + seed_bytes.len() + )); + } + let mut seed = Zeroizing::new([0u8; MASTER_SEED_LEN]); + seed.copy_from_slice(&seed_bytes); + + // Pairwise secret: import once, or load the sealed copy. + let pairwise = match args.import_pairwise { + Some(secret) => { + if db.get_service_key(PAIRWISE_HMAC_PURPOSE)?.is_some() { + return Err( + "SIGNET_IMPORT_PAIRWISE_HMAC is set but a sealed pairwise secret \ + already exists; refusing to start (unset the env var — the \ + sealed copy is authoritative and is never silently overwritten)" + .to_string(), + ); + } + let sealed_pw = kek.seal(PAIRWISE_HMAC_PURPOSE, SERVICE_KEY_ID, &secret)?; + if !db.insert_service_key(PAIRWISE_HMAC_PURPOSE, &sealed_pw)? { + return Err("pairwise secret import raced a concurrent insert; refusing" + .to_string()); + } + tracing::info!( + "imported the pairwise HMAC secret into service_keys (env consumed)" + ); + Some(secret) + } + None => match db.get_service_key(PAIRWISE_HMAC_PURPOSE)? { + Some(sealed_pw) => Some(Zeroizing::new(kek.open( + PAIRWISE_HMAC_PURPOSE, + SERVICE_KEY_ID, + &sealed_pw, + )?)), + None => None, + }, + }; + + let keys = PrfKeys::from_seed(*seed, pairwise)?; + let derived = keys.public_key_b64(); + if derived != pin { + // Both values are public keys — safe to surface for ops. + return Err(format!( + "derived VOPRF public key {derived} does not match SIGNET_DEDUP_PUBKEY_PIN \ + {pin}; refusing to start (key-fork guard: this node's sealed seed is not \ + the pinned one)" + )); + } + Ok(PrfBoot::Enabled(Box::new(keys))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_kek() -> Kek { + Kek::from_encoded(&hex::encode([0x77u8; 32])).unwrap() + } + + /// Extract the error from a boot attempt without requiring Debug on + /// PrfBoot (which holds key material and deliberately has no Debug impl). + fn boot_err(db: &Db, kek: &Kek, a: PrfBootArgs<'_>) -> String { + match prepare_prf_boot(db, kek, a) { + Err(e) => e, + Ok(_) => panic!("expected the boot policy to refuse"), + } + } + + fn args<'a>(configured: bool, pin: Option<&'a str>, import: Option<&[u8]>) -> PrfBootArgs<'a> { + PrfBootArgs { + prf_clients_configured: configured, + dedup_pubkey_pin: pin, + import_pairwise: import.map(|b| Zeroizing::new(b.to_vec())), + } + } + + #[test] + fn init_is_one_shot_and_prints_only_pk() { + let db = Db::open_in_memory().unwrap(); + let kek = test_kek(); + let pk = init_service_keys(&db, &kek).unwrap(); + // The pin encoding: base64url no padding, 32-byte element -> 43 chars. + assert_eq!(pk.len(), 43); + assert!(pk + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')); + // A second init must refuse (never-rotate, never overwrite). + assert!(init_service_keys(&db, &kek).is_err()); + // The sealed row exists and opens back to a 32-byte seed under the KEK. + let sealed = db.get_service_key(MASTER_SEED_PURPOSE).unwrap().unwrap(); + let seed = kek.open(MASTER_SEED_PURPOSE, 0, &sealed).unwrap(); + assert_eq!(seed.len(), MASTER_SEED_LEN); + // And the returned pk is exactly the one derived from that seed. + let mut arr = [0u8; MASTER_SEED_LEN]; + arr.copy_from_slice(&seed); + assert_eq!(PrfKeys::from_seed(arr, None).unwrap().public_key_b64(), pk); + } + + #[test] + fn boot_disabled_when_nothing_configured() { + let db = Db::open_in_memory().unwrap(); + assert!(matches!( + prepare_prf_boot(&db, &test_kek(), args(false, None, None)).unwrap(), + PrfBoot::Disabled + )); + } + + #[test] + fn boot_refuses_seed_absent_with_prf_clients_configured() { + let db = Db::open_in_memory().unwrap(); + let err = boot_err(&db, &test_kek(), args(true, Some("pin"), None)); + assert!(err.contains("not initialized"), "{err}"); + } + + #[test] + fn boot_refuses_initialized_keys_with_empty_prf_list() { + let db = Db::open_in_memory().unwrap(); + let kek = test_kek(); + init_service_keys(&db, &kek).unwrap(); + let err = boot_err(&db, &kek, args(false, None, None)); + assert!(err.contains("SIGNET_PRF_CLIENT_IDS is empty"), "{err}"); + } + + #[test] + fn boot_refuses_missing_or_mismatched_pin() { + let db = Db::open_in_memory().unwrap(); + let kek = test_kek(); + let pk = init_service_keys(&db, &kek).unwrap(); + // Missing pin. + let err = boot_err(&db, &kek, args(true, None, None)); + assert!(err.contains("SIGNET_DEDUP_PUBKEY_PIN is required"), "{err}"); + // Mismatched pin (a forked/wrong seed scenario). + let err = boot_err( + &db, + &kek, + args( + true, + Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + None, + ), + ); + assert!(err.contains("does not match"), "{err}"); + // Correct pin boots (whitespace around the pin is tolerated). + let padded = format!(" {pk}\n"); + assert!(matches!( + prepare_prf_boot(&db, &kek, args(true, Some(&padded), None)).unwrap(), + PrfBoot::Enabled(_) + )); + } + + #[test] + fn pairwise_import_is_one_shot_and_persists() { + let db = Db::open_in_memory().unwrap(); + let kek = test_kek(); + let pk = init_service_keys(&db, &kek).unwrap(); + let secret = b"live-pairwise-secret-bytes"; + + // First boot with the import env: sealed + usable. + let keys = match prepare_prf_boot(&db, &kek, args(true, Some(&pk), Some(secret))).unwrap() { + PrfBoot::Enabled(keys) => keys, + PrfBoot::Disabled => panic!("must be enabled"), + }; + assert!(keys.has_pairwise()); + let out_first = keys.pairwise(b"probe").unwrap(); + + // Second boot with the import STILL set: refuse (no silent overwrite). + let err = boot_err(&db, &kek, args(true, Some(&pk), Some(secret))); + assert!(err.contains("already exists"), "{err}"); + + // Ordinary boot without the env: loads the sealed copy, byte-identical. + let keys = match prepare_prf_boot(&db, &kek, args(true, Some(&pk), None)).unwrap() { + PrfBoot::Enabled(keys) => keys, + PrfBoot::Disabled => panic!("must be enabled"), + }; + assert!(keys.has_pairwise()); + assert_eq!(keys.pairwise(b"probe").unwrap(), out_first); + } + + #[test] + fn boot_refuses_import_when_surface_disabled() { + let db = Db::open_in_memory().unwrap(); + let err = boot_err(&db, &test_kek(), args(false, None, Some(b"secret"))); + assert!(err.contains("not enabled"), "{err}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9f7ae01..8fe5293 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod config; pub mod crypto; pub mod db; +pub mod dedup; pub mod error; pub mod handlers; pub mod identity; From b5ed91411d51ddafbc615393066c78cdcf91f178 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:12:19 -0400 Subject: [PATCH 08/20] feat(prf): mount the PRF/dedup HTTP surface with fail-closed boot and per-route authz Wire the Phase 2 surface end to end: /prf/{pairwise,evaluate,public-key, disclose} and /dedup/{register,release,reassign} routes (mounted only when the boot policy enables them), the dedicated SIGNET_PRF_CLIENT_IDS identity role and per-route gate, config/env plumbing (pin, one-shot pairwise import, PRF rate-limit bucket), and the error variants for the dedup ledger. Test suites: golden-vector + DLEQ round-trip + ledger flow + concurrency + malformed-input (tests/prf.rs), the authorization matrix (tests/prf_authz.rs), no-log assertions (tests/prf_logging.rs), and service-key at-rest coverage. --- src/config.rs | 95 +++++++-- src/dedup.rs | 21 +- src/error.rs | 16 +- src/handlers.rs | 473 ++++++++++++++++++++++++++++++++++++++++++- src/identity.rs | 134 ++++++++++-- src/lib.rs | 21 +- src/main.rs | 107 ++++++++-- src/state.rs | 17 ++ tests/at_rest.rs | 66 ++++++ tests/common/mod.rs | 91 ++++++++- tests/prf.rs | 466 ++++++++++++++++++++++++++++++++++++++++++ tests/prf_authz.rs | 258 +++++++++++++++++++++++ tests/prf_logging.rs | 154 ++++++++++++++ 13 files changed, 1861 insertions(+), 58 deletions(-) create mode 100644 tests/prf.rs create mode 100644 tests/prf_authz.rs create mode 100644 tests/prf_logging.rs diff --git a/src/config.rs b/src/config.rs index ad1664e..dd1a138 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,12 +3,13 @@ //! The KEK (key-encryption key) is the single most sensitive input. It is read //! from `SIGNET_KEK` (32 raw bytes, hex- or base64-encoded) and is NEVER //! persisted, logged, or returned by any endpoint. It exists only in process -//! memory. +//! memory. `SIGNET_IMPORT_PAIRWISE_HMAC` (the one-shot pairwise-secret import) +//! follows the same consume-zeroize-remove pattern. use crate::keystore::Kek; use std::net::SocketAddr; use std::path::PathBuf; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; #[derive(Clone)] pub struct Config { @@ -47,6 +48,27 @@ pub struct Config { pub rl_key_identity_max: u32, /// Global rate limit for `/key*` endpoints, per window. Audit H1. pub rl_key_global_max: u32, + /// Allow-list of client identities permitted to call the `/prf/*` and + /// `/dedup/*` endpoints (SIGNET_PRF_CLIENT_IDS). Separate from — and + /// NEVER granted by — `allowed_client_ids` or its open back-compat mode. + /// Empty = PRF surface not configured (routes not mounted); an empty list + /// with initialized service keys refuses startup (fail-closed). + pub prf_client_ids: std::collections::BTreeSet, + /// The pinned VOPRF public key `pkS` (base64url, no padding), as printed + /// by `signet init-service-keys`. Required whenever the PRF surface is + /// enabled; a mismatch with the derived key refuses startup. + pub dedup_pubkey_pin: Option, + /// One-shot pairwise-secret import (SIGNET_IMPORT_PAIRWISE_HMAC): the + /// EXACT UTF-8 bytes of the live secret, consumed from the environment at + /// load (removed + zeroized) and sealed into `service_keys` at boot. + /// NOT trimmed: byte-stability with Minister's Node derivation requires + /// the bytes verbatim. + pub import_pairwise_hmac: Option>>, + /// Per-identity rate limit for the `/prf/*` + `/dedup/*` endpoints, per + /// window (its own bucket, separate from /sign and /key*). + pub rl_prf_identity_max: u32, + /// Global rate limit for the `/prf/*` + `/dedup/*` endpoints, per window. + pub rl_prf_global_max: u32, } /// Parse a comma-separated env var into a set of trimmed, non-empty identities. @@ -75,31 +97,57 @@ fn env_or(key: &str, default: T) -> Result { } } +/// Consume `SIGNET_KEK` from the environment: parse it, zeroize the raw copy, +/// and remove the variable so it is not readable via /proc//environ, +/// inherited by a child process, or surfaced by a crash dump walking the +/// environment block. The returned in-memory [`Kek`] is the only remaining +/// copy and is itself zeroized on drop. +/// +/// SAFETY: `remove_var` is sound here because this is called from `main` +/// BEFORE the tokio runtime is built (audit L1) — for the serve path via +/// [`Config::from_env`] and for the `init-service-keys` one-shot directly — +/// so the process is still single-threaded and there is no concurrent env +/// access. Callers must preserve that ordering. +pub fn consume_kek_env() -> Result { + let mut kek_raw = env_required("SIGNET_KEK")?; + let kek_result = Kek::from_encoded(&kek_raw); + // Wipe the encoded KEK from our heap copy as soon as it is parsed, + // regardless of whether parsing succeeded. + kek_raw.zeroize(); + std::env::remove_var("SIGNET_KEK"); + kek_result.map_err(|e| format!("SIGNET_KEK is invalid: {e}")) +} + +/// Consume `SIGNET_IMPORT_PAIRWISE_HMAC` (if set): take the EXACT UTF-8 bytes +/// (no trimming — byte-stability with Minister's live derivation), remove the +/// variable from the environment, and return the bytes in a zeroizing buffer. +/// Same single-threaded-before-runtime requirement as [`consume_kek_env`]. +fn consume_pairwise_import_env() -> Option>> { + match std::env::var("SIGNET_IMPORT_PAIRWISE_HMAC") { + Ok(raw) => { + std::env::remove_var("SIGNET_IMPORT_PAIRWISE_HMAC"); + Some(Zeroizing::new(raw.into_bytes())) + } + Err(_) => None, + } +} + +/// The SQLite database path (`SIGNET_DB`, default `signet.db`). Shared by the +/// serve path and the `init-service-keys` one-shot. +pub fn db_path_from_env() -> Result { + Ok(PathBuf::from(env_or("SIGNET_DB", "signet.db".to_string())?)) +} + impl Config { pub fn from_env() -> Result { let bind: SocketAddr = env_or("SIGNET_BIND", "0.0.0.0:8443".parse().unwrap())?; - let db_path = PathBuf::from(env_or("SIGNET_DB", "signet.db".to_string())?); + let db_path = db_path_from_env()?; let tls_cert = PathBuf::from(env_required("SIGNET_TLS_CERT")?); let tls_key = PathBuf::from(env_required("SIGNET_TLS_KEY")?); let client_ca = PathBuf::from(env_required("SIGNET_CLIENT_CA")?); - let mut kek_raw = env_required("SIGNET_KEK")?; - let kek_result = Kek::from_encoded(&kek_raw); - // Wipe the encoded KEK from our heap copy as soon as it is parsed, - // regardless of whether parsing succeeded, so the raw key material does - // not linger in process memory. - kek_raw.zeroize(); - // Remove the KEK from the process environment so it is not readable via - // /proc//environ, inherited by any child process, or surfaced by a - // crash dump that walks the environment block. The in-memory `Kek` is - // the only remaining copy and is itself zeroized on drop. - // - // SAFETY: `remove_var` is sound here because `Config::from_env` is - // called from `main` BEFORE the tokio runtime is built (audit L1), so - // the process is still single-threaded and there is no concurrent env - // access. Callers must preserve that ordering. - std::env::remove_var("SIGNET_KEK"); - let kek = kek_result.map_err(|e| format!("SIGNET_KEK is invalid: {e}"))?; + let kek = consume_kek_env()?; + let import_pairwise_hmac = consume_pairwise_import_env(); let key_bits: usize = env_or("SIGNET_KEY_BITS", 2048usize)?; if !(2048..=4096).contains(&key_bits) || !key_bits.is_multiple_of(16) { @@ -130,6 +178,13 @@ impl Config { keygen_max_concurrent, rl_key_identity_max: env_or("SIGNET_RL_KEY_IDENTITY_MAX", 10u32)?, rl_key_global_max: env_or("SIGNET_RL_KEY_GLOBAL_MAX", 100u32)?, + prf_client_ids: env_id_set("SIGNET_PRF_CLIENT_IDS"), + dedup_pubkey_pin: std::env::var("SIGNET_DEDUP_PUBKEY_PIN").ok(), + import_pairwise_hmac, + // The pairwise oracle sits on the token-mint hot path; defaults + // are generous but finite. + rl_prf_identity_max: env_or("SIGNET_RL_PRF_IDENTITY_MAX", 1000u32)?, + rl_prf_global_max: env_or("SIGNET_RL_PRF_GLOBAL_MAX", 5000u32)?, }) } } diff --git a/src/dedup.rs b/src/dedup.rs index adfdb67..eff0a4f 100644 --- a/src/dedup.rs +++ b/src/dedup.rs @@ -52,10 +52,27 @@ pub fn init_service_keys(db: &Db, kek: &Kek) -> Result { rand::rngs::OsRng .try_fill_bytes(seed.as_mut()) .map_err(|e| format!("OS RNG failure: {e}"))?; - // Derive first: if the seed were somehow unusable, nothing is persisted. + seal_master_seed(db, kek, &seed) +} + +/// Seal a PROVIDED master seed into `service_keys`, returning the derived +/// `pkS` in the pin encoding. Refuses if service keys already exist. +/// +/// This is the deliberate-injection path for the integration test harness +/// (which needs a FIXED seed to assert frozen vectors over the real HTTP +/// surface). Production initialization always mints fresh OS randomness via +/// [`init_service_keys`]; nothing routes user input here. +pub fn seal_master_seed( + db: &Db, + kek: &Kek, + seed: &[u8; MASTER_SEED_LEN], +) -> Result { + if db.get_service_key(MASTER_SEED_PURPOSE)?.is_some() { + return Err("service keys are already initialized; refusing to overwrite".to_string()); + } let keys = PrfKeys::from_seed(*seed, None)?; let pk = keys.public_key_b64(); - let sealed = kek.seal(MASTER_SEED_PURPOSE, SERVICE_KEY_ID, seed.as_ref())?; + let sealed = kek.seal(MASTER_SEED_PURPOSE, SERVICE_KEY_ID, seed)?; if !db.insert_service_key(MASTER_SEED_PURPOSE, &sealed)? { return Err("service keys were initialized concurrently; refusing".to_string()); } diff --git a/src/error.rs b/src/error.rs index 8e540bc..1793270 100644 --- a/src/error.rs +++ b/src/error.rs @@ -24,8 +24,16 @@ pub enum AppError { /// Maps to HTTP 202 Accepted with `{ "status": "pending" }`. KeyPending, /// The caller's pinned client identity is not authorized for this endpoint - /// (e.g. a non-admin calling `/key/rotate`). Maps to HTTP 403. + /// (e.g. a non-admin calling `/key/rotate`, a non-PRF identity calling + /// `/prf/*`, or an owner-handle mismatch on the dedup ledger). Maps to + /// HTTP 403. Forbidden(&'static str), + /// A referenced resource (e.g. a dedup entry ref) does not exist. Maps to + /// HTTP 404 with the `not_found` code. + NotFound(&'static str), + /// The dedup value is already registered to a DIFFERENT owner. Maps to + /// HTTP 409 with the `taken` code (one-credential-one-account). + DedupTaken, /// Internal failure (DB, crypto, encoding). Never includes detail in body. Internal(String), } @@ -39,6 +47,8 @@ impl std::fmt::Display for AppError { AppError::NoSuchKey => write!(f, "no such key"), AppError::KeyPending => write!(f, "key pending"), AppError::Forbidden(m) => write!(f, "forbidden: {m}"), + AppError::NotFound(m) => write!(f, "not found: {m}"), + AppError::DedupTaken => write!(f, "taken"), AppError::Internal(m) => write!(f, "internal error: {m}"), } } @@ -55,6 +65,8 @@ impl IntoResponse for AppError { AppError::NoSuchKey => (StatusCode::NOT_FOUND, "no_such_key"), AppError::KeyPending => (StatusCode::ACCEPTED, "pending"), AppError::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"), + AppError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"), + AppError::DedupTaken => (StatusCode::CONFLICT, "taken"), AppError::Internal(detail) => { // Log detail; never return it to the caller. tracing::error!(error = %detail, "internal error"); @@ -70,6 +82,8 @@ impl IntoResponse for AppError { AppError::NoSuchKey => "no key for this group", AppError::KeyPending => "key is being generated; retry shortly", AppError::Forbidden(m) => *m, + AppError::NotFound(m) => *m, + AppError::DedupTaken => "this credential is already registered to another owner", AppError::Internal(_) => "internal error", }; // The pending status uses `status` rather than `error` so clients can diff --git a/src/handlers.rs b/src/handlers.rs index b23d6c4..5f4d076 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -7,9 +7,21 @@ //! POST /key/rotate?group_id=… -> ADMIN ONLY: rotate to a fresh key //! GET /healthz -> liveness //! +//! PRF surface (mounted ONLY when the fail-closed boot policy enabled it; +//! every route additionally requires the caller on SIGNET_PRF_CLIENT_IDS): +//! POST /prf/pairwise { input } -> { output } keyed HMAC oracle +//! POST /prf/evaluate { blinded_element } -> { evaluation_element, proof } +//! GET /prf/public-key -> { suite, public_key } the pinned pkS +//! POST /prf/disclose { entry_ref, owner_handle, client_id } -> { nullifier } +//! POST /dedup/register { value, owner_handle, badge_type } -> { status, entry_ref } +//! POST /dedup/release { entry_ref, owner_handle } -> { status } +//! POST /dedup/reassign { entry_refs, from_owner_handle, to_owner_handle } -> { status, reassigned } +//! //! ANONYMITY: `/sign` treats `blinded_message` as opaque bytes, signs it, and //! returns the blind signature. It never logs the blinded message or the //! signature. The audit log records only (group_id, participant_id, version_id). +//! The PRF surface goes further: its logs record ONLY the pinned identity and +//! the endpoint — never inputs, outputs, values, handles, or refs. //! //! ASYNC KEYGEN (audit H1): safe-prime keygen is multi-second, so key creation //! never blocks a request thread. `POST /key` and the auto-create path of @@ -19,18 +31,24 @@ //! //! IDENTITY (audit M1/M3): every request carries a pinned [`ClientIdentity`] //! (see `identity.rs`). `/key/rotate` requires the `Admin` role; the `/key*` -//! endpoints are rate-limited per identity and globally. +//! endpoints are rate-limited per identity and globally. The PRF/dedup routes +//! are gated per-route on `may_prf()` (the dedicated allow-list — mirroring +//! the `is_admin()` gate, never the open client-list convention) with their +//! own rate-limit bucket; conversely the blind-RSA surface refuses PRF-only +//! identities via `may_sign()`, so admitting Minister for PRF never widens +//! /sign. -use crate::db::{self, Reservation}; +use crate::db::{self, DedupReassign, DedupRegister, DedupRelease, Reservation}; use crate::error::{AppError, AppResult}; use crate::identity::ClientIdentity; use crate::keygen::KeygenStatus; +use crate::prf::{self, PrfError}; use crate::ratelimit::{Decision, KeyDecision}; -use crate::state::AppState; +use crate::state::{AppState, PrfState}; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::Json; -use base64::engine::general_purpose::STANDARD as B64; +use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE_NO_PAD as B64URL}; use base64::Engine; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -125,6 +143,24 @@ fn check_key_rate_limit(state: &AppState, identity: &ClientIdentity) -> AppResul } } +/// Refuse PRF-only identities on the blind-RSA surface. Identities admitted +/// via the client/admin lists (or the open back-compat list) are unaffected — +/// a PRF-only identity could not even connect before the PRF list existed, so +/// this is a pure fail-closed narrowing, not a behavior change for deployed +/// clients. +fn require_sign_surface(identity: &ClientIdentity) -> AppResult<()> { + if !identity.may_sign() { + tracing::warn!( + identity = %identity.name, + "rejected blind-RSA surface request: PRF-only identity" + ); + return Err(AppError::Forbidden( + "this identity is authorized only for the PRF surface", + )); + } + Ok(()) +} + pub async fn healthz() -> &'static str { "ok" } @@ -134,9 +170,10 @@ pub async fn healthz() -> &'static str { /// if it is still not ready, returns 202 pending instead of blocking a thread. pub async fn sign( State(state): State>, - _identity: ClientIdentity, + identity: ClientIdentity, Json(req): Json, ) -> AppResult> { + require_sign_surface(&identity)?; validate_id(&req.group_id, "group_id")?; validate_id(&req.participant_id, "participant_id")?; validate_id(&req.version_id, "version_id")?; @@ -264,6 +301,7 @@ pub async fn get_key( identity: ClientIdentity, Query(q): Query, ) -> AppResult<(StatusCode, Json)> { + require_sign_surface(&identity)?; validate_id(&q.group_id, "group_id")?; check_key_rate_limit(&state, &identity)?; @@ -311,6 +349,7 @@ pub async fn create_key( identity: ClientIdentity, Query(q): Query, ) -> AppResult<(StatusCode, Json)> { + require_sign_surface(&identity)?; validate_id(&q.group_id, "group_id")?; check_key_rate_limit(&state, &identity)?; @@ -348,6 +387,7 @@ pub async fn rotate_key( identity: ClientIdentity, Query(q): Query, ) -> AppResult> { + require_sign_surface(&identity)?; validate_id(&q.group_id, "group_id")?; if !identity.is_admin() { tracing::warn!( @@ -379,3 +419,426 @@ pub async fn rotate_key( ); Ok(Json(KeyResponse::ready(q.group_id, &spki, key_id))) } + +// --------------------------------------------------------------------------- +// PRF surface: /prf/* + /dedup/* +// --------------------------------------------------------------------------- + +/// Cap on the opaque `/prf/pairwise` input, in BYTES of the UTF-8 string. +const MAX_PAIRWISE_INPUT: usize = 512; +/// Cap on `client_id` (mirrors the Minister-side clientId cap). +const MAX_CLIENT_ID_LEN: usize = 256; +/// Cap on `badge_type` (mirrors the shared badge-type registry slugs). +const MAX_BADGE_TYPE_LEN: usize = 64; +/// Cap on owner handles (Minister mints 22-char base64url handles; capped +/// generously but finitely). +const MAX_OWNER_HANDLE_LEN: usize = 128; +/// Length of a dedup entry ref (raw bytes). +const ENTRY_REF_LEN: usize = 16; +/// Cap on the number of refs in one /dedup/reassign batch. +const MAX_REASSIGN_REFS: usize = 256; + +/// Per-route, fail-closed PRF authorization + the PRF rate-limit bucket. +/// +/// Mirrors the `is_admin()` gate on /key/rotate: the check runs INSIDE every +/// /prf/* and /dedup/* handler against the DEDICATED allow-list flag — +/// connection-level classification (including the open back-compat client +/// list) never grants PRF access. Authorization is checked before the rate +/// limit so an unauthorized caller always sees 403 and cannot consume budget. +fn require_prf<'a>(state: &'a AppState, identity: &ClientIdentity) -> AppResult<&'a PrfState> { + let prf = state.prf.as_ref().ok_or_else(|| { + // The PRF routes are only mounted when the state exists; reaching this + // means the router was wired without it — refuse, never fail open. + tracing::error!("PRF handler reached without PRF state"); + AppError::Internal("PRF surface unavailable".into()) + })?; + if !identity.may_prf() { + tracing::warn!( + identity = %identity.name, + "rejected PRF request: identity not on SIGNET_PRF_CLIENT_IDS" + ); + return Err(AppError::Forbidden( + "client identity is not authorized for the PRF surface", + )); + } + match prf.rate_limiter.check(&identity.name) { + KeyDecision::Allow => Ok(prf), + KeyDecision::DenyIdentity | KeyDecision::DenyGlobal => Err(AppError::RateLimited), + } +} + +/// Decode a base64url-no-pad field, strictly. Failure is always a 400. +fn b64url_decode(value: &str, err: &'static str) -> AppResult> { + B64URL + .decode(value.as_bytes()) + .map_err(|_| AppError::BadRequest(err)) +} + +/// Validate a text field: non-empty and within its byte cap. +fn validate_text(value: &str, max: usize, err: &'static str) -> AppResult<()> { + if value.is_empty() || value.len() > max { + return Err(AppError::BadRequest(err)); + } + Ok(()) +} + +/// Decode + validate an entry ref (base64url of exactly 16 bytes). +fn decode_entry_ref(value: &str) -> AppResult> { + // 16 bytes -> 22 base64url chars; reject anything longer before decoding. + if value.is_empty() || value.len() > 24 { + return Err(AppError::BadRequest("entry_ref length out of range")); + } + let raw = b64url_decode(value, "entry_ref is not valid base64url")?; + if raw.len() != ENTRY_REF_LEN { + return Err(AppError::BadRequest("entry_ref has the wrong length")); + } + Ok(raw) +} + +#[derive(Deserialize)] +pub struct PairwiseRequest { + /// Opaque input string; HMAC'd verbatim (exact UTF-8 bytes). + pub input: String, +} + +#[derive(Serialize)] +pub struct PairwiseResponse { + /// base64url (no padding) of HMAC-SHA256(pairwise secret, input). + pub output: String, +} + +/// POST /prf/pairwise — the keyed pairwise HMAC oracle. An HMAC oracle BY +/// DESIGN (Minister composes the tagged inputs), which is exactly why the +/// per-route fail-closed gate above exists. NEVER logs input or output. +pub async fn prf_pairwise( + State(state): State>, + identity: ClientIdentity, + Json(req): Json, +) -> AppResult> { + let prf = require_prf(&state, &identity)?; + if req.input.is_empty() || req.input.len() > MAX_PAIRWISE_INPUT { + return Err(AppError::BadRequest("input length out of range")); + } + let output = prf + .keys + .pairwise(req.input.as_bytes()) + .ok_or(AppError::NotFound( + "the pairwise secret has not been imported", + ))?; + tracing::info!(identity = %identity.name, endpoint = "prf/pairwise", "served"); + Ok(Json(PairwiseResponse { output })) +} + +#[derive(Deserialize)] +pub struct EvaluateRequest { + /// base64url (no padding) of a serialized ristretto255 blinded element. + pub blinded_element: String, +} + +#[derive(Serialize)] +pub struct EvaluateResponse { + /// base64url (no padding) of the serialized evaluation element. + pub evaluation_element: String, + /// base64url (no padding) of the serialized DLEQ proof (c || s). + pub proof: String, +} + +/// POST /prf/evaluate — blind VOPRF evaluation with a DLEQ proof. The input +/// is BLINDED: Signet never sees the underlying anchor. NEVER logs the +/// element or the result. +pub async fn prf_evaluate( + State(state): State>, + identity: ClientIdentity, + Json(req): Json, +) -> AppResult> { + let prf = require_prf(&state, &identity)?; + // 32 bytes -> 43 base64url chars; bound before decoding. + if req.blinded_element.is_empty() || req.blinded_element.len() > 64 { + return Err(AppError::BadRequest("blinded_element length out of range")); + } + let raw = b64url_decode( + &req.blinded_element, + "blinded_element is not valid base64url", + )?; + let out = prf.keys.evaluate(&raw).map_err(|e| match e { + PrfError::BadElement => { + AppError::BadRequest("blinded_element is not a valid group element") + } + })?; + tracing::info!(identity = %identity.name, endpoint = "prf/evaluate", "served"); + Ok(Json(EvaluateResponse { + evaluation_element: B64URL.encode(out.evaluation_element), + proof: B64URL.encode(out.proof), + })) +} + +#[derive(Serialize)] +pub struct PublicKeyResponse { + /// The VOPRF ciphersuite identifier. + pub suite: &'static str, + /// base64url (no padding) of the serialized public key pkS — the same + /// encoding as SIGNET_DEDUP_PUBKEY_PIN and the init output. + pub public_key: String, +} + +/// GET /prf/public-key — the pinned VOPRF public key, for client-side DLEQ +/// verification against an independently pinned copy. +pub async fn prf_public_key( + State(state): State>, + identity: ClientIdentity, +) -> AppResult> { + let prf = require_prf(&state, &identity)?; + tracing::info!(identity = %identity.name, endpoint = "prf/public-key", "served"); + Ok(Json(PublicKeyResponse { + suite: prf::SUITE, + public_key: prf.keys.public_key_b64(), + })) +} + +#[derive(Deserialize)] +pub struct DiscloseRequest { + /// base64url (no padding) of the 16-byte entry ref. + pub entry_ref: String, + /// The caller-asserted owner handle; must equal the stored owner_tag. + pub owner_handle: String, + /// The relying party's clientId. + pub client_id: String, +} + +#[derive(Serialize)] +pub struct DiscloseResponse { + /// The per-RP disclosed nullifier, "mnv1:" + base64url(HMAC output). + pub nullifier: String, +} + +/// POST /prf/disclose — derive the per-RP nullifier from a STORED ledger +/// entry. Owner-checked: a mis-bound or swapped ref (a Minister-DB-write +/// attacker moving Badge.nullifierRef between users) fails closed with 403 +/// rather than presenting another user's Sybil nullifier. +pub async fn prf_disclose( + State(state): State>, + identity: ClientIdentity, + Json(req): Json, +) -> AppResult> { + let prf = require_prf(&state, &identity)?; + let entry_ref = decode_entry_ref(&req.entry_ref)?; + validate_text( + &req.owner_handle, + MAX_OWNER_HANDLE_LEN, + "owner_handle length out of range", + )?; + validate_text( + &req.client_id, + MAX_CLIENT_ID_LEN, + "client_id length out of range", + )?; + let entry = state + .db + .dedup_entry_by_ref(&entry_ref) + .map_err(AppError::Internal)? + .ok_or(AppError::NotFound("no such dedup entry"))?; + if entry.owner_tag != req.owner_handle { + tracing::warn!( + identity = %identity.name, + endpoint = "prf/disclose", + "rejected disclose: owner handle does not match the stored owner tag" + ); + return Err(AppError::Forbidden( + "owner_handle does not match the entry owner", + )); + } + let nullifier = prf.keys.disclose(&entry.value, &req.client_id); + tracing::info!(identity = %identity.name, endpoint = "prf/disclose", "served"); + Ok(Json(DiscloseResponse { nullifier })) +} + +#[derive(Deserialize)] +pub struct RegisterRequest { + /// base64url (no padding) of the 64-byte finalized VOPRF output N_dedup. + pub value: String, + /// The opaque per-user owner handle. + pub owner_handle: String, + /// The badge type slug this credential nullifies. + pub badge_type: String, +} + +#[derive(Serialize)] +pub struct RegisterResponse { + /// "registered" | "already_yours". + pub status: &'static str, + /// base64url (no padding) of the entry ref (existing one on already_yours). + pub entry_ref: String, +} + +/// POST /dedup/register — record-first UNIQUE(value) insert. Same value + +/// same owner -> already_yours (re-issue, same ref); different owner -> 409 +/// taken (one-credential-one-account). +pub async fn dedup_register( + State(state): State>, + identity: ClientIdentity, + Json(req): Json, +) -> AppResult> { + let _prf = require_prf(&state, &identity)?; + if req.value.is_empty() || req.value.len() > 96 { + return Err(AppError::BadRequest("value length out of range")); + } + let value = b64url_decode(&req.value, "value is not valid base64url")?; + if value.len() != prf::DEDUP_VALUE_LEN { + return Err(AppError::BadRequest("value has the wrong length")); + } + validate_text( + &req.owner_handle, + MAX_OWNER_HANDLE_LEN, + "owner_handle length out of range", + )?; + validate_text( + &req.badge_type, + MAX_BADGE_TYPE_LEN, + "badge_type length out of range", + )?; + + // Mint the candidate ref outside the DB call; on already_yours the stored + // ref wins and this one is discarded. + let mut entry_ref = [0u8; ENTRY_REF_LEN]; + use rand::TryRngCore; + rand::rngs::OsRng + .try_fill_bytes(&mut entry_ref) + .map_err(|e| AppError::Internal(format!("OS RNG failure: {e}")))?; + + let outcome = state + .db + .register_dedup(&entry_ref, &value, &req.owner_handle, &req.badge_type) + .map_err(AppError::Internal)?; + let (status, entry_ref) = match outcome { + DedupRegister::Registered { entry_ref } => ("registered", entry_ref), + DedupRegister::AlreadyYours { entry_ref } => ("already_yours", entry_ref), + DedupRegister::Taken => { + tracing::info!(identity = %identity.name, endpoint = "dedup/register", "taken"); + return Err(AppError::DedupTaken); + } + }; + tracing::info!(identity = %identity.name, endpoint = "dedup/register", status, "served"); + Ok(Json(RegisterResponse { + status, + entry_ref: B64URL.encode(entry_ref), + })) +} + +#[derive(Deserialize)] +pub struct ReleaseRequest { + pub entry_ref: String, + pub owner_handle: String, +} + +#[derive(Serialize)] +pub struct ReleaseResponse { + /// "released" | "already_released" (absent ref — idempotent retry). + pub status: &'static str, +} + +/// POST /dedup/release — owner-checked delete (badge revocation / account +/// deletion). Idempotent: releasing an already-released ref succeeds. +pub async fn dedup_release( + State(state): State>, + identity: ClientIdentity, + Json(req): Json, +) -> AppResult> { + let _prf = require_prf(&state, &identity)?; + let entry_ref = decode_entry_ref(&req.entry_ref)?; + validate_text( + &req.owner_handle, + MAX_OWNER_HANDLE_LEN, + "owner_handle length out of range", + )?; + let outcome = state + .db + .release_dedup(&entry_ref, &req.owner_handle) + .map_err(AppError::Internal)?; + let status = match outcome { + DedupRelease::Released => "released", + DedupRelease::NotFound => "already_released", + DedupRelease::OwnerMismatch => { + tracing::warn!( + identity = %identity.name, + endpoint = "dedup/release", + "rejected release: owner handle does not match the stored owner tag" + ); + return Err(AppError::Forbidden( + "owner_handle does not match the entry owner", + )); + } + }; + tracing::info!(identity = %identity.name, endpoint = "dedup/release", status, "served"); + Ok(Json(ReleaseResponse { status })) +} + +#[derive(Deserialize)] +pub struct ReassignRequest { + /// EXPLICIT list of entry refs to re-tag (never wholesale by owner). + pub entry_refs: Vec, + pub from_owner_handle: String, + pub to_owner_handle: String, +} + +#[derive(Serialize)] +pub struct ReassignResponse { + pub status: &'static str, + /// Rows whose owner actually changed (already-target refs are no-ops). + pub reassigned: usize, +} + +/// POST /dedup/reassign — per-ref owner re-tag for account merge / reverse +/// merge. All-or-nothing: any ref owned by neither side rolls the whole batch +/// back (403), an unknown ref rolls back with 404. Retry-idempotent. +pub async fn dedup_reassign( + State(state): State>, + identity: ClientIdentity, + Json(req): Json, +) -> AppResult> { + let _prf = require_prf(&state, &identity)?; + if req.entry_refs.is_empty() || req.entry_refs.len() > MAX_REASSIGN_REFS { + return Err(AppError::BadRequest("entry_refs count out of range")); + } + let mut refs = Vec::with_capacity(req.entry_refs.len()); + for r in &req.entry_refs { + refs.push(decode_entry_ref(r)?); + } + validate_text( + &req.from_owner_handle, + MAX_OWNER_HANDLE_LEN, + "from_owner_handle length out of range", + )?; + validate_text( + &req.to_owner_handle, + MAX_OWNER_HANDLE_LEN, + "to_owner_handle length out of range", + )?; + if req.from_owner_handle == req.to_owner_handle { + return Err(AppError::BadRequest("from and to owner handles are equal")); + } + let outcome = state + .db + .reassign_dedup(&refs, &req.from_owner_handle, &req.to_owner_handle) + .map_err(AppError::Internal)?; + let moved = match outcome { + DedupReassign::Reassigned { moved } => moved, + DedupReassign::NotFound => { + return Err(AppError::NotFound("an entry_ref does not exist")); + } + DedupReassign::OwnerMismatch => { + tracing::warn!( + identity = %identity.name, + endpoint = "dedup/reassign", + "rejected reassign: an entry is owned by neither the source nor the target" + ); + return Err(AppError::Forbidden( + "an entry is owned by neither from_owner_handle nor to_owner_handle", + )); + } + }; + tracing::info!(identity = %identity.name, endpoint = "dedup/reassign", "served"); + Ok(Json(ReassignResponse { + status: "reassigned", + reassigned: moved, + })) +} diff --git a/src/identity.rs b/src/identity.rs index d5e32d6..575533a 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -12,14 +12,27 @@ //! - **admin** — additionally may call `/key/rotate`. The allow-list is //! `SIGNET_ADMIN_IDS`; if it is empty, `/key/rotate` is refused for //! everyone (fail-closed: no admin identity configured => no rotation). +//! - **prf** — admitted SOLELY via `SIGNET_PRF_CLIENT_IDS` (not on the +//! client/admin lists). May call only the `/prf/*` and `/dedup/*` +//! endpoints; the blind-RSA surface (`/sign`, `/key*`) refuses it, so +//! admitting Minister for PRF work never widens the /sign surface. +//! +//! PRF authorization is deliberately NOT granted by `classify`'s client rules: +//! the open back-compat client list ("empty = any valid-chain cert") must +//! never reach the PRF surface, which includes a raw HMAC oracle over the +//! pairwise secret. Instead every identity carries a `prf_allowed` flag, +//! matched ONLY against the dedicated `SIGNET_PRF_CLIENT_IDS` set, and every +//! `/prf/*` / `/dedup/*` handler checks it per-route (mirroring the +//! `is_admin()` gate on `/key/rotate`) — fail-closed at both layers. //! //! Enforcement happens in two places: //! 1. **Connection admission** (`IdentityAcceptor`): when an allow-list is -//! configured, a peer whose identity is on neither the client nor the -//! admin list is dropped at the TLS layer, before any HTTP runs. +//! configured, a peer whose identity is on none of the client, admin, or +//! PRF lists is dropped at the TLS layer, before any HTTP runs. //! 2. **Per-route gating** (the [`ClientIdentity`] extractor + role check in //! the handlers): `/key/rotate` requires the `Admin` role even for an -//! otherwise-allowed client. +//! otherwise-allowed client; `/prf/*` and `/dedup/*` require +//! `prf_allowed`; `/sign` and `/key*` refuse `Prf`-role identities. //! //! How the cert reaches a handler: axum-server's standard serve path consumes //! the rustls connection into the hyper IO and never surfaces the peer @@ -50,17 +63,25 @@ pub enum Role { Client, /// Allowed to do everything a client can, plus rotate keys. Admin, + /// Admitted SOLELY via the PRF allow-list: may call only the `/prf/*` and + /// `/dedup/*` endpoints. The blind-RSA surface refuses this role, so a + /// PRF-only identity (Minister) never gains /sign access as a side + /// effect of being admitted. + Prf, } /// A verified peer identity, derived from the mTLS leaf certificate. /// /// `name` is the identity that matched the allow-list (a CN or a DNS SAN), used /// for audit logging and as the per-identity rate-limit key. `role` is the -/// authorization tier the identity was classified into. +/// authorization tier the identity was classified into. `prf_allowed` is +/// matched ONLY against `SIGNET_PRF_CLIENT_IDS` — never implied by the client +/// list or its open back-compat mode. #[derive(Debug, Clone)] pub struct ClientIdentity { pub name: String, pub role: Role, + pub prf_allowed: bool, } impl ClientIdentity { @@ -68,6 +89,16 @@ impl ClientIdentity { pub fn is_admin(&self) -> bool { self.role == Role::Admin } + + /// True if this identity may call the blind-RSA surface (/sign, /key*). + pub fn may_sign(&self) -> bool { + matches!(self.role, Role::Client | Role::Admin) + } + + /// True if this identity may call the PRF surface (/prf/*, /dedup/*). + pub fn may_prf(&self) -> bool { + self.prf_allowed + } } /// The configured allow-lists that classify a peer identity into a role. @@ -78,13 +109,19 @@ impl ClientIdentity { pub struct IdentityPolicy { allowed_clients: Arc>, admins: Arc>, + prf_clients: Arc>, } impl IdentityPolicy { - pub fn new(allowed_clients: BTreeSet, admins: BTreeSet) -> Self { + pub fn new( + allowed_clients: BTreeSet, + admins: BTreeSet, + prf_clients: BTreeSet, + ) -> Self { Self { allowed_clients: Arc::new(allowed_clients), admins: Arc::new(admins), + prf_clients: Arc::new(prf_clients), } } @@ -104,13 +141,18 @@ impl IdentityPolicy { /// /// Admin is checked first so an identity on both lists is treated as admin. /// If the client allow-list is empty, every peer is at least a `Client` - /// (back-compat); the admin list is always enforced explicitly. + /// (back-compat); the admin list is always enforced explicitly. The PRF + /// list admits otherwise-unlisted identities with the restricted `Prf` + /// role, and sets `prf_allowed` on any admitted identity whose candidates + /// match it — the ONLY way `prf_allowed` becomes true. pub fn classify(&self, candidates: &[String]) -> Option { + let prf_allowed = candidates.iter().any(|c| self.prf_clients.contains(c)); let admin_match = candidates.iter().find(|c| self.admins.contains(*c)); if let Some(name) = admin_match { return Some(ClientIdentity { name: name.clone(), role: Role::Admin, + prf_allowed, }); } if self.allowed_clients.is_empty() { @@ -123,15 +165,34 @@ impl IdentityPolicy { return Some(ClientIdentity { name, role: Role::Client, + prf_allowed, }); } - let client_match = candidates + if let Some(name) = candidates .iter() - .find(|c| self.allowed_clients.contains(*c)); - client_match.map(|name| ClientIdentity { - name: name.clone(), - role: Role::Client, - }) + .find(|c| self.allowed_clients.contains(*c)) + { + return Some(ClientIdentity { + name: name.clone(), + role: Role::Client, + prf_allowed, + }); + } + // Not on the client list: admit with the restricted Prf role iff on + // the PRF list (per-route gates take it from here). + if prf_allowed { + let name = candidates + .iter() + .find(|c| self.prf_clients.contains(*c)) + .cloned() + .unwrap_or_else(|| "".to_string()); + return Some(ClientIdentity { + name, + role: Role::Prf, + prf_allowed, + }); + } + None } } @@ -330,9 +391,14 @@ mod tests { use super::*; fn policy(clients: &[&str], admins: &[&str]) -> IdentityPolicy { + policy_with_prf(clients, admins, &[]) + } + + fn policy_with_prf(clients: &[&str], admins: &[&str], prf: &[&str]) -> IdentityPolicy { IdentityPolicy::new( clients.iter().map(|s| s.to_string()).collect(), admins.iter().map(|s| s.to_string()).collect(), + prf.iter().map(|s| s.to_string()).collect(), ) } @@ -388,6 +454,48 @@ mod tests { assert!(!id.is_admin()); } + #[test] + fn prf_allowed_comes_only_from_the_prf_list() { + // The open back-compat client list must NEVER grant PRF access. + let p = policy_with_prf(&[], &[], &["minister"]); + let open = p.classify(&["whoever".to_string()]).unwrap(); + assert_eq!(open.role, Role::Client); + assert!(!open.may_prf(), "open client list must not grant PRF"); + // A configured client off the PRF list gets no PRF either. + let p = policy_with_prf(&["freedink"], &[], &["minister"]); + let c = p.classify(&["freedink".to_string()]).unwrap(); + assert!(c.may_sign()); + assert!(!c.may_prf()); + // The PRF-listed identity is PRF-allowed. + let m = p.classify(&["minister".to_string()]).unwrap(); + assert!(m.may_prf()); + } + + #[test] + fn prf_only_identity_gets_restricted_role() { + // With a configured client list, a PRF-only identity is admitted with + // the Prf role: it may reach /prf but NOT the blind-RSA surface. + let p = policy_with_prf(&["freedink"], &[], &["minister"]); + let m = p.classify(&["minister".to_string()]).unwrap(); + assert_eq!(m.role, Role::Prf); + assert_eq!(m.name, "minister"); + assert!(m.may_prf()); + assert!(!m.may_sign()); + assert!(!m.is_admin()); + // An identity on BOTH lists keeps full client access plus PRF. + let p = policy_with_prf(&["freedink", "minister"], &[], &["minister"]); + let m = p.classify(&["minister".to_string()]).unwrap(); + assert_eq!(m.role, Role::Client); + assert!(m.may_prf()); + assert!(m.may_sign()); + } + + #[test] + fn unlisted_identity_still_rejected_with_prf_list_configured() { + let p = policy_with_prf(&["freedink"], &[], &["minister"]); + assert!(p.classify(&["intruder".to_string()]).is_none()); + } + #[tokio::test] async fn tls_handshake_past_the_timeout_is_dropped() { use axum_server::accept::Accept; @@ -450,7 +558,7 @@ mod tests { let acceptor = IdentityAcceptor::new( Arc::new(config), - IdentityPolicy::new(BTreeSet::new(), BTreeSet::new()), + IdentityPolicy::new(BTreeSet::new(), BTreeSet::new(), BTreeSet::new()), ) .with_handshake_timeout(Duration::from_millis(100)); diff --git a/src/lib.rs b/src/lib.rs index 8fe5293..89156c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,13 +26,28 @@ use state::AppState; use std::sync::Arc; /// Build the application router with all endpoints wired to `state`. +/// +/// The PRF/dedup routes are mounted ONLY when `state.prf` is present (i.e. +/// the fail-closed boot policy in [`dedup::prepare_prf_boot`] enabled the +/// surface). Without it they 404 and the deployed /sign behavior is exactly +/// what it was before the PRF surface existed. pub fn router(state: Arc) -> Router { - Router::new() + let mut router = Router::new() .route("/healthz", get(handlers::healthz)) .route("/sign", post(handlers::sign)) .route("/key", get(handlers::get_key).post(handlers::create_key)) - .route("/key/rotate", post(handlers::rotate_key)) - .with_state(state) + .route("/key/rotate", post(handlers::rotate_key)); + if state.prf.is_some() { + router = router + .route("/prf/pairwise", post(handlers::prf_pairwise)) + .route("/prf/evaluate", post(handlers::prf_evaluate)) + .route("/prf/public-key", get(handlers::prf_public_key)) + .route("/prf/disclose", post(handlers::prf_disclose)) + .route("/dedup/register", post(handlers::dedup_register)) + .route("/dedup/release", post(handlers::dedup_release)) + .route("/dedup/reassign", post(handlers::dedup_reassign)); + } + router.with_state(state) } /// Serve the router over mTLS on an already-bound `std::net::TcpListener`, with diff --git a/src/main.rs b/src/main.rs index 5f099d6..a100410 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,19 @@ //! Signet — hardened partially-blind RSA signing service for FreedInk vote -//! tokens. +//! tokens, plus the Minister PRF/dedup surface (RFC 9497 VOPRF nullifiers and +//! the pairwise HMAC oracle). //! -//! Thin entrypoint: load config, open the DB, build the mTLS server, and serve -//! the router defined in the library. See README.md for the integration -//! contract and operational setup. +//! Thin entrypoint: load config, open the DB, run the fail-closed PRF boot +//! policy, build the mTLS server, and serve the router defined in the +//! library. Also hosts the one-shot `init-service-keys` mode. See README.md +//! for the integration contract and operational setup. -use signet::config::Config; +use signet::config::{self, Config}; use signet::db::Db; +use signet::dedup::{self, PrfBoot, PrfBootArgs}; use signet::identity::IdentityPolicy; use signet::keygen::KeygenService; use signet::ratelimit::{KeyRateLimiter, RateLimiter}; -use signet::state::AppState; +use signet::state::{AppState, PrfState}; use signet::{router, serve, tls}; use std::sync::Arc; @@ -23,11 +26,21 @@ fn main() { ) .init(); + // One-shot service-key initialization (`signet init-service-keys` or + // SIGNET_INIT_SERVICE_KEYS=1): mint + seal the nullifier master seed, + // print pkS for pinning, and exit. Deliberately NOT part of ordinary + // boot — ordinary boot never generates key material (key-fork guard). + if is_init_mode() { + run_init_service_keys(); + return; + } + // Parse configuration BEFORE the async runtime exists (audit L1). Config - // loading consumes secret environment variables (`SIGNET_KEK`) via - // `std::env::remove_var`, which is only sound while the process is still - // single-threaded. A `#[tokio::main]` entrypoint would spawn the runtime's - // worker threads first and make that env mutation a data race. + // loading consumes secret environment variables (`SIGNET_KEK`, + // `SIGNET_IMPORT_PAIRWISE_HMAC`) via `std::env::remove_var`, which is + // only sound while the process is still single-threaded. A + // `#[tokio::main]` entrypoint would spawn the runtime's worker threads + // first and make that env mutation a data race. let cfg = match Config::from_env() { Ok(cfg) => cfg, Err(e) => { @@ -53,13 +66,73 @@ fn main() { } } -async fn run(cfg: Config) -> Result<(), String> { +fn is_init_mode() -> bool { + std::env::args().nth(1).as_deref() == Some("init-service-keys") + || std::env::var("SIGNET_INIT_SERVICE_KEYS").is_ok_and(|v| v == "1") +} + +/// Mint + seal the master seed (one-shot), print the derived public key `pkS` +/// on stdout — and ONLY `pkS`, never seed bytes — then exit. The operator +/// pins the printed value as `SIGNET_DEDUP_PUBKEY_PIN` (and Minister's +/// `MINISTER_SIGNET_DEDUP_PUBKEY`). +fn run_init_service_keys() { + let result = (|| -> Result { + let kek = config::consume_kek_env()?; + let db_path = config::db_path_from_env()?; + let db = Db::open(&db_path)?; + dedup::init_service_keys(&db, &kek) + })(); + match result { + Ok(pk) => { + tracing::info!( + "service keys initialized; pin the printed public key as \ + SIGNET_DEDUP_PUBKEY_PIN and Minister's MINISTER_SIGNET_DEDUP_PUBKEY" + ); + println!("{pk}"); + } + Err(e) => { + tracing::error!(error = %e, "init-service-keys failed"); + std::process::exit(1); + } + } +} + +async fn run(mut cfg: Config) -> Result<(), String> { // Install the ring-based default crypto provider for rustls 0.23. rustls::crypto::ring::default_provider() .install_default() .map_err(|_| "failed to install rustls crypto provider".to_string())?; let db = Arc::new(Db::open(&cfg.db_path)?); + + // Fail-closed PRF boot policy: decides whether the PRF surface mounts, + // refuses startup on any inconsistent state (seed absent while + // configured, empty allow-list with initialized keys, missing or + // mismatched public-key pin, double import). + let prf_boot = dedup::prepare_prf_boot( + &db, + &cfg.kek, + PrfBootArgs { + prf_clients_configured: !cfg.prf_client_ids.is_empty(), + dedup_pubkey_pin: cfg.dedup_pubkey_pin.as_deref(), + import_pairwise: cfg.import_pairwise_hmac.take(), + }, + )?; + let prf = match prf_boot { + PrfBoot::Disabled => None, + PrfBoot::Enabled(keys) => Some(PrfState { + keys: *keys, + allowed_client_ids: cfg.prf_client_ids.clone(), + rate_limiter: KeyRateLimiter::new( + cfg.rl_prf_identity_max, + cfg.rl_prf_global_max, + cfg.rl_window_secs, + ), + }), + }; + let prf_enabled = prf.is_some(); + let prf_pairwise_ready = prf.as_ref().is_some_and(|p| p.keys.has_pairwise()); + let keygen = KeygenService::new( db.clone(), cfg.kek.clone(), @@ -82,9 +155,14 @@ async fn run(cfg: Config) -> Result<(), String> { keygen, auto_create_keys: cfg.auto_create_keys, key_bits: cfg.key_bits, + prf, }); - let policy = IdentityPolicy::new(cfg.allowed_client_ids.clone(), cfg.admin_ids.clone()); + let policy = IdentityPolicy::new( + cfg.allowed_client_ids.clone(), + cfg.admin_ids.clone(), + cfg.prf_client_ids.clone(), + ); if policy.client_list_is_open() { tracing::warn!( "SIGNET_ALLOWED_CLIENT_IDS is unset: ANY certificate chaining to \ @@ -116,6 +194,11 @@ async fn run(cfg: Config) -> Result<(), String> { rl_key_global_max = cfg.rl_key_global_max, allowed_client_ids = cfg.allowed_client_ids.len(), admin_ids = cfg.admin_ids.len(), + prf_enabled, + prf_client_ids = cfg.prf_client_ids.len(), + prf_pairwise_ready, + rl_prf_identity_max = cfg.rl_prf_identity_max, + rl_prf_global_max = cfg.rl_prf_global_max, "signet starting (mTLS required)" ); diff --git a/src/state.rs b/src/state.rs index ad9b628..9d57e8f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,9 +3,24 @@ use crate::db::Db; use crate::keygen::KeygenService; use crate::keystore::Kek; +use crate::prf::PrfKeys; use crate::ratelimit::{KeyRateLimiter, RateLimiter}; +use std::collections::BTreeSet; use std::sync::Arc; +/// State for the PRF/dedup surface. Present only when the fail-closed boot +/// policy enabled it (service keys initialized, non-empty allow-list, public +/// key pin verified); `None` means the `/prf/*` and `/dedup/*` routes are not +/// even mounted. +pub struct PrfState { + pub keys: PrfKeys, + /// The `SIGNET_PRF_CLIENT_IDS` allow-list, checked INSIDE each PRF/dedup + /// handler (per-route, fail-closed — mirroring the `is_admin()` gate). + pub allowed_client_ids: BTreeSet, + /// The PRF surface's own rate-limit bucket (separate from /sign + /key*). + pub rate_limiter: KeyRateLimiter, +} + pub struct AppState { /// Shared with [`KeygenService`] so handlers and the keygen worker pool use /// the same SQLite connection (one write-serialized connection behind a @@ -19,4 +34,6 @@ pub struct AppState { pub keygen: KeygenService, pub auto_create_keys: bool, pub key_bits: usize, + /// PRF/dedup surface state; `None` = surface disabled (routes unmounted). + pub prf: Option, } diff --git a/tests/at_rest.rs b/tests/at_rest.rs index 589864f..82b4fe1 100644 --- a/tests/at_rest.rs +++ b/tests/at_rest.rs @@ -103,3 +103,69 @@ async fn private_key_is_ciphertext_at_rest() { "control: plaintext PKCS#8 should contain the rsaEncryption OID" ); } + +#[tokio::test] +async fn service_keys_are_ciphertext_at_rest() { + // The PRF service keys (master seed + imported pairwise secret) must be + // stored ONLY as KEK-sealed AES-GCM envelopes: neither the fixed test + // seed bytes nor the pairwise secret bytes may appear anywhere in the + // database file's service_keys rows. + let pki = make_pki(); + let seed = *b"MINISTER-TEST-VECTOR-SEED-0001!!"; + let pairwise = b"minister-golden-vector-secret-v1-do-not-change!!".to_vec(); + let server = start_server( + &pki, + ServerOpts { + prf: Some(PrfOpts { + master_seed: seed, + pairwise_secret: Some(pairwise.clone()), + ..PrfOpts::default() + }), + ..ServerOpts::default() + }, + ) + .await; + + let conn = Connection::open(&server.db_path).unwrap(); + let mut stmt = conn + .prepare("SELECT purpose, sealed FROM service_keys") + .unwrap(); + let rows: Vec<(String, Vec)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + let purposes: Vec<&str> = rows.iter().map(|(p, _)| p.as_str()).collect(); + assert!(purposes.contains(&"master-seed-v1"), "seed row present"); + assert!( + purposes.contains(&"pairwise-hmac-v1"), + "pairwise row present" + ); + + for (purpose, blob) in &rows { + assert_eq!( + blob[0], 0x01, + "service key {purpose} must be our sealed envelope (v1)" + ); + assert!( + !contains(blob, &seed), + "service key {purpose} contains the plaintext master seed" + ); + assert!( + !contains(blob, &pairwise), + "service key {purpose} contains the plaintext pairwise secret" + ); + // No long plaintext substring either (a partial leak is still a leak). + assert!( + !contains(blob, &seed[..16]), + "service key {purpose} contains a seed prefix" + ); + assert!( + !contains(blob, &pairwise[..16]), + "service key {purpose} contains a pairwise-secret prefix" + ); + } + + // Control: the check is meaningful — the seed does contain its own prefix. + assert!(contains(&seed, &seed[..16])); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 64af831..7280467 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -12,15 +12,17 @@ use rcgen::{ KeyUsagePurpose, SanType, }; use signet::db::Db; +use signet::dedup::{prepare_prf_boot, PrfBoot, PrfBootArgs}; use signet::identity::IdentityPolicy; use signet::keygen::KeygenService; use signet::keystore::Kek; use signet::ratelimit::{KeyRateLimiter, RateLimiter}; -use signet::state::AppState; +use signet::state::{AppState, PrfState}; use std::collections::BTreeSet; use std::net::{Ipv4Addr, SocketAddr}; use std::sync::Arc; use std::sync::Once; +use zeroize::Zeroizing; static INIT: Once = Once::new(); @@ -44,6 +46,9 @@ pub struct Pki { /// that chains to the CA but is off the allow-list is rejected). pub other_cert_pem: String, pub other_key_pem: String, + /// PRF client identity: CN "minister". + pub prf_cert_pem: String, + pub prf_key_pem: String, } /// CN of the default client cert. @@ -52,6 +57,8 @@ pub const CLIENT_CN: &str = "freedink"; pub const ADMIN_CN: &str = "signet-admin"; /// CN of the second, non-allow-listed client cert. pub const OTHER_CN: &str = "intruder"; +/// CN of the PRF client cert. +pub const PRF_CN: &str = "minister"; pub fn make_pki() -> Pki { let ca_key = KeyPair::generate().unwrap(); @@ -84,6 +91,7 @@ pub fn make_pki() -> Pki { let (client_cert_pem, client_key_pem) = mint_client(CLIENT_CN); let (admin_cert_pem, admin_key_pem) = mint_client(ADMIN_CN); let (other_cert_pem, other_key_pem) = mint_client(OTHER_CN); + let (prf_cert_pem, prf_key_pem) = mint_client(PRF_CN); Pki { ca_pem: ca_cert.pem(), @@ -95,6 +103,8 @@ pub fn make_pki() -> Pki { admin_key_pem, other_cert_pem, other_key_pem, + prf_cert_pem, + prf_key_pem, } } @@ -111,6 +121,33 @@ impl Drop for Server { } } +/// PRF-surface knobs for the test server. Present = the surface is enabled +/// through the REAL boot path (seed sealed into service_keys, pairwise +/// imported via the one-shot import path, pin computed and verified). +pub struct PrfOpts { + /// Identities allowed on the PRF surface (SIGNET_PRF_CLIENT_IDS analogue). + pub client_ids: BTreeSet, + /// The FIXED master seed (fixed so frozen vectors are assertable). + pub master_seed: [u8; 32], + /// Pairwise secret to import (None = /prf/pairwise not initialized). + pub pairwise_secret: Option>, + /// Per-identity + global PRF rate-limit ceilings. + pub rl_identity_max: u32, + pub rl_global_max: u32, +} + +impl Default for PrfOpts { + fn default() -> Self { + Self { + client_ids: id_set(&[PRF_CN]), + master_seed: *b"MINISTER-TEST-VECTOR-SEED-0001!!", + pairwise_secret: Some(b"minister-golden-vector-secret-v1-do-not-change!!".to_vec()), + rl_identity_max: 1_000_000, + rl_global_max: 1_000_000, + } + } +} + /// Configuration knobs for the test server. pub struct ServerOpts { pub rl_participant_max: u32, @@ -127,6 +164,9 @@ pub struct ServerOpts { /// Admin identities (empty = rotation disabled). pub admin_ids: BTreeSet, pub auto_create_keys: bool, + /// PRF surface (None = not mounted, the default — /sign-only tests run + /// exactly the pre-PRF deployment shape). + pub prf: Option, } impl Default for ServerOpts { @@ -146,6 +186,7 @@ impl Default for ServerOpts { allowed_client_ids: BTreeSet::new(), admin_ids: BTreeSet::new(), auto_create_keys: true, + prf: None, } } } @@ -172,6 +213,42 @@ pub async fn start_server(pki: &Pki, opts: ServerOpts) -> Server { let kek = Kek::from_encoded(&hex::encode([0x5au8; 32])).unwrap(); let db = Arc::new(Db::open(&db_path).unwrap()); + + // PRF surface: exercise the REAL lifecycle — seal the fixed seed like + // init would, then load through the production boot policy (pin check + + // one-shot pairwise import included). + let (prf_state, prf_ids) = match &opts.prf { + Some(prf) => { + let pin = signet::dedup::seal_master_seed(&db, &kek, &prf.master_seed).unwrap(); + let boot = prepare_prf_boot( + &db, + &kek, + PrfBootArgs { + prf_clients_configured: !prf.client_ids.is_empty(), + dedup_pubkey_pin: Some(&pin), + import_pairwise: prf + .pairwise_secret + .as_ref() + .map(|s| Zeroizing::new(s.clone())), + }, + ) + .expect("PRF boot policy must enable the surface"); + let keys = match boot { + PrfBoot::Enabled(keys) => *keys, + PrfBoot::Disabled => panic!("PRF opts set but boot disabled the surface"), + }; + ( + Some(PrfState { + keys, + allowed_client_ids: prf.client_ids.clone(), + rate_limiter: KeyRateLimiter::new(prf.rl_identity_max, prf.rl_global_max, 60), + }), + prf.client_ids.clone(), + ) + } + None => (None, BTreeSet::new()), + }; + let keygen = KeygenService::new( db.clone(), kek.clone(), @@ -186,11 +263,16 @@ pub async fn start_server(pki: &Pki, opts: ServerOpts) -> Server { keygen, auto_create_keys: opts.auto_create_keys, key_bits: opts.key_bits, + prf: prf_state, }); let app = signet::router(state); let tls = signet::tls::build_server_config(&cert_path, &key_path, &ca_path).unwrap(); - let policy = IdentityPolicy::new(opts.allowed_client_ids.clone(), opts.admin_ids.clone()); + let policy = IdentityPolicy::new( + opts.allowed_client_ids.clone(), + opts.admin_ids.clone(), + prf_ids, + ); // Bind an ephemeral port via std, learn the addr, then hand to the shared // identity-pinning serve path. @@ -248,6 +330,11 @@ pub fn other_client(pki: &Pki) -> reqwest::Client { client_with_identity(pki, &pki.other_cert_pem, &pki.other_key_pem) } +/// A reqwest client presenting the PRF client certificate (CN minister). +pub fn prf_client(pki: &Pki) -> reqwest::Client { + client_with_identity(pki, &pki.prf_cert_pem, &pki.prf_key_pem) +} + /// A reqwest client with NO client certificate (should be rejected by mTLS). pub fn client_without_cert(pki: &Pki) -> reqwest::Client { let ca = reqwest::Certificate::from_pem(pki.ca_pem.as_bytes()).unwrap(); diff --git a/tests/prf.rs b/tests/prf.rs new file mode 100644 index 0000000..12abafe --- /dev/null +++ b/tests/prf.rs @@ -0,0 +1,466 @@ +//! PRF/dedup surface over the live mTLS HTTP service: +//! - the Minister golden pairwise vectors reproduce byte-for-byte, +//! - blind evaluate round-trips with a verifying DLEQ proof and finalizes +//! to the frozen ecosystem N_dedup, +//! - the dedup ledger enforces one-credential-one-account (incl. under +//! concurrency), owner-checked release/reassign/disclose, +//! - malformed input is a 400, never a 500. + +mod common; +use common::*; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL; +use base64::Engine; +use serde_json::{json, Value}; +use std::sync::Arc; +use voprf::{Group, Ristretto255, VoprfClient}; + +fn prf_server_opts() -> ServerOpts { + ServerOpts { + prf: Some(PrfOpts::default()), + ..ServerOpts::default() + } +} + +async fn post( + client: &reqwest::Client, + base: &str, + path: &str, + body: Value, +) -> (reqwest::StatusCode, Value) { + let res = client + .post(format!("{base}{path}")) + .json(&body) + .send() + .await + .unwrap(); + let status = res.status(); + let body: Value = res.json().await.unwrap(); + (status, body) +} + +fn vectors() -> Value { + serde_json::from_str(include_str!("../interop/prf-vectors.json")).unwrap() +} + +#[tokio::test] +async fn pairwise_reproduces_the_minister_golden_vectors() { + let pki = make_pki(); + let server = start_server(&pki, prf_server_opts()).await; + let client = prf_client(&pki); + let base = base_url(&server); + + for vector in vectors()["pairwise"]["vectors"].as_array().unwrap() { + let input = vector["input"].as_str().unwrap(); + let expected = vector["output"].as_str().unwrap(); + let (status, body) = post(&client, &base, "/prf/pairwise", json!({ "input": input })).await; + assert_eq!(status, 200, "{body}"); + assert_eq!( + body["output"].as_str().unwrap(), + expected, + "pairwise output for {input:?} must be byte-identical to Minister's live path" + ); + } +} + +#[tokio::test] +async fn pairwise_without_imported_secret_is_404() { + let pki = make_pki(); + let server = start_server( + &pki, + ServerOpts { + prf: Some(PrfOpts { + pairwise_secret: None, + ..PrfOpts::default() + }), + ..ServerOpts::default() + }, + ) + .await; + let client = prf_client(&pki); + let (status, body) = post( + &client, + &base_url(&server), + "/prf/pairwise", + json!({ "input": "user:client" }), + ) + .await; + assert_eq!(status, 404, "{body}"); + assert_eq!(body["error"], "not_found"); +} + +#[tokio::test] +async fn evaluate_roundtrip_finalizes_to_the_frozen_n_dedup_with_verified_dleq() { + let pki = make_pki(); + let server = start_server(&pki, prf_server_opts()).await; + let client = prf_client(&pki); + let base = base_url(&server); + let vectors = vectors(); + + // The pinned public key must match the frozen fixture. + let res = client + .get(format!("{base}/prf/public-key")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["suite"], "ristretto255-SHA512"); + let pk_b64 = body["public_key"].as_str().unwrap().to_string(); + assert_eq!(pk_b64, vectors["public_key_b64url"].as_str().unwrap()); + + // Client-side blind (with a RANDOM blind) -> HTTP evaluate -> finalize. + // Determinism of the finalized output regardless of the blind is exactly + // what the dedup ledger relies on. + let input = hex::decode(vectors["dedup"]["input_hex"].as_str().unwrap()).unwrap(); + let blind_result = VoprfClient::::blind(&input, &mut rand_core::OsRng).unwrap(); + let (status, body) = post( + &client, + &base, + "/prf/evaluate", + json!({ "blinded_element": B64URL.encode(blind_result.message.serialize()) }), + ) + .await; + assert_eq!(status, 200, "{body}"); + + let eval_raw = B64URL + .decode(body["evaluation_element"].as_str().unwrap()) + .unwrap(); + let proof_raw = B64URL.decode(body["proof"].as_str().unwrap()).unwrap(); + let eval = voprf::EvaluationElement::::deserialize(&eval_raw).unwrap(); + let proof = voprf::Proof::::deserialize(&proof_raw).unwrap(); + let pk_raw = B64URL.decode(&pk_b64).unwrap(); + let pk = ::deserialize_elem(&pk_raw).unwrap(); + + // finalize verifies the DLEQ proof against the pinned public key. + let output = blind_result + .state + .finalize(&input, &eval, &proof, pk) + .expect("finalize (incl. DLEQ verification) must succeed"); + assert_eq!( + hex::encode(output), + vectors["dedup"]["n_dedup_hex"].as_str().unwrap(), + "finalized N_dedup must equal the frozen ecosystem vector" + ); + + // A proof tampered with by one byte must fail DLEQ verification. + let mut bad_proof_raw = proof_raw.clone(); + bad_proof_raw[0] ^= 0x01; + if let Ok(bad_proof) = voprf::Proof::::deserialize(&bad_proof_raw) { + assert!( + blind_result + .state + .finalize(&input, &eval, &bad_proof, pk) + .is_err(), + "a tampered DLEQ proof must not verify" + ); + } +} + +#[tokio::test] +async fn dedup_register_release_reassign_and_disclose_flow() { + let pki = make_pki(); + let server = start_server(&pki, prf_server_opts()).await; + let client = prf_client(&pki); + let base = base_url(&server); + let value = B64URL.encode([0x21u8; 64]); + + // Register. + let (status, body) = post( + &client, + &base, + "/dedup/register", + json!({ "value": value, "owner_handle": "owner-a", "badge_type": "oauth-account" }), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["status"], "registered"); + let entry_ref = body["entry_ref"].as_str().unwrap().to_string(); + + // Same owner re-register: already_yours with the SAME ref. + let (status, body) = post( + &client, + &base, + "/dedup/register", + json!({ "value": value, "owner_handle": "owner-a", "badge_type": "oauth-account" }), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["status"], "already_yours"); + assert_eq!(body["entry_ref"].as_str().unwrap(), entry_ref); + + // Different owner: 409 taken. + let (status, body) = post( + &client, + &base, + "/dedup/register", + json!({ "value": value, "owner_handle": "owner-b", "badge_type": "oauth-account" }), + ) + .await; + assert_eq!(status, 409, "{body}"); + assert_eq!(body["error"], "taken"); + + // Disclose: owner-checked, per-RP distinct, deterministic, versioned. + let (status, body) = post( + &client, + &base, + "/prf/disclose", + json!({ "entry_ref": entry_ref, "owner_handle": "owner-a", "client_id": "mc_rp_one" }), + ) + .await; + assert_eq!(status, 200, "{body}"); + let n_rp_one = body["nullifier"].as_str().unwrap().to_string(); + assert!(n_rp_one.starts_with("mnv1:")); + let (_, body_again) = post( + &client, + &base, + "/prf/disclose", + json!({ "entry_ref": entry_ref, "owner_handle": "owner-a", "client_id": "mc_rp_one" }), + ) + .await; + assert_eq!(body_again["nullifier"].as_str().unwrap(), n_rp_one); + let (_, body_two) = post( + &client, + &base, + "/prf/disclose", + json!({ "entry_ref": entry_ref, "owner_handle": "owner-a", "client_id": "mc_rp_two" }), + ) + .await; + assert_ne!( + body_two["nullifier"].as_str().unwrap(), + n_rp_one, + "different RPs must receive unlinkable nullifiers" + ); + + // Disclose with the wrong owner handle: 403, fail closed. + let (status, body) = post( + &client, + &base, + "/prf/disclose", + json!({ "entry_ref": entry_ref, "owner_handle": "owner-b", "client_id": "mc_rp_one" }), + ) + .await; + assert_eq!(status, 403, "{body}"); + assert_eq!(body["error"], "forbidden"); + + // Reassign (merge): explicit ref list, owner-checked. + let (status, body) = post( + &client, + &base, + "/dedup/reassign", + json!({ + "entry_refs": [entry_ref], + "from_owner_handle": "owner-a", + "to_owner_handle": "owner-b" + }), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["reassigned"], 1); + // Disclosure now works for the new owner and yields the SAME per-RP value + // (merge-invariant: the nullifier derives from the credential, not the + // owner). + let (status, body) = post( + &client, + &base, + "/prf/disclose", + json!({ "entry_ref": entry_ref, "owner_handle": "owner-b", "client_id": "mc_rp_one" }), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["nullifier"].as_str().unwrap(), n_rp_one); + + // Release with the wrong owner: 403; with the right owner: released. + let (status, _) = post( + &client, + &base, + "/dedup/release", + json!({ "entry_ref": entry_ref, "owner_handle": "owner-a" }), + ) + .await; + assert_eq!(status, 403); + let (status, body) = post( + &client, + &base, + "/dedup/release", + json!({ "entry_ref": entry_ref, "owner_handle": "owner-b" }), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["status"], "released"); + // Idempotent retry. + let (status, body) = post( + &client, + &base, + "/dedup/release", + json!({ "entry_ref": entry_ref, "owner_handle": "owner-b" }), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["status"], "already_released"); + + // After release the credential is registrable again (serial-identity + // path: delete account -> re-verify from a new account succeeds). + let (status, body) = post( + &client, + &base, + "/dedup/register", + json!({ "value": value, "owner_handle": "owner-c", "badge_type": "oauth-account" }), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["status"], "registered"); +} + +#[tokio::test] +async fn concurrent_register_same_value_has_exactly_one_winner() { + let pki = make_pki(); + let server = start_server(&pki, prf_server_opts()).await; + let client = Arc::new(prf_client(&pki)); + let base = base_url(&server); + let value = B64URL.encode([0x77u8; 64]); + + let n = 16; + let mut tasks = Vec::new(); + for i in 0..n { + let client = client.clone(); + let base = base.clone(); + let value = value.clone(); + tasks.push(tokio::spawn(async move { + let res = client + .post(format!("{base}/dedup/register")) + .json(&json!({ + "value": value, + "owner_handle": format!("owner-{i}"), + "badge_type": "email-domain" + })) + .send() + .await + .unwrap(); + res.status().as_u16() + })); + } + let mut ok = 0; + let mut taken = 0; + let mut other = 0; + for t in tasks { + match t.await.unwrap() { + 200 => ok += 1, + 409 => taken += 1, + _ => other += 1, + } + } + assert_eq!(ok, 1, "exactly one register may win"); + assert_eq!(taken, n - 1, "the rest must be 409 taken"); + assert_eq!(other, 0, "no other status allowed"); +} + +#[tokio::test] +async fn malformed_inputs_are_400_never_500() { + let pki = make_pki(); + let server = start_server(&pki, prf_server_opts()).await; + let client = prf_client(&pki); + let base = base_url(&server); + + let cases: Vec<(&str, Value)> = vec![ + // pairwise: empty and oversize inputs. + ("/prf/pairwise", json!({ "input": "" })), + ("/prf/pairwise", json!({ "input": "x".repeat(513) })), + // evaluate: bad base64, wrong length, non-canonical element, identity. + ( + "/prf/evaluate", + json!({ "blinded_element": "!!!not-base64!!!" }), + ), + ( + "/prf/evaluate", + json!({ "blinded_element": B64URL.encode([0u8; 31]) }), + ), + ( + "/prf/evaluate", + json!({ "blinded_element": B64URL.encode([0xffu8; 32]) }), + ), + ( + "/prf/evaluate", + json!({ "blinded_element": B64URL.encode([0u8; 32]) }), + ), + ("/prf/evaluate", json!({ "blinded_element": "" })), + // register: bad value encodings and oversize fields. + ( + "/dedup/register", + json!({ "value": "###", "owner_handle": "o", "badge_type": "t" }), + ), + ( + "/dedup/register", + json!({ "value": B64URL.encode([0u8; 32]), "owner_handle": "o", "badge_type": "t" }), + ), + ( + "/dedup/register", + json!({ "value": B64URL.encode([0u8; 64]), "owner_handle": "o".repeat(129), "badge_type": "t" }), + ), + ( + "/dedup/register", + json!({ "value": B64URL.encode([0u8; 64]), "owner_handle": "o", "badge_type": "t".repeat(65) }), + ), + // disclose / release: bad refs. + ( + "/prf/disclose", + json!({ "entry_ref": "short", "owner_handle": "o", "client_id": "c" }), + ), + ( + "/prf/disclose", + json!({ "entry_ref": B64URL.encode([0u8; 8]), "owner_handle": "o", "client_id": "c" }), + ), + ( + "/prf/disclose", + json!({ "entry_ref": B64URL.encode([0u8; 16]), "owner_handle": "o", "client_id": "c".repeat(257) }), + ), + ( + "/dedup/release", + json!({ "entry_ref": "%%%%", "owner_handle": "o" }), + ), + // reassign: empty list, oversize list, equal handles. + ( + "/dedup/reassign", + json!({ "entry_refs": [], "from_owner_handle": "a", "to_owner_handle": "b" }), + ), + ( + "/dedup/reassign", + json!({ + "entry_refs": vec![B64URL.encode([0u8; 16]); 257], + "from_owner_handle": "a", + "to_owner_handle": "b" + }), + ), + ( + "/dedup/reassign", + json!({ "entry_refs": [B64URL.encode([0u8; 16])], "from_owner_handle": "a", "to_owner_handle": "a" }), + ), + ]; + + for (path, body) in cases { + let (status, resp) = post(&client, &base, path, body.clone()).await; + assert_eq!( + status, 400, + "{path} with {body} must be 400, got {status}: {resp}" + ); + } + + // Unknown refs: 404 (well-formed but absent). + let absent = B64URL.encode([0xeeu8; 16]); + let (status, _) = post( + &client, + &base, + "/prf/disclose", + json!({ "entry_ref": absent, "owner_handle": "o", "client_id": "c" }), + ) + .await; + assert_eq!(status, 404); + let (status, _) = post( + &client, + &base, + "/dedup/reassign", + json!({ "entry_refs": [absent], "from_owner_handle": "a", "to_owner_handle": "b" }), + ) + .await; + assert_eq!(status, 404); +} diff --git a/tests/prf_authz.rs b/tests/prf_authz.rs new file mode 100644 index 0000000..b2a7960 --- /dev/null +++ b/tests/prf_authz.rs @@ -0,0 +1,258 @@ +//! PRF surface authorization, fail-closed at every layer: +//! - a /sign-authorized identity NOT on SIGNET_PRF_CLIENT_IDS gets 403 on +//! EVERY /prf/* and /dedup/* route, +//! - a PRF-only identity gets 403 on the blind-RSA surface, +//! - without PRF configuration the routes are not even mounted (404) and +//! /sign works exactly as before, +//! - startup refusals (empty-list / seed-absent / pin-mismatch) are pinned +//! at the boot-policy level in src/dedup.rs unit tests. + +mod common; +use common::*; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL; +use base64::Engine; +use serde_json::json; + +/// Every PRF/dedup route with a syntactically valid body, so the ONLY thing +/// that can reject the request is authorization. +fn prf_routes() -> Vec<(&'static str, &'static str, serde_json::Value)> { + let entry_ref = B64URL.encode([0u8; 16]); + vec![ + ("POST", "/prf/pairwise", json!({ "input": "user:client" })), + ( + "POST", + "/prf/evaluate", + json!({ "blinded_element": B64URL.encode([0u8; 32]) }), + ), + ("GET", "/prf/public-key", json!({})), + ( + "POST", + "/prf/disclose", + json!({ "entry_ref": entry_ref, "owner_handle": "o", "client_id": "c" }), + ), + ( + "POST", + "/dedup/register", + json!({ "value": B64URL.encode([0u8; 64]), "owner_handle": "o", "badge_type": "t" }), + ), + ( + "POST", + "/dedup/release", + json!({ "entry_ref": entry_ref, "owner_handle": "o" }), + ), + ( + "POST", + "/dedup/reassign", + json!({ "entry_refs": [entry_ref], "from_owner_handle": "a", "to_owner_handle": "b" }), + ), + ] +} + +async fn status_for( + client: &reqwest::Client, + base: &str, + method: &str, + path: &str, + body: &serde_json::Value, +) -> u16 { + let req = match method { + "GET" => client.get(format!("{base}{path}")), + _ => client.post(format!("{base}{path}")).json(body), + }; + req.send().await.unwrap().status().as_u16() +} + +#[tokio::test] +async fn sign_authorized_identity_gets_403_on_every_prf_route() { + let pki = make_pki(); + // freedink is on the client allow-list (may /sign) but NOT on the PRF + // list; minister is PRF-only. + let server = start_server( + &pki, + ServerOpts { + allowed_client_ids: id_set(&[CLIENT_CN]), + prf: Some(PrfOpts::default()), + ..ServerOpts::default() + }, + ) + .await; + let base = base_url(&server); + let freedink = client_with_cert(&pki); + for (method, path, body) in prf_routes() { + assert_eq!( + status_for(&freedink, &base, method, path, &body).await, + 403, + "{path} must be 403 for a /sign-authorized, non-PRF identity" + ); + } +} + +#[tokio::test] +async fn open_client_list_still_does_not_grant_prf() { + let pki = make_pki(); + // OPEN client list (back-compat: any valid-chain cert may /sign) — the + // PRF surface must STILL refuse identities off the PRF list. + let server = start_server( + &pki, + ServerOpts { + allowed_client_ids: id_set(&[]), + prf: Some(PrfOpts::default()), + ..ServerOpts::default() + }, + ) + .await; + let base = base_url(&server); + let intruder = other_client(&pki); + for (method, path, body) in prf_routes() { + assert_eq!( + status_for(&intruder, &base, method, path, &body).await, + 403, + "{path} must be 403 under the open client list" + ); + } +} + +#[tokio::test] +async fn prf_only_identity_is_refused_on_the_blind_rsa_surface() { + let pki = make_pki(); + let server = start_server( + &pki, + ServerOpts { + allowed_client_ids: id_set(&[CLIENT_CN]), + prf: Some(PrfOpts::default()), + ..ServerOpts::default() + }, + ) + .await; + let base = base_url(&server); + let minister = prf_client(&pki); + + // The PRF surface serves it… + let res = minister + .get(format!("{base}/prf/public-key")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + + // …but /sign and /key* refuse it. + let res = minister + .post(format!("{base}/sign")) + .json(&json!({ + "group_id": "g", "participant_id": "p", "version_id": "v", + "blinded_message": "AAAA" + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 403, "PRF-only identity must not reach /sign"); + let res = minister + .get(format!("{base}/key?group_id=g")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 403, "PRF-only identity must not reach /key"); + let res = minister + .post(format!("{base}/key/rotate?group_id=g")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 403); +} + +#[tokio::test] +async fn identity_on_both_lists_reaches_both_surfaces() { + let pki = make_pki(); + let prf = PrfOpts { + client_ids: id_set(&[PRF_CN, CLIENT_CN]), + ..PrfOpts::default() + }; + let server = start_server( + &pki, + ServerOpts { + allowed_client_ids: id_set(&[CLIENT_CN]), + prf: Some(prf), + ..ServerOpts::default() + }, + ) + .await; + let base = base_url(&server); + let freedink = client_with_cert(&pki); + // PRF surface: allowed (on the PRF list). + let res = freedink + .get(format!("{base}/prf/public-key")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + // Blind-RSA surface: allowed (on the client list). /key GET enqueues. + let res = freedink + .get(format!("{base}/key?group_id=g1")) + .send() + .await + .unwrap(); + assert!( + res.status() == 200 || res.status() == 202, + "client-listed identity must reach /key, got {}", + res.status() + ); +} + +#[tokio::test] +async fn without_prf_config_routes_are_not_mounted_and_sign_is_unchanged() { + let pki = make_pki(); + let server = start_server(&pki, ServerOpts::default()).await; // no PRF + let base = base_url(&server); + let client = client_with_cert(&pki); + + for (method, path, body) in prf_routes() { + assert_eq!( + status_for(&client, &base, method, path, &body).await, + 404, + "{path} must be unmounted (404) without PRF configuration" + ); + } + // The pre-PRF surface is intact. + let res = client.get(format!("{base}/healthz")).send().await.unwrap(); + assert_eq!(res.status(), 200); + let res = client + .get(format!("{base}/key?group_id=blog-1")) + .send() + .await + .unwrap(); + assert!(res.status() == 200 || res.status() == 202); +} + +#[tokio::test] +async fn prf_rate_limit_bucket_is_separate_and_fires() { + let pki = make_pki(); + let prf = PrfOpts { + rl_identity_max: 2, + ..PrfOpts::default() + }; + let server = start_server( + &pki, + ServerOpts { + prf: Some(prf), + ..ServerOpts::default() + }, + ) + .await; + let base = base_url(&server); + let minister = prf_client(&pki); + for _ in 0..2 { + let res = minister + .get(format!("{base}/prf/public-key")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + } + let res = minister + .get(format!("{base}/prf/public-key")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 429, "the PRF bucket must fire on the third"); +} diff --git a/tests/prf_logging.rs b/tests/prf_logging.rs new file mode 100644 index 0000000..c82fbd2 --- /dev/null +++ b/tests/prf_logging.rs @@ -0,0 +1,154 @@ +//! No-log assertions for the PRF surface: with a real global tracing +//! subscriber capturing everything the server emits, drive every PRF/dedup +//! endpoint (success AND failure paths) and assert the log stream contains +//! identities/endpoints but NEVER inputs, outputs, values, nullifiers, owner +//! handles, or entry refs. +//! +//! This file is its own integration-test binary with exactly one test, so the +//! global subscriber cannot interfere with other tests. + +mod common; +use common::*; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL; +use base64::Engine; +use serde_json::json; +use std::io::Write; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +struct SharedBuf(Arc>>); + +impl Write for SharedBuf { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn prf_logs_carry_identity_and_endpoint_but_never_payloads() { + let buf = SharedBuf(Arc::new(Mutex::new(Vec::new()))); + let writer_buf = buf.clone(); + tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_writer(move || writer_buf.clone()) + .init(); + + let pki = make_pki(); + let server = start_server( + &pki, + ServerOpts { + prf: Some(PrfOpts::default()), + allowed_client_ids: id_set(&[CLIENT_CN]), + ..ServerOpts::default() + }, + ) + .await; + let client = prf_client(&pki); + let base = base_url(&server); + + // Distinctive payload markers that must never appear in logs. + let pairwise_input = "SECRET-PAIRWISE-INPUT-user_zz91:mc_zz91"; + let owner_a = "OWNER-HANDLE-SECRET-A-zz91"; + let owner_b = "OWNER-HANDLE-SECRET-B-zz91"; + let client_id = "mc_SECRET_CLIENT_zz91"; + let value_bytes = [0xd7u8; 64]; + let value_b64 = B64URL.encode(value_bytes); + + // Success paths. + let res = client + .post(format!("{base}/prf/pairwise")) + .json(&json!({ "input": pairwise_input })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let pairwise_output = res.json::().await.unwrap()["output"] + .as_str() + .unwrap() + .to_string(); + + let res = client + .post(format!("{base}/dedup/register")) + .json(&json!({ "value": value_b64, "owner_handle": owner_a, "badge_type": "email-domain" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let entry_ref = res.json::().await.unwrap()["entry_ref"] + .as_str() + .unwrap() + .to_string(); + + let res = client + .post(format!("{base}/prf/disclose")) + .json(&json!({ "entry_ref": entry_ref, "owner_handle": owner_a, "client_id": client_id })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let nullifier = res.json::().await.unwrap()["nullifier"] + .as_str() + .unwrap() + .to_string(); + + // Failure paths (owner mismatch + taken + authz refusal) — the warn/info + // lines they emit must be payload-free too. + let res = client + .post(format!("{base}/prf/disclose")) + .json(&json!({ "entry_ref": entry_ref, "owner_handle": owner_b, "client_id": client_id })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 403); + let res = client + .post(format!("{base}/dedup/register")) + .json(&json!({ "value": value_b64, "owner_handle": owner_b, "badge_type": "email-domain" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409); + let freedink = client_with_cert(&pki); + let res = freedink + .post(format!("{base}/prf/pairwise")) + .json(&json!({ "input": pairwise_input })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 403); + + let logs = String::from_utf8_lossy(&buf.0.lock().unwrap()).to_string(); + + // Sanity: the capture works and carries identity + endpoint. + assert!( + logs.contains("prf/pairwise"), + "expected endpoint names in the captured logs; capture broken?" + ); + assert!( + logs.contains(PRF_CN), + "expected the pinned identity in logs" + ); + + // The forbidden strings: inputs, outputs, handles, refs, values. + for (what, needle) in [ + ("pairwise input", pairwise_input), + ("pairwise output", pairwise_output.as_str()), + ("owner handle A", owner_a), + ("owner handle B", owner_b), + ("client_id", client_id), + ("entry_ref", entry_ref.as_str()), + ("dedup value (b64)", value_b64.as_str()), + ("disclosed nullifier", nullifier.as_str()), + ] { + assert!( + !logs.contains(needle), + "{what} leaked into the logs: {needle}" + ); + } + // Hex spellings of the dedup value must not appear either. + assert!(!logs.contains(&hex::encode(value_bytes))); +} From 5d1879678201cac3b46afd8507234d8ef790bb9e Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:13:24 -0400 Subject: [PATCH 09/20] fix(dedup): verify the pubkey pin before sealing a pairwise import A boot that ultimately refuses (pin mismatch on a forked or wrong node) was persisting the KEK-sealed pairwise secret to that node's disk first, and the leftover row then tripped the one-shot 'already exists' refusal on the corrected boot. Resolve the import/sealed bytes without writing, derive the keys, check the pin, and seal the first-boot import only after every boot validation passes. Pinned by a mismatched-pin test asserting no pairwise-hmac-v1 row is written and that the corrected boot still imports. --- src/dedup.rs | 89 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 19 deletions(-) diff --git a/src/dedup.rs b/src/dedup.rs index eff0a4f..7cac1b9 100644 --- a/src/dedup.rs +++ b/src/dedup.rs @@ -23,7 +23,9 @@ //! - **One-shot pairwise import.** `SIGNET_IMPORT_PAIRWISE_HMAC` is consumed //! at config load (zeroize + remove_var, the SIGNET_KEK pattern) and sealed //! here on first boot; a second import while a sealed copy exists refuses -//! startup rather than silently overwriting. +//! startup rather than silently overwriting. The seal runs only AFTER every +//! other boot validation (pin check included) passes: a refusing boot never +//! persists the imported secret as a side effect. use crate::db::Db; use crate::keystore::Kek; @@ -145,8 +147,12 @@ pub fn prepare_prf_boot(db: &Db, kek: &Kek, args: PrfBootArgs<'_>) -> Result { if db.get_service_key(PAIRWISE_HMAC_PURPOSE)?.is_some() { return Err( @@ -156,27 +162,25 @@ pub fn prepare_prf_boot(db: &Db, kek: &Kek, args: PrfBootArgs<'_>) -> Result match db.get_service_key(PAIRWISE_HMAC_PURPOSE)? { - Some(sealed_pw) => Some(Zeroizing::new(kek.open( - PAIRWISE_HMAC_PURPOSE, - SERVICE_KEY_ID, - &sealed_pw, - )?)), - None => None, + Some(sealed_pw) => ( + Some(Zeroizing::new(kek.open( + PAIRWISE_HMAC_PURPOSE, + SERVICE_KEY_ID, + &sealed_pw, + )?)), + false, + ), + None => (None, false), }, }; - let keys = PrfKeys::from_seed(*seed, pairwise)?; + let keys = PrfKeys::from_seed( + *seed, + pairwise.as_ref().map(|s| Zeroizing::new(s.to_vec())), + )?; let derived = keys.public_key_b64(); if derived != pin { // Both values are public keys — safe to surface for ops. @@ -186,6 +190,20 @@ pub fn prepare_prf_boot(db: &Db, kek: &Kek, args: PrfBootArgs<'_>) -> Result keys, + PrfBoot::Disabled => panic!("must be enabled"), + }; + assert!(keys.has_pairwise()); + } + #[test] fn boot_refuses_import_when_surface_disabled() { let db = Db::open_in_memory().unwrap(); From 92c95320f3d1bafee30c5c20c4f2803063c79a07 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:14:46 -0400 Subject: [PATCH 10/20] fix(dedup): refuse init on a pinned node and boot on an orphaned pin Two fail-closed config-hygiene guards: - init-service-keys now refuses when SIGNET_DEDUP_PUBKEY_PIN is set (a pinned node's seed exists elsewhere by definition), so a stray SIGNET_INIT_SERVICE_KEYS=1 in a persistent unit env can no longer mint a fresh seed on a replica racing its keystore restore. It also consumes SIGNET_IMPORT_PAIRWISE_HMAC (zeroize + remove_var) and refuses instead of silently ignoring the secret on an init invocation. - prepare_prf_boot's disabled arm now refuses when the pubkey pin is set with no keys and no allow-list, mirroring the existing import-var refusal, instead of silently not mounting a surface the operator believes is pinned. --- src/config.rs | 6 +++-- src/dedup.rs | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 11 ++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index dd1a138..f3378c8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -121,8 +121,10 @@ pub fn consume_kek_env() -> Result { /// Consume `SIGNET_IMPORT_PAIRWISE_HMAC` (if set): take the EXACT UTF-8 bytes /// (no trimming — byte-stability with Minister's live derivation), remove the /// variable from the environment, and return the bytes in a zeroizing buffer. -/// Same single-threaded-before-runtime requirement as [`consume_kek_env`]. -fn consume_pairwise_import_env() -> Option>> { +/// Same single-threaded-before-runtime requirement as [`consume_kek_env`], +/// and the same bounded residual (see that function's note). Public because +/// the `init-service-keys` one-shot also consumes it (and then refuses). +pub fn consume_pairwise_import_env() -> Option>> { match std::env::var("SIGNET_IMPORT_PAIRWISE_HMAC") { Ok(raw) => { std::env::remove_var("SIGNET_IMPORT_PAIRWISE_HMAC"); diff --git a/src/dedup.rs b/src/dedup.rs index 7cac1b9..8534fff 100644 --- a/src/dedup.rs +++ b/src/dedup.rs @@ -38,6 +38,44 @@ use zeroize::Zeroizing; /// cannot be replayed under a different purpose. const SERVICE_KEY_ID: i64 = 0; +/// Guard for the one-shot init mode, run BEFORE anything is minted. Minting +/// must be structurally impossible on a node that is configured to belong to +/// an existing keyspace: +/// +/// - `SIGNET_DEDUP_PUBKEY_PIN` set → the pinned seed already exists somewhere +/// by definition, so this node must restore its keystore, never initialize. +/// This closes the operator-error fork: a stray `SIGNET_INIT_SERVICE_KEYS=1` +/// left in a persistent unit env can no longer mint a fresh seed on a +/// replica that boots before its keystore restore completes. +/// - `SIGNET_IMPORT_PAIRWISE_HMAC` set → the import is consumed at ordinary +/// boot after all boot validations pass; on an init invocation it would be +/// silently ignored, so refuse loudly instead (the caller must still +/// consume/zeroize the variable before calling this). +pub fn check_init_preconditions( + pin_configured: bool, + import_configured: bool, +) -> Result<(), String> { + if pin_configured { + return Err( + "SIGNET_DEDUP_PUBKEY_PIN is set; refusing to initialize service keys. A pinned \ + node's master seed already exists elsewhere by definition (key-fork guard): \ + restore the keystore instead. Run the one deliberate first-time init without \ + the pin, then pin the printed public key" + .to_string(), + ); + } + if import_configured { + return Err( + "SIGNET_IMPORT_PAIRWISE_HMAC is set on an init invocation; refusing (the \ + variable has been consumed and removed from the environment). The pairwise \ + import is sealed on the first ORDINARY boot after validations pass — run \ + init without it, then boot once with the import variable set" + .to_string(), + ); + } + Ok(()) +} + /// One-shot service-key initialization. Mints a fresh 32-byte master seed /// from OS randomness, seals it into `service_keys`, and returns the derived /// public key `pkS` in the pin encoding (base64url, no padding). Refuses if @@ -116,6 +154,15 @@ pub fn prepare_prf_boot(db: &Db, kek: &Kek, args: PrfBootArgs<'_>) -> Result Err( @@ -375,4 +422,28 @@ mod tests { let err = boot_err(&db, &test_kek(), args(false, None, Some(b"secret"))); assert!(err.contains("not enabled"), "{err}"); } + + #[test] + fn boot_refuses_orphaned_pubkey_pin() { + // A pin with no keys and no PRF allow-list is a config slip (e.g. the + // allow-list was forgotten); refuse rather than silently mounting + // nothing while the operator believes the surface is pinned. + let db = Db::open_in_memory().unwrap(); + let err = boot_err(&db, &test_kek(), args(false, Some("some-pin"), None)); + assert!(err.contains("SIGNET_DEDUP_PUBKEY_PIN is set"), "{err}"); + } + + #[test] + fn init_refuses_on_a_pinned_or_importing_node() { + // A pinned node must NEVER mint: its seed exists elsewhere by + // definition. + let err = check_init_preconditions(true, false).unwrap_err(); + assert!(err.contains("refusing to initialize"), "{err}"); + // An init invocation carrying the pairwise import refuses loudly + // instead of silently ignoring the secret. + let err = check_init_preconditions(false, true).unwrap_err(); + assert!(err.contains("init invocation"), "{err}"); + // The deliberate first-time init (no pin, no import) proceeds. + assert!(check_init_preconditions(false, false).is_ok()); + } } diff --git a/src/main.rs b/src/main.rs index a100410..c585ff4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,8 +75,19 @@ fn is_init_mode() -> bool { /// on stdout — and ONLY `pkS`, never seed bytes — then exit. The operator /// pins the printed value as `SIGNET_DEDUP_PUBKEY_PIN` (and Minister's /// `MINISTER_SIGNET_DEDUP_PUBKEY`). +/// +/// Guarded: a node configured with a pubkey pin (its seed exists elsewhere by +/// definition) or a pairwise import must NEVER mint — see +/// [`dedup::check_init_preconditions`]. This closes the fork where a stray +/// `SIGNET_INIT_SERVICE_KEYS=1` in a persistent unit env mints a fresh seed +/// on a replica racing its keystore restore. fn run_init_service_keys() { let result = (|| -> Result { + let pin_configured = std::env::var("SIGNET_DEDUP_PUBKEY_PIN").is_ok(); + // Consume (zeroize + remove) the import variable BEFORE refusing on + // it, so the secret does not linger in the runtime environment. + let import = config::consume_pairwise_import_env(); + dedup::check_init_preconditions(pin_configured, import.is_some())?; let kek = config::consume_kek_env()?; let db_path = config::db_path_from_env()?; let db = Db::open(&db_path)?; From dd1db0d43841124736413e2153c1d7f2676384c7 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:17:24 -0400 Subject: [PATCH 11/20] feat(handlers): re-check the PRF allow-list inside require_prf PrfState.allowed_client_ids was populated but never read; the per-route gate trusted only the connection-time prf_allowed flag. Add the second layer: the pinned identity NAME must itself be on the boot-immutable allow-list, so a future classify() refactor bug cannot silently widen the PRF surface, and a SAN-smuggled grant on a cert whose pinned name came from another list is refused. Pinned by a CN sneaky-rp / DNS SAN minister cert asserting 403 on every PRF/dedup route while the genuine PRF identity still passes. --- src/handlers.rs | 27 ++++++++++++++++++++++----- src/state.rs | 8 ++++++-- tests/common/mod.rs | 24 ++++++++++++++++++++++++ tests/prf_authz.rs | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/handlers.rs b/src/handlers.rs index 5f4d076..ef4e783 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -440,11 +440,18 @@ const MAX_REASSIGN_REFS: usize = 256; /// Per-route, fail-closed PRF authorization + the PRF rate-limit bucket. /// -/// Mirrors the `is_admin()` gate on /key/rotate: the check runs INSIDE every -/// /prf/* and /dedup/* handler against the DEDICATED allow-list flag — -/// connection-level classification (including the open back-compat client -/// list) never grants PRF access. Authorization is checked before the rate -/// limit so an unauthorized caller always sees 403 and cannot consume budget. +/// Mirrors the `is_admin()` gate on /key/rotate, in TWO layers that must +/// agree: (1) the `prf_allowed` flag, pinned per connection by `classify()` +/// against the dedicated `SIGNET_PRF_CLIENT_IDS` set — connection-level +/// client classification (including the open back-compat client list) never +/// grants it; and (2) an in-handler membership re-check of the PINNED +/// identity name against the same set held on [`PrfState`] (immutable after +/// boot), so a future `classify()` refactor bug cannot silently widen the +/// PRF surface. Deliberate side effect of layer 2: an identity whose pinned +/// name came from another list (e.g. an allow-listed CN carrying a stray +/// PRF-colliding SAN) is refused — the audited name must ITSELF be the +/// PRF-authorized name. Authorization is checked before the rate limit so an +/// unauthorized caller always sees 403 and cannot consume budget. fn require_prf<'a>(state: &'a AppState, identity: &ClientIdentity) -> AppResult<&'a PrfState> { let prf = state.prf.as_ref().ok_or_else(|| { // The PRF routes are only mounted when the state exists; reaching this @@ -461,6 +468,16 @@ fn require_prf<'a>(state: &'a AppState, identity: &ClientIdentity) -> AppResult< "client identity is not authorized for the PRF surface", )); } + if !prf.allowed_client_ids.contains(&identity.name) { + tracing::warn!( + identity = %identity.name, + "rejected PRF request: pinned identity name is not on SIGNET_PRF_CLIENT_IDS \ + (second-layer allow-list check)" + ); + return Err(AppError::Forbidden( + "client identity is not authorized for the PRF surface", + )); + } match prf.rate_limiter.check(&identity.name) { KeyDecision::Allow => Ok(prf), KeyDecision::DenyIdentity | KeyDecision::DenyGlobal => Err(AppError::RateLimited), diff --git a/src/state.rs b/src/state.rs index 9d57e8f..2b6842c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -14,8 +14,12 @@ use std::sync::Arc; /// even mounted. pub struct PrfState { pub keys: PrfKeys, - /// The `SIGNET_PRF_CLIENT_IDS` allow-list, checked INSIDE each PRF/dedup - /// handler (per-route, fail-closed — mirroring the `is_admin()` gate). + /// The `SIGNET_PRF_CLIENT_IDS` allow-list, immutable after boot. Second + /// layer of the two-layer PRF gate: `classify()` pins the `prf_allowed` + /// flag per connection from this same set (the authoritative grant), and + /// `require_prf` re-checks the pinned identity NAME against this copy + /// inside every PRF/dedup handler (defense in depth — a classification + /// bug cannot silently widen the PRF surface past this membership check). pub allowed_client_ids: BTreeSet, /// The PRF surface's own rate-limit bucket (separate from /sign + /key*). pub rate_limiter: KeyRateLimiter, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7280467..d38e421 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -49,6 +49,12 @@ pub struct Pki { /// PRF client identity: CN "minister". pub prf_cert_pem: String, pub prf_key_pem: String, + /// A cert with an off-list CN but a DNS SAN colliding with the PRF + /// identity (CN "sneaky-rp", SAN "minister"): proves the second-layer + /// name check refuses a SAN-smuggled PRF grant when the pinned identity + /// name came from another list. + pub san_smuggle_cert_pem: String, + pub san_smuggle_key_pem: String, } /// CN of the default client cert. @@ -93,6 +99,16 @@ pub fn make_pki() -> Pki { let (other_cert_pem, other_key_pem) = mint_client(OTHER_CN); let (prf_cert_pem, prf_key_pem) = mint_client(PRF_CN); + // CN off every list, plus a DNS SAN that collides with the PRF identity. + let (san_smuggle_cert_pem, san_smuggle_key_pem) = { + let key = KeyPair::generate().unwrap(); + let mut cp = CertificateParams::new(vec![PRF_CN.to_string()]).unwrap(); + cp.distinguished_name.push(DnType::CommonName, "sneaky-rp"); + cp.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; + let cert = cp.signed_by(&key, &ca_cert, &ca_key).unwrap(); + (cert.pem(), key.serialize_pem()) + }; + Pki { ca_pem: ca_cert.pem(), server_cert_pem: server_cert.pem(), @@ -105,6 +121,8 @@ pub fn make_pki() -> Pki { other_key_pem, prf_cert_pem, prf_key_pem, + san_smuggle_cert_pem, + san_smuggle_key_pem, } } @@ -335,6 +353,12 @@ pub fn prf_client(pki: &Pki) -> reqwest::Client { client_with_identity(pki, &pki.prf_cert_pem, &pki.prf_key_pem) } +/// A reqwest client presenting the SAN-smuggling certificate (CN "sneaky-rp", +/// DNS SAN "minister"). +pub fn san_smuggle_client(pki: &Pki) -> reqwest::Client { + client_with_identity(pki, &pki.san_smuggle_cert_pem, &pki.san_smuggle_key_pem) +} + /// A reqwest client with NO client certificate (should be rejected by mTLS). pub fn client_without_cert(pki: &Pki) -> reqwest::Client { let ca = reqwest::Certificate::from_pem(pki.ca_pem.as_bytes()).unwrap(); diff --git a/tests/prf_authz.rs b/tests/prf_authz.rs index b2a7960..6647138 100644 --- a/tests/prf_authz.rs +++ b/tests/prf_authz.rs @@ -113,6 +113,42 @@ async fn open_client_list_still_does_not_grant_prf() { } } +#[tokio::test] +async fn san_smuggled_prf_grant_with_a_foreign_pinned_name_is_refused() { + let pki = make_pki(); + // OPEN client list: the smuggling cert (CN "sneaky-rp", DNS SAN + // "minister") is admitted as a Client and classify pins its CN as the + // identity name, while the stray SAN sets the prf_allowed flag. The + // in-handler second-layer check must refuse every PRF/dedup route: the + // PINNED (audited) name is not itself on SIGNET_PRF_CLIENT_IDS. + let server = start_server( + &pki, + ServerOpts { + allowed_client_ids: id_set(&[]), + prf: Some(PrfOpts::default()), + ..ServerOpts::default() + }, + ) + .await; + let base = base_url(&server); + let smuggler = san_smuggle_client(&pki); + for (method, path, body) in prf_routes() { + assert_eq!( + status_for(&smuggler, &base, method, path, &body).await, + 403, + "{path} must be 403 when the pinned name is not the PRF-listed one" + ); + } + // The genuine PRF identity (pinned name == PRF-listed name) still passes. + let minister = prf_client(&pki); + let res = minister + .get(format!("{base}/prf/public-key")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); +} + #[tokio::test] async fn prf_only_identity_is_refused_on_the_blind_rsa_surface() { let pki = make_pki(); From 6959dde6df4b147c1d4a01110304a363f6556033 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:18:44 -0400 Subject: [PATCH 12/20] fix(db): constant-time owner-handle comparisons on the dedup ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disclose owner check and the register/release/reassign owner classifications used short-circuit String equality. Route them through a subtle::ConstantTimeEq compare so a crypto-core authorization check does not leak match position through timing (hardening: only mTLS-pinned, PRF-allow-listed callers reach these paths and handles are 128-bit random). New direct dependency: subtle =2.6.1 — constant-time equality; already in the dependency tree via curve25519-dalek at the same version, pinned exact. --- Cargo.lock | 1 + Cargo.toml | 3 +++ src/db.rs | 20 ++++++++++++++++---- src/handlers.rs | 2 +- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e66357c..0d265ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1977,6 +1977,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "tempfile", "tokio", "tokio-rustls", diff --git a/Cargo.toml b/Cargo.toml index 79600b3..980a721 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,9 @@ sha2 = "=0.10.9" # 0.6 RNG traits, which the crate's own `rand` 0.9 (rand_core 0.9) does not # implement. rand_core = { version = "=0.6.4", features = ["getrandom"] } +# Constant-time owner-handle comparison on the dedup ledger (already in-tree +# via curve25519-dalek; same version, pinned exact). +subtle = "=2.6.1" # HTTP + TLS axum = { version = "0.8", default-features = false, features = ["json", "tokio", "http1", "query"] } diff --git a/src/db.rs b/src/db.rs index 8d51bad..cf1eaa0 100644 --- a/src/db.rs +++ b/src/db.rs @@ -88,6 +88,18 @@ pub enum DedupReassign { OwnerMismatch, } +/// Constant-time owner-handle equality for the dedup ledger's authorization +/// compares (register/release/reassign classification and the disclose owner +/// check). Handles are 128-bit random values minted by Minister and only +/// PRF-allow-listed callers reach these paths, so a remote timing oracle is +/// already impractical — but this is a crypto-core authorization compare, so +/// it does not short-circuit on content. The length check inside `ct_eq` is +/// the only data-dependent branch (handle length is not secret). +pub(crate) fn owner_eq(a: &str, b: &str) -> bool { + use subtle::ConstantTimeEq; + a.as_bytes().ct_eq(b.as_bytes()).into() +} + /// Current unix time in seconds. /// /// `SystemTime::now()` can only be before the unix epoch if the host clock is @@ -417,7 +429,7 @@ impl Db { .optional() .map_err(|e| e.to_string())?; match existing { - Some((existing_ref, existing_owner)) if existing_owner == owner_tag => { + Some((existing_ref, existing_owner)) if owner_eq(&existing_owner, owner_tag) => { Ok(DedupRegister::AlreadyYours { entry_ref: existing_ref, }) @@ -468,7 +480,7 @@ impl Db { .map_err(|e| e.to_string())?; match existing { None => Ok(DedupRelease::NotFound), - Some(owner) if owner != owner_tag => Ok(DedupRelease::OwnerMismatch), + Some(owner) if !owner_eq(&owner, owner_tag) => Ok(DedupRelease::OwnerMismatch), Some(_) => { conn.execute( "DELETE FROM dedup_entries WHERE entry_ref = ?1", @@ -504,7 +516,7 @@ impl Db { .map_err(|e| e.to_string())?; match owner { None => return Ok(DedupReassign::NotFound), // tx drops -> rollback - Some(owner) if owner == from => { + Some(owner) if owner_eq(&owner, from) => { tx.execute( "UPDATE dedup_entries SET owner_tag = ?1 WHERE entry_ref = ?2", params![to, entry_ref], @@ -512,7 +524,7 @@ impl Db { .map_err(|e| e.to_string())?; moved += 1; } - Some(owner) if owner == to => {} // already moved (retry) — no-op + Some(owner) if owner_eq(&owner, to) => {} // already moved (retry) — no-op Some(_) => return Ok(DedupReassign::OwnerMismatch), // rollback } } diff --git a/src/handlers.rs b/src/handlers.rs index ef4e783..8abdef9 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -654,7 +654,7 @@ pub async fn prf_disclose( .dedup_entry_by_ref(&entry_ref) .map_err(AppError::Internal)? .ok_or(AppError::NotFound("no such dedup entry"))?; - if entry.owner_tag != req.owner_handle { + if !db::owner_eq(&entry.owner_tag, &req.owner_handle) { tracing::warn!( identity = %identity.name, endpoint = "prf/disclose", From 0a79140aad2f0cc909f3ccea4f2b35fb09e52e9e Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:20:58 -0400 Subject: [PATCH 13/20] fix(handlers): drop per-request outcome status from dedup log lines dedup/register and dedup/release logged status=registered/already_yours/ released/already_released and a 'taken' info line, exceeding the identity+endpoint-only PRF logging contract: a log reader could see credential-collision events and timestamp-correlate them with Minister-side logs. Log one uniform outcome-free 'served' line per request (including the taken path, so absence infers nothing) and pin it in the no-log suite. --- src/handlers.rs | 18 +++++++++++------- tests/prf_logging.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/handlers.rs b/src/handlers.rs index 8abdef9..9477292 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -21,7 +21,11 @@ //! returns the blind signature. It never logs the blinded message or the //! signature. The audit log records only (group_id, participant_id, version_id). //! The PRF surface goes further: its logs record ONLY the pinned identity and -//! the endpoint — never inputs, outputs, values, handles, or refs. +//! the endpoint — never inputs, outputs, values, handles, refs, or per-request +//! outcome status (an outcome like a dedup collision is visible only to the +//! caller; logging it would make credential-collision events readable and +//! timestamp-correlatable from the log stream). Authorization refusals emit a +//! payload-free warning. //! //! ASYNC KEYGEN (audit H1): safe-prime keygen is multi-second, so key creation //! never blocks a request thread. `POST /key` and the auto-create path of @@ -726,15 +730,14 @@ pub async fn dedup_register( .db .register_dedup(&entry_ref, &value, &req.owner_handle, &req.badge_type) .map_err(AppError::Internal)?; + // Logged uniformly for every outcome (registered / already_yours / taken): + // the outcome goes only to the caller, never to the log stream. + tracing::info!(identity = %identity.name, endpoint = "dedup/register", "served"); let (status, entry_ref) = match outcome { DedupRegister::Registered { entry_ref } => ("registered", entry_ref), DedupRegister::AlreadyYours { entry_ref } => ("already_yours", entry_ref), - DedupRegister::Taken => { - tracing::info!(identity = %identity.name, endpoint = "dedup/register", "taken"); - return Err(AppError::DedupTaken); - } + DedupRegister::Taken => return Err(AppError::DedupTaken), }; - tracing::info!(identity = %identity.name, endpoint = "dedup/register", status, "served"); Ok(Json(RegisterResponse { status, entry_ref: B64URL.encode(entry_ref), @@ -771,6 +774,8 @@ pub async fn dedup_release( .db .release_dedup(&entry_ref, &req.owner_handle) .map_err(AppError::Internal)?; + // Uniform, outcome-free log line (see the module doc). + tracing::info!(identity = %identity.name, endpoint = "dedup/release", "served"); let status = match outcome { DedupRelease::Released => "released", DedupRelease::NotFound => "already_released", @@ -785,7 +790,6 @@ pub async fn dedup_release( )); } }; - tracing::info!(identity = %identity.name, endpoint = "dedup/release", status, "served"); Ok(Json(ReleaseResponse { status })) } diff --git a/tests/prf_logging.rs b/tests/prf_logging.rs index c82fbd2..34e7b31 100644 --- a/tests/prf_logging.rs +++ b/tests/prf_logging.rs @@ -96,6 +96,15 @@ async fn prf_logs_carry_identity_and_endpoint_but_never_payloads() { .unwrap() .to_string(); + // already_yours re-register: its log line must carry no outcome status. + let res = client + .post(format!("{base}/dedup/register")) + .json(&json!({ "value": value_b64, "owner_handle": owner_a, "badge_type": "email-domain" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + // Failure paths (owner mismatch + taken + authz refusal) — the warn/info // lines they emit must be payload-free too. let res = client @@ -121,6 +130,18 @@ async fn prf_logs_carry_identity_and_endpoint_but_never_payloads() { .unwrap(); assert_eq!(res.status(), 403); + // Release + idempotent retry (released / already_released outcomes) — run + // last so the entry_ref stays live for the paths above. + for _ in 0..2 { + let res = client + .post(format!("{base}/dedup/release")) + .json(&json!({ "entry_ref": entry_ref, "owner_handle": owner_a })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + } + let logs = String::from_utf8_lossy(&buf.0.lock().unwrap()).to_string(); // Sanity: the capture works and carries identity + endpoint. @@ -151,4 +172,14 @@ async fn prf_logs_carry_identity_and_endpoint_but_never_payloads() { } // Hex spellings of the dedup value must not appear either. assert!(!logs.contains(&hex::encode(value_bytes))); + + // Per-request OUTCOME status must not appear: a log reader must not be + // able to see credential-collision events ("taken") or distinguish + // registered/already_yours/released/already_released from the stream. + for outcome in ["status=", "taken", "already_yours", "already_released"] { + assert!( + !logs.contains(outcome), + "outcome marker {outcome:?} leaked into the logs" + ); + } } From 846306b57004751b37ad4a13cdbe988f104008d6 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:22:24 -0400 Subject: [PATCH 14/20] perf(handlers): run PRF/dedup ledger DB work in spawn_blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedup register/release/reassign and disclose handlers called the write-serialized SQLite connection synchronously on tokio worker threads — the one piece of runtime state shared with /sign, which deliberately isolates its DB work in spawn_blocking. Route the ledger paths through the same convention so a PRF-listed caller holding the connection mutex (e.g. 256-ref reassign transactions at the default rate ceilings) cannot add tail latency to the signing surface. --- src/handlers.rs | 47 +++++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/handlers.rs b/src/handlers.rs index 9477292..a4e444e 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -488,6 +488,21 @@ fn require_prf<'a>(state: &'a AppState, identity: &ClientIdentity) -> AppResult< } } +/// Run blocking dedup-ledger DB work off the async runtime (the `/sign` +/// convention, see [`sign`]): the single write-serialized SQLite connection +/// is shared with the blind-RSA surface, so ledger queries and transactions +/// must not occupy tokio worker threads or add tail latency to `/sign`. +async fn run_db_blocking(f: F) -> AppResult +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(f) + .await + .map_err(|e| AppError::Internal(format!("join error: {e}")))? + .map_err(AppError::Internal) +} + /// Decode a base64url-no-pad field, strictly. Failure is always a 400. fn b64url_decode(value: &str, err: &'static str) -> AppResult> { B64URL @@ -653,10 +668,9 @@ pub async fn prf_disclose( MAX_CLIENT_ID_LEN, "client_id length out of range", )?; - let entry = state - .db - .dedup_entry_by_ref(&entry_ref) - .map_err(AppError::Internal)? + let db = state.db.clone(); + let entry = run_db_blocking(move || db.dedup_entry_by_ref(&entry_ref)) + .await? .ok_or(AppError::NotFound("no such dedup entry"))?; if !db::owner_eq(&entry.owner_tag, &req.owner_handle) { tracing::warn!( @@ -726,10 +740,11 @@ pub async fn dedup_register( .try_fill_bytes(&mut entry_ref) .map_err(|e| AppError::Internal(format!("OS RNG failure: {e}")))?; - let outcome = state - .db - .register_dedup(&entry_ref, &value, &req.owner_handle, &req.badge_type) - .map_err(AppError::Internal)?; + let db = state.db.clone(); + let outcome = run_db_blocking(move || { + db.register_dedup(&entry_ref, &value, &req.owner_handle, &req.badge_type) + }) + .await?; // Logged uniformly for every outcome (registered / already_yours / taken): // the outcome goes only to the caller, never to the log stream. tracing::info!(identity = %identity.name, endpoint = "dedup/register", "served"); @@ -770,10 +785,9 @@ pub async fn dedup_release( MAX_OWNER_HANDLE_LEN, "owner_handle length out of range", )?; - let outcome = state - .db - .release_dedup(&entry_ref, &req.owner_handle) - .map_err(AppError::Internal)?; + let db = state.db.clone(); + let outcome = + run_db_blocking(move || db.release_dedup(&entry_ref, &req.owner_handle)).await?; // Uniform, outcome-free log line (see the module doc). tracing::info!(identity = %identity.name, endpoint = "dedup/release", "served"); let status = match outcome { @@ -837,10 +851,11 @@ pub async fn dedup_reassign( if req.from_owner_handle == req.to_owner_handle { return Err(AppError::BadRequest("from and to owner handles are equal")); } - let outcome = state - .db - .reassign_dedup(&refs, &req.from_owner_handle, &req.to_owner_handle) - .map_err(AppError::Internal)?; + let db = state.db.clone(); + let outcome = run_db_blocking(move || { + db.reassign_dedup(&refs, &req.from_owner_handle, &req.to_owner_handle) + }) + .await?; let moved = match outcome { DedupReassign::Reassigned { moved } => moved, DedupReassign::NotFound => { From eb13086c6e12191300bba1e0dd782309f73fb592 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:22:54 -0400 Subject: [PATCH 15/20] docs(config): state what remove_var actually protects The consume_kek_env comment claimed removal made SIGNET_KEK unreadable via /proc//environ; on Linux that file exposes the initial exec-time environment block, which remove_var cannot scrub, so the bytes stay readable there (and in a core dump's env region) for the process lifetime. Correct the claim to inheritance + in-process env reads, document the ptrace-bounded residual, and note the file/fd (read-then-shred) delivery preference for the deployment runbook. Applies equally to SIGNET_IMPORT_PAIRWISE_HMAC, which references the same note. --- src/config.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index f3378c8..9700c4b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -98,10 +98,20 @@ fn env_or(key: &str, default: T) -> Result { } /// Consume `SIGNET_KEK` from the environment: parse it, zeroize the raw copy, -/// and remove the variable so it is not readable via /proc//environ, -/// inherited by a child process, or surfaced by a crash dump walking the -/// environment block. The returned in-memory [`Kek`] is the only remaining -/// copy and is itself zeroized on drop. +/// and remove the variable so it is not inherited by child processes and not +/// readable through `std::env` for the rest of this process's lifetime. The +/// returned in-memory [`Kek`] is the intended remaining copy and is zeroized +/// on drop. +/// +/// KNOWN RESIDUAL (bounded, accepted): `remove_var` mutates only the runtime +/// environ copy. On Linux, `/proc//environ` exposes the INITIAL +/// exec-time environment block (`mm->env_start..env_end`), so the original +/// secret bytes remain readable there — and in a core dump's environment +/// region — for the process lifetime. Reading it requires ptrace-level +/// access to this process (same-UID or CAP_SYS_PTRACE), so this does not +/// weaken the mTLS/at-rest boundaries, but it is why the deployment runbook +/// should prefer file/fd secret delivery (read-then-shred) over the +/// environment for the most sensitive imports. /// /// SAFETY: `remove_var` is sound here because this is called from `main` /// BEFORE the tokio runtime is built (audit L1) — for the serve path via From 310908a3d03fb5e8ac929e57b9bb37ccad5247ce Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:23:19 -0400 Subject: [PATCH 16/20] docs(identity): record the CA issuance discipline the PRF list relies on classify() collects the leaf CN plus every DNS SAN as authorization candidates, so a client CA that signs CSR-supplied names verbatim could smuggle a PRF identity onto an RP certificate. Document that issuance must use operator-fixed CN/SAN only, that PRF identities should carry a name no RP cert would legitimately have, and exactly how far the in-handler name re-check narrows (but does not remove) that exposure. --- src/identity.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/identity.rs b/src/identity.rs index 575533a..a4f29d4 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -23,7 +23,22 @@ //! pairwise secret. Instead every identity carries a `prf_allowed` flag, //! matched ONLY against the dedicated `SIGNET_PRF_CLIENT_IDS` set, and every //! `/prf/*` / `/dedup/*` handler checks it per-route (mirroring the -//! `is_admin()` gate on `/key/rotate`) — fail-closed at both layers. +//! `is_admin()` gate on `/key/rotate`), where the pinned identity NAME is +//! additionally re-checked against the same set — fail-closed at both layers. +//! +//! CA ISSUANCE DISCIPLINE (load-bearing for the PRF allow-list): the +//! candidate names cover the leaf CN plus EVERY DNS SAN, so a certificate +//! signed from a CSR-supplied name set could smuggle a PRF identity (e.g. a +//! dNSName "minister") onto an RP certificate and silently grant it the full +//! PRF surface, including the pairwise HMAC oracle. The Signet client CA +//! must therefore issue client certificates with operator-fixed CN/SAN only +//! — never sign CSR-supplied subject names or SANs verbatim — and PRF +//! identities should use names no RP certificate would legitimately carry +//! (a dedicated prefix such as `prf-` makes a collision visually +//! impossible). The in-handler name re-check narrows the blast radius of a +//! sloppy issuance (the pinned name must itself be PRF-listed) but does not +//! remove it: a cert whose FIRST candidate is the smuggled name still pins +//! it. Issuance discipline is the actual boundary. //! //! Enforcement happens in two places: //! 1. **Connection admission** (`IdentityAcceptor`): when an allow-list is From 6b4d29190c049055fba62913380bca4d2b5e6a31 Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:24:51 -0400 Subject: [PATCH 17/20] fix(db): make the service/group key AAD domain split explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service keys seal with AAD (purpose, 0) through the same encoding group keys use for (group_id, rowid). group_id is client-chosen free text, so the only structural separator between the tables is key_id 0 vs the AUTOINCREMENT rowid >= 1 — an invariant that was implicit. Document it at both seal sites, fail closed in insert_active_key_resealed if a group key ever lands on a rowid < 1, and extend the at-rest test to scan the raw DB file plus WAL for the plaintext seed/pairwise bytes rather than only the service_keys rows. --- src/db.rs | 9 +++++++++ src/dedup.rs | 8 ++++++++ src/keystore.rs | 7 +++++++ tests/at_rest.rs | 22 ++++++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/src/db.rs b/src/db.rs index cf1eaa0..f0a94c6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -597,6 +597,15 @@ where ) .map_err(|e| e.to_string())?; let key_id = tx.last_insert_rowid(); + // AAD domain-separation invariant: group keys must never seal under + // key_id 0, which is reserved for the service_keys AAD (see + // dedup::SERVICE_KEY_ID). SQLite rowids start at 1, so this can only + // fire on a corrupted table — fail closed rather than seal ambiguously. + if key_id < 1 { + return Err(format!( + "group key rowid {key_id} violates the key_id >= 1 invariant" + )); + } let sealed = seal(key_id)?; let updated = tx .execute( diff --git a/src/dedup.rs b/src/dedup.rs index 8534fff..f41febc 100644 --- a/src/dedup.rs +++ b/src/dedup.rs @@ -36,6 +36,14 @@ use zeroize::Zeroizing; /// AAD key-id used when sealing service keys. There is exactly one row per /// purpose; the purpose string is the AAD group identity, so a sealed blob /// cannot be replayed under a different purpose. +/// +/// DOMAIN-SEPARATION INVARIANT (load-bearing): service keys always seal with +/// key-id 0, while group keys seal under their `group_keys.key_id` rowid, +/// which SQLite assigns starting at 1 (checked in +/// `db::insert_active_key_resealed`). `group_id` is client-chosen free text +/// (a group literally named "master-seed-v1" is legal), so the 0-vs-≥1 +/// key-id split — not the purpose string — is what makes a cross-table blob +/// swap fail AES-GCM authentication. const SERVICE_KEY_ID: i64 = 0; /// Guard for the one-shot init mode, run BEFORE anything is minted. Minting diff --git a/src/keystore.rs b/src/keystore.rs index a006942..76c14eb 100644 --- a/src/keystore.rs +++ b/src/keystore.rs @@ -90,6 +90,13 @@ impl Kek { } } +/// AAD encoding for a sealed blob. Group keys bind `(group_id, key_id)` with +/// the AUTOINCREMENT rowid (>= 1); service keys bind `(purpose, 0)` through +/// the same encoding. Because `group_id` is client-chosen free text, the +/// key-id domain split (0 = service keys, >= 1 = group keys) is the +/// structural separator between the two tables — see +/// `dedup::SERVICE_KEY_ID` and the invariant check in +/// `db::insert_active_key_resealed`. fn associated_data(group_id: &str, key_id: i64) -> Vec { let mut aad = Vec::with_capacity(group_id.len() + 8); aad.extend_from_slice(group_id.as_bytes()); diff --git a/tests/at_rest.rs b/tests/at_rest.rs index 82b4fe1..24faf9f 100644 --- a/tests/at_rest.rs +++ b/tests/at_rest.rs @@ -166,6 +166,28 @@ async fn service_keys_are_ciphertext_at_rest() { ); } + // Raw-file scan: the secret bytes must appear nowhere in the database + // file OR its WAL (not just in the service_keys rows the query above + // selected) — a partial page copy or another table leaking the material + // would be caught here. + let mut file_bytes = std::fs::read(&server.db_path).unwrap(); + let wal_path = server.db_path.with_extension("db-wal"); + if let Ok(mut wal_bytes) = std::fs::read(&wal_path) { + file_bytes.append(&mut wal_bytes); + } + assert!(!file_bytes.is_empty(), "expected raw DB bytes to scan"); + for (what, needle) in [ + ("master seed", &seed[..]), + ("master seed prefix", &seed[..16]), + ("pairwise secret", &pairwise[..]), + ("pairwise secret prefix", &pairwise[..16]), + ] { + assert!( + !contains(&file_bytes, needle), + "raw DB file (incl. WAL) contains the plaintext {what}" + ); + } + // Control: the check is meaningful — the seed does contain its own prefix. assert!(contains(&seed, &seed[..16])); } From 47d9c5656cdf05a1828c7ef3ffad39abb77cebdf Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:25:13 -0400 Subject: [PATCH 18/20] test(keygen): raise unit-test wait ceilings to 360s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two keygen unit tests capped wait_ready at 30s; 1024-bit safe-prime keygen has exceeded that under full-suite CPU load on slow shared runners, reddening CI on a timing flake. Match the 360s ceiling the integration suites already use — wait_ready returns as soon as the key is ready, so the ceiling only bounds the worst case. --- src/keygen.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/keygen.rs b/src/keygen.rs index c280f13..3f6f2c6 100644 --- a/src/keygen.rs +++ b/src/keygen.rs @@ -286,8 +286,11 @@ mod tests { let st = h.await.unwrap(); assert!(matches!(st, KeygenStatus::Ready | KeygenStatus::Pending)); } - // Wait for completion. - let st = svc.wait_ready("g1", Duration::from_secs(30)).await.unwrap(); + // Wait for completion. Ceiling matches the integration suites (360s): + // safe-prime keygen is high-variance and the full test suite can + // saturate a slow shared CI runner; wait_ready returns as soon as the + // key is ready, so the ceiling only bounds the worst case. + let st = svc.wait_ready("g1", Duration::from_secs(360)).await.unwrap(); assert_eq!(st, KeygenStatus::Ready); // Exactly one active key exists for the group (dedup held). assert!(db.active_key("g1").unwrap().is_some()); @@ -301,8 +304,8 @@ mod tests { // A 1ns wait should not be enough for safe-prime keygen. let quick = svc.wait_ready("g1", Duration::from_nanos(1)).await.unwrap(); assert!(matches!(quick, KeygenStatus::Pending | KeygenStatus::Ready)); - // Given enough time, it becomes ready. - let st = svc.wait_ready("g1", Duration::from_secs(30)).await.unwrap(); + // Given enough time, it becomes ready (360s ceiling — see above). + let st = svc.wait_ready("g1", Duration::from_secs(360)).await.unwrap(); assert_eq!(st, KeygenStatus::Ready); } From e284bc269e52e90c6a73104a88373a3f1bb6a53b Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:28:13 -0400 Subject: [PATCH 19/20] feat(interop): cross-language VOPRF harness and CI gate (interop/prf.mjs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cargo.toml voprf pin claimed interop proof against @cloudflare/voprf-ts via interop/prf.mjs, but the harness did not exist and CI ran only the blind-RSA check — the Phase 2 ciphersuite decision gate was unexecuted. Ship it: TS (@cloudflare/voprf-ts 1.0.0 + @noble/curves, pinned exact) blinds the frozen dedup input, Rust blind-evaluates through the exact PrfKeys::evaluate path /prf/evaluate runs (examples/prf_interop_tool), TS finalizes with the DLEQ proof verified against the pinned pkS, and the output is byte-compared to the frozen ecosystem vectors. Adversarial checks assert a tampered proof and a wrong-key evaluation are rejected; the stage-2 N_rp and pairwise golden vectors are re-derived with Node crypto. Any red check exits non-zero (verified against a corrupted vector). Wired into the CI interop job next to the RSA harness. --- .github/workflows/ci.yml | 10 +- .gitignore | 4 +- examples/prf_interop_tool.rs | 53 ++++++++++ interop/README.md | 33 +++++++ interop/package.json | 12 +++ interop/prf.mjs | 187 +++++++++++++++++++++++++++++++++++ interop/run-prf.sh | 25 +++++ 7 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 examples/prf_interop_tool.rs create mode 100644 interop/package.json create mode 100644 interop/prf.mjs create mode 100755 interop/run-prf.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5834ec..0c1011e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,5 +65,13 @@ jobs: # exact @cloudflare/blindrsa-ts FreedInk uses, and cross-version / metadata # mismatches must be rejected. Builds the interop_tool example, installs the # pinned TS lib, and runs the driver; prints INTEROP OK on success. - - name: Run interop harness + - name: Run blind-RSA interop harness run: bash interop/run.sh + # VOPRF suite decision gate (ADR Phase 2 item 6): TS (@cloudflare/voprf-ts + # + noble) blind -> Rust evaluate + DLEQ -> TS finalize (DLEQ verified + # against the pinned pkS) -> byte-compare against the frozen ecosystem + # vectors; tampered-proof and wrong-key evaluations must be rejected. + # Red here means flip the ciphersuite to P256-SHA256 before anything + # depends on values. Prints PRF INTEROP OK on success. + - name: Run PRF (VOPRF) interop harness + run: bash interop/run-prf.sh diff --git a/.gitignore b/.gitignore index f17b705..2937f9c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ *.pem *.key -# Node interop deps (installed on demand by interop/run.sh) +# Node interop deps (installed on demand by interop/run.sh + interop/run-prf.sh) /interop/node/node_modules /interop/node/package-lock.json +/interop/node_modules +/interop/package-lock.json diff --git a/examples/prf_interop_tool.rs b/examples/prf_interop_tool.rs new file mode 100644 index 0000000..de8e25e --- /dev/null +++ b/examples/prf_interop_tool.rs @@ -0,0 +1,53 @@ +//! PRF interop CLI used by `interop/prf.mjs` to prove cross-language VOPRF +//! compatibility with `@cloudflare/voprf-ts`. Not part of the service binary. +//! +//! Usage: `prf_interop_tool ` +//! +//! Loads the FROZEN TEST master seed from the vectors file (never a +//! production seed), derives the key schedule exactly as the service does +//! (`signet::prf::PrfKeys`), blind-evaluates the supplied element — the same +//! code path `/prf/evaluate` runs — and prints: +//! +//! ```text +//! pk= +//! eval= +//! proof= +//! ``` + +use signet::prf::{PrfKeys, MASTER_SEED_LEN}; + +fn main() { + let usage = "usage: prf_interop_tool "; + let vectors_path = std::env::args().nth(1).unwrap_or_else(|| { + eprintln!("{usage}"); + std::process::exit(2); + }); + let blinded_hex = std::env::args().nth(2).unwrap_or_else(|| { + eprintln!("{usage}"); + std::process::exit(2); + }); + + let raw = std::fs::read_to_string(&vectors_path).expect("cannot read the vectors file"); + let vectors: serde_json::Value = serde_json::from_str(&raw).expect("vectors file is not JSON"); + let seed_hex = vectors["master_seed_hex"] + .as_str() + .expect("vectors file lacks master_seed_hex"); + let seed_bytes = hex::decode(seed_hex).expect("master_seed_hex is not hex"); + assert_eq!( + seed_bytes.len(), + MASTER_SEED_LEN, + "frozen test seed must be {MASTER_SEED_LEN} bytes" + ); + let mut seed = [0u8; MASTER_SEED_LEN]; + seed.copy_from_slice(&seed_bytes); + + let keys = PrfKeys::from_seed(seed, None).expect("key schedule derivation failed"); + let blinded = hex::decode(blinded_hex.trim()).expect("blinded element is not hex"); + let out = keys + .evaluate(&blinded) + .expect("blinded element is not a valid group element"); + + println!("pk={}", hex::encode(keys.public_key_bytes())); + println!("eval={}", hex::encode(out.evaluation_element)); + println!("proof={}", hex::encode(out.proof)); +} diff --git a/interop/README.md b/interop/README.md index 95e7784..6cdbf92 100644 --- a/interop/README.md +++ b/interop/README.md @@ -33,3 +33,36 @@ Requirements: a Rust toolchain and Node.js. The Node verifier resolves A passing run prints `INTEROP OK` and exits 0. Any verification failure or cross-version leak exits non-zero. + +# Interop proof: Rust VOPRF ⇄ `@cloudflare/voprf-ts` (`prf.mjs`) + +The second harness is the **ciphersuite decision gate** for the Minister +nullifier surface (RFC 9497 VOPRF, mode 0x01, ristretto255-SHA512): it proves +the Rust server (`voprf` crate, the exact `PrfKeys::evaluate` path +`/prf/evaluate` runs) interoperates with the TS client Minister will use +(`@cloudflare/voprf-ts` with the `@noble/curves` CryptoProvider), byte-exact +against the frozen ecosystem vectors in `prf-vectors.json`. + +1. TS `DeriveKeyPair` independently reproduces the frozen `pkS` from the + frozen test master seed (RFC 9497 §3.2.1 key schedule). +2. TS **blinds** the frozen dedup input; Rust **blind-evaluates** it and + returns the evaluation element plus a DLEQ proof. +3. TS **finalizes**, verifying the Rust DLEQ proof against the pinned `pkS`, + and the output must equal the frozen `N_dedup` byte-for-byte. +4. Adversarial checks: a tampered Rust proof and an evaluation under a + different key must be **rejected** by the TS DLEQ verifier. +5. The stage-2 disclose `N_rp` and the pairwise golden vectors are reproduced + with Node's own HKDF/HMAC as an extra cross-language check. + +## Run + +```sh +# from the repo root +./interop/run-prf.sh +``` + +The TS deps resolve from `interop/node_modules` (install with +`npm --prefix interop install`), pinned exact. A passing run prints +`PRF INTEROP OK` and exits 0; ANY red check exits non-zero — per the build +plan, a red gate means flipping the ciphersuite to P256-SHA256 before +anything depends on persisted values. diff --git a/interop/package.json b/interop/package.json new file mode 100644 index 0000000..93a1a71 --- /dev/null +++ b/interop/package.json @@ -0,0 +1,12 @@ +{ + "name": "signet-prf-interop", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Cross-language VOPRF interop check: TS blind -> Rust evaluate -> TS finalize + DLEQ verify against the frozen PRF vectors (prf.mjs).", + "dependencies": { + "@cloudflare/voprf-ts": "1.0.0", + "@noble/curves": "2.2.0", + "@noble/hashes": "2.2.0" + } +} diff --git a/interop/prf.mjs b/interop/prf.mjs new file mode 100644 index 0000000..1cb24ef --- /dev/null +++ b/interop/prf.mjs @@ -0,0 +1,187 @@ +// Cross-language VOPRF interop proof (the ADR Phase 2 suite decision gate): +// +// TS (@cloudflare/voprf-ts + noble) blind +// -> Rust (voprf crate, via examples/prf_interop_tool — the exact +// PrfKeys::evaluate path /prf/evaluate runs) blind-evaluate + DLEQ +// -> TS finalize (verifies the Rust DLEQ proof against the pinned pkS) +// -> byte-compare against the frozen ecosystem vectors +// (interop/prf-vectors.json). +// +// Also asserts, adversarially, that a tampered Rust proof and an evaluation +// under a different key are REJECTED by the TS DLEQ verifier, and +// cross-checks the stage-2 disclose HMAC and the pairwise vectors with Node +// crypto. ANY failure exits non-zero; a red run means flip the ciphersuite +// to P256-SHA256 before anything depends on values (see the build-plan ADR). +// +// Usage: node prf.mjs + +import { + Oprf, + VOPRFClient, + VOPRFServer, + Evaluation, + deriveKeyPair, +} from '@cloudflare/voprf-ts'; +import { CryptoNoble } from '@cloudflare/voprf-ts/crypto-noble'; +import { hkdfSync, createHmac } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +const [vectorsPath, rustBin] = process.argv.slice(2); +if (!vectorsPath || !rustBin) { + console.error('usage: node prf.mjs '); + process.exit(2); +} + +Oprf.Crypto = CryptoNoble; +const suite = Oprf.Suite.RISTRETTO255_SHA512; +const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')); + +let failures = 0; +const check = (name, ok, detail = '') => { + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`); + if (!ok) failures += 1; +}; + +const hex = (u8) => Buffer.from(u8).toString('hex'); +const b64url = (u8) => Buffer.from(u8).toString('base64url'); +// LP(x): 2-byte big-endian length prefix — must mirror src/prf.rs `lp`. +const lp = (x) => { + const b = Buffer.from(x); + const len = Buffer.alloc(2); + len.writeUInt16BE(b.length); + return Buffer.concat([len, b]); +}; + +// Key schedule per the ADR / src/prf.rs: +// seed_null = HKDF-SHA512(ikm=master_seed, salt="", info="minister/v1/nullifier", 32) +// (skS, pkS) = DeriveKeyPair(seed_null, "minister/v1/nullifier/dedup") [RFC 9497 §3.2.1] +const masterSeed = Buffer.from(vectors.master_seed_hex, 'hex'); +const seedNull = Buffer.from( + hkdfSync('sha512', masterSeed, Buffer.alloc(0), 'minister/v1/nullifier', 32), +); + +// 1. TS DeriveKeyPair must independently reproduce the frozen pkS. +const keyPair = await deriveKeyPair( + Oprf.Mode.VOPRF, + suite, + seedNull, + Buffer.from('minister/v1/nullifier/dedup'), +); +check( + 'TS DeriveKeyPair reproduces the frozen pkS', + b64url(keyPair.publicKey) === vectors.public_key_b64url, + b64url(keyPair.publicKey), +); + +// 2. TS blind -> Rust evaluate -> TS finalize (DLEQ verified vs pinned pkS). +const input = Buffer.from(vectors.dedup.input_hex, 'hex'); +const client = new VOPRFClient(suite, keyPair.publicKey); +const [finData, evalReq] = await client.blind([input]); +const rustOut = execFileSync(rustBin, [ + vectorsPath, + hex(evalReq.blinded[0].serialize()), +]).toString(); +const rustField = (k) => { + const m = rustOut.match(new RegExp(`${k}=([0-9a-f]+)`)); + if (!m) throw new Error(`Rust tool output lacks ${k}=`); + return Buffer.from(m[1], 'hex'); +}; +check( + 'Rust-derived pkS matches the frozen pkS', + b64url(rustField('pk')) === vectors.public_key_b64url, + b64url(rustField('pk')), +); + +// voprf-ts Evaluation wire: u16 element count || element || mode byte || proof. +const evalWire = (proofBytes) => + Buffer.concat([ + Buffer.from([0, 1]), + rustField('eval'), + Buffer.from([Oprf.Mode.VOPRF]), + proofBytes, + ]); + +let nDedup = null; +try { + [nDedup] = await client.finalize( + finData, + Evaluation.deserialize(suite, evalWire(rustField('proof')), Oprf.Crypto), + ); + check( + 'TS finalize of the Rust evaluation yields the frozen N_dedup', + hex(nDedup) === vectors.dedup.n_dedup_hex, + hex(nDedup), + ); +} catch (e) { + check('TS finalize (incl. DLEQ verify) of the Rust evaluation', false, e.message); +} + +// 3. A tampered Rust DLEQ proof must be REJECTED by the TS verifier. +const tampered = Buffer.from(rustField('proof')); +tampered[5] ^= 0x01; +try { + await client.finalize( + finData, + Evaluation.deserialize(suite, evalWire(tampered), Oprf.Crypto), + ); + check('tampered Rust proof rejected by TS DLEQ verify', false, 'ACCEPTED'); +} catch { + check('tampered Rust proof rejected by TS DLEQ verify', true); +} + +// 4. An evaluation under a DIFFERENT key must fail DLEQ against the pinned pkS. +const otherKeyPair = await deriveKeyPair( + Oprf.Mode.VOPRF, + suite, + Buffer.alloc(32, 0x11), + Buffer.from('minister/v1/nullifier/dedup'), +); +const otherServer = new VOPRFServer(suite, otherKeyPair.privateKey); +const wrongEval = await otherServer.blindEvaluate(evalReq); +try { + await client.finalize(finData, wrongEval); + check('wrong-key evaluation rejected by TS DLEQ verify', false, 'ACCEPTED'); +} catch { + check('wrong-key evaluation rejected by TS DLEQ verify', true); +} + +// 5. Stage-2 disclose reproduced in Node crypto over the TS-finalized N_dedup: +// k_disc = HKDF-SHA512(master_seed, "", "minister/v1/nullifier/disclose" || LP(clientId), 32) +// N_rp = "mnv1:" + b64url(HMAC-SHA256(k_disc, LP("minister/null/v1")||LP("rp")||LP(N_dedup)||LP(clientId))) +if (nDedup) { + const clientId = vectors.disclose.client_id; + const kDisc = Buffer.from( + hkdfSync( + 'sha512', + masterSeed, + Buffer.alloc(0), + Buffer.concat([Buffer.from('minister/v1/nullifier/disclose'), lp(clientId)]), + 32, + ), + ); + const msg = Buffer.concat([ + lp('minister/null/v1'), + lp('rp'), + lp(Buffer.from(nDedup)), + lp(clientId), + ]); + const nRp = `mnv1:${createHmac('sha256', kDisc).update(msg).digest('base64url')}`; + check('Node-derived stage-2 N_rp matches the frozen vector', nRp === vectors.disclose.n_rp, nRp); +} + +// 6. Pairwise golden vectors reproduced with Node's createHmac (the exact +// construction Minister's live path uses). +const pairwiseSecret = Buffer.from(vectors.pairwise.secret_utf8, 'utf8'); +for (const v of vectors.pairwise.vectors) { + const out = createHmac('sha256', pairwiseSecret) + .update(Buffer.from(v.input, 'utf8')) + .digest('base64url'); + check(`pairwise vector ${JSON.stringify(v.input)}`, out === v.output, out); +} + +if (failures > 0) { + console.error(`\nPRF INTEROP FAILED: ${failures} check(s) red`); + process.exit(1); +} +console.log('\nPRF INTEROP OK'); diff --git a/interop/run-prf.sh b/interop/run-prf.sh new file mode 100755 index 0000000..da60184 --- /dev/null +++ b/interop/run-prf.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Build the Rust PRF interop tool and run the cross-language VOPRF check +# against the real @cloudflare/voprf-ts library (the ADR Phase 2 suite +# decision gate). Exits 0 and prints PRF INTEROP OK on success. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INTEROP_DIR="$ROOT/interop" + +echo "[1/3] building prf_interop_tool (release)..." +cargo build --release --example prf_interop_tool --manifest-path "$ROOT/Cargo.toml" >/dev/null + +BIN="$ROOT/target/release/examples/prf_interop_tool" +if [[ ! -x "$BIN" ]]; then + echo "prf_interop_tool binary not found at $BIN" >&2 + exit 1 +fi + +echo "[2/3] installing TS deps (pinned @cloudflare/voprf-ts)..." +if [[ ! -d "$INTEROP_DIR/node_modules/@cloudflare/voprf-ts" ]]; then + ( cd "$INTEROP_DIR" && npm install --silent --no-audit --no-fund ) +fi + +echo "[3/3] running PRF interop driver..." +node "$INTEROP_DIR/prf.mjs" "$INTEROP_DIR/prf-vectors.json" "$BIN" From aef79324badd0eb16b0b3734cf2dd1658ceff22c Mon Sep 17 00:00:00 2001 From: AtHeartEngineer <1675654+AtHeartEngineer@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:28:49 -0400 Subject: [PATCH 20/20] style: rustfmt --- src/db.rs | 4 +++- src/dedup.rs | 6 ++---- src/handlers.rs | 3 +-- src/keygen.rs | 10 ++++++++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/db.rs b/src/db.rs index f0a94c6..bdddc05 100644 --- a/src/db.rs +++ b/src/db.rs @@ -429,7 +429,9 @@ impl Db { .optional() .map_err(|e| e.to_string())?; match existing { - Some((existing_ref, existing_owner)) if owner_eq(&existing_owner, owner_tag) => { + Some((existing_ref, existing_owner)) + if owner_eq(&existing_owner, owner_tag) => + { Ok(DedupRegister::AlreadyYours { entry_ref: existing_ref, }) diff --git a/src/dedup.rs b/src/dedup.rs index f41febc..e6980d0 100644 --- a/src/dedup.rs +++ b/src/dedup.rs @@ -232,10 +232,8 @@ pub fn prepare_prf_boot(db: &Db, kek: &Kek, args: PrfBootArgs<'_>) -> Result