diff --git a/.env.example b/.env.example index b82771c..84862fe 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,13 @@ ALTCHA_HMAC_KEY= POW_REQUIRED=false ALTCHA_COST=100000 +# ── Registration gates (composable; each advertised via /capabilities) ──── +# Require an operator-minted invite code to create a NEW wallet. Mint codes with +# `smirk-admin mint-invite --count `. Returning wallets bypass it, and +# self-hosting bypasses all gates (run your own backend). PoW is the ALTCHA gate +# above; pay-to-register is planned. Compose freely (e.g. invite + PoW). +REGISTRATION_REQUIRE_INVITE=false + # ── Bitcoin ─────────────────────────────────────────────────────────────── BTC_NETWORK=mainnet BTC_USE_LOCAL_NODE=false diff --git a/migrations/20260630000001_invite_codes.sql b/migrations/20260630000001_invite_codes.sql new file mode 100644 index 0000000..8399f42 --- /dev/null +++ b/migrations/20260630000001_invite_codes.sql @@ -0,0 +1,20 @@ +-- Operator-minted registration invite codes — one composable registration gate +-- (alongside PoW and, later, pay-to-register). v1 is single-use, operator-minted +-- via the `smirk-admin mint-invite` CLI; the schema leaves room for multi-use, +-- expiry, and referral without a breaking change. +-- +-- Only sha256(code) is stored. The raw code is a bearer secret shown once at +-- mint time and never persisted, so a database leak is NOT a list of usable +-- codes — codes are >=128-bit random, so the hash is not brute-forceable and no +-- pepper is needed. +CREATE TABLE invite_codes ( + code_hash TEXT PRIMARY KEY, -- hex sha256 of the raw code + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + label TEXT, -- optional operator note (batch / who-for) + expires_at TIMESTAMPTZ, -- NULL = never (reserved; v1 mints without expiry) + used_at TIMESTAMPTZ, -- NULL = unused (single-use) + used_by UUID REFERENCES users (id) ON DELETE SET NULL +); + +-- Fast lookup of still-spendable codes (mint listing / future sweeps). +CREATE INDEX idx_invite_codes_unused ON invite_codes (created_at) WHERE used_at IS NULL; diff --git a/openapi.json b/openapi.json index 47ca51a..f4b45e5 100644 --- a/openapi.json +++ b/openapi.json @@ -1892,7 +1892,8 @@ "contract_version", "chains", "features", - "restore" + "restore", + "registration" ], "properties": { "chains": { @@ -1907,6 +1908,10 @@ "features": { "$ref": "#/components/schemas/FeatureCapabilities" }, + "registration": { + "$ref": "#/components/schemas/RegistrationCapability", + "description": "Registration gates for a new wallet on this instance." + }, "restore": { "$ref": "#/components/schemas/RestoreCapability", "description": "Wallet restore (import) policy for this instance." @@ -2099,6 +2104,13 @@ "type": "object", "description": "Optional proof-of-work solution. Required when the PoW gate applies to\nthis pubkey (see [`pow_applies`]); otherwise ignored." }, + "invite_code": { + "type": [ + "string", + "null" + ], + "description": "Operator-minted invite code. Required when the instance enables the invite\nregistration gate (see `/capabilities` → `registration.invite_required`);\nignored otherwise and for returning wallets." + }, "keys": { "type": "array", "items": { @@ -2912,6 +2924,24 @@ } } }, + "RegistrationCapability": { + "type": "object", + "description": "Registration gates this instance enforces for a NEW wallet (returning wallets\nand self-hosting bypass them). The wallet uses these to shape onboarding —\nprompt for an invite code, solve PoW, etc.", + "required": [ + "invite_required", + "pow_required" + ], + "properties": { + "invite_required": { + "type": "boolean", + "description": "A valid operator-minted invite code is required to register." + }, + "pow_required": { + "type": "boolean", + "description": "A proof-of-work solution is required to register." + } + } + }, "RelayEntry": { "type": "object", "description": "A relay entry as returned to an authorized party.", diff --git a/src/api/auth.rs b/src/api/auth.rs index 5ae3f8c..83be6ac 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -303,6 +303,11 @@ pub struct ExtensionRegisterRequest { #[serde(default)] #[schema(value_type = Object)] pub altcha_solution: Option, + /// Operator-minted invite code. Required when the instance enables the invite + /// registration gate (see `/capabilities` → `registration.invite_required`); + /// ignored otherwise and for returning wallets. + #[serde(default)] + pub invite_code: Option, } /// Register a new extension wallet or authenticate an existing one. @@ -460,6 +465,7 @@ pub async fn extension_register( returning_by_pubkey, req.altcha_solution.as_ref(), )?; + enforce_invite(&state, returning_by_pubkey, req.invite_code.as_deref()).await?; // If we reached here after an unproven fingerprint match, do NOT attach that // fingerprint to the new row — it belongs to another user, and the UNIQUE @@ -579,6 +585,48 @@ fn enforce_pow( Ok(()) } +/// Invite-code registration gate. A genuinely new wallet must present a valid, +/// unused operator-minted invite code when the instance requires one; a +/// returning wallet (known pubkey) bypasses it, exactly like PoW, and an +/// instance with the gate off accepts everyone. The claim is atomic and +/// single-use (db layer), so a code is redeemable by at most one registration +/// even under concurrent submission. Self-hosting bypasses this entirely +/// (operators simply leave the gate off). +async fn enforce_invite( + state: &AppState, + returning: bool, + invite_code: Option<&str>, +) -> Result<(), AppError> { + if returning || !state.config.registration.require_invite { + return Ok(()); + } + let code = invite_code + .map(str::trim) + .filter(|c| !c.is_empty()) + .ok_or_else(|| { + warn!( + gate = "invite", + "invite required for new wallet but none supplied" + ); + AppError::ValidationError( + "An invite code is required to register on this instance.".into(), + ) + })?; + if !state + .db + .claim_invite_code(&crate::core::invite::hash_invite_code(code)) + .await? + { + // One literal for "no such code", "already used", and "expired" — never + // an oracle that distinguishes them. + return Err(AppError::ValidationError( + "Invalid or already-used invite code.".into(), + )); + } + info!(gate = "invite", "invite code accepted (new user)"); + Ok(()) +} + /// SHA-256 hex of a public key string — the wallet's stable identity handle. fn hash_public_key(public_key: &str) -> String { use sha2::{Digest, Sha256}; diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index be4ccf0..dae4b1a 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -60,6 +60,17 @@ pub struct RestoreCapability { pub max_depth_days: Option, } +/// Registration gates this instance enforces for a NEW wallet (returning wallets +/// and self-hosting bypass them). The wallet uses these to shape onboarding — +/// prompt for an invite code, solve PoW, etc. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct RegistrationCapability { + /// A valid operator-minted invite code is required to register. + pub invite_required: bool, + /// A proof-of-work solution is required to register. + pub pow_required: bool, +} + #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct CapabilitiesResponse { /// Backend version (Cargo package version). @@ -70,6 +81,8 @@ pub struct CapabilitiesResponse { pub features: FeatureCapabilities, /// Wallet restore (import) policy for this instance. pub restore: RestoreCapability, + /// Registration gates for a new wallet on this instance. + pub registration: RegistrationCapability, } /// Whether an enabled chain can actually be served (its infra secret/URL is @@ -135,6 +148,10 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse { _ => None, }, }, + registration: RegistrationCapability { + invite_required: config.registration.require_invite, + pow_required: config.pow.enabled && config.pow.required, + }, } } diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 294919c..7a35c7c 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -68,6 +68,7 @@ use utoipa::OpenApi; crate::api::capabilities::ChainCapability, crate::api::capabilities::FeatureCapabilities, crate::api::capabilities::RestoreCapability, + crate::api::capabilities::RegistrationCapability, crate::api::prices::PricesResponse, crate::api::auth::AuthResponse, crate::api::auth::UserInfo, diff --git a/src/bin/smirk-admin.rs b/src/bin/smirk-admin.rs index 1ec439b..c95b870 100644 --- a/src/bin/smirk-admin.rs +++ b/src/bin/smirk-admin.rs @@ -26,6 +26,7 @@ use rand::RngCore; use uuid::Uuid; use zeroize::Zeroize; +use smirk_backend_core::core::invite::{generate_invite_code, hash_invite_code}; use smirk_backend_core::infra::db::{AddKeyOutcome, Database, RevokeKeyOutcome}; use smirk_backend_core::models::db::{NewAdminAudit, NewAdminKey}; @@ -43,6 +44,7 @@ COMMANDS: revoke-key --id replace-key --old --pubkey <64hex> [--revoke-all] create-admin-wallet --out + mint-invite [--count ] [--label ] (registration invite codes) doctor "; @@ -93,6 +95,7 @@ async fn run(args: &[String]) -> Result<(), String> { "revoke-key" => revoke_key(&db, &secret, rest).await, "replace-key" => replace_key(&db, &secret, rest).await, "create-admin-wallet" => create_admin_wallet(&db, &secret, rest).await, + "mint-invite" => mint_invite(&db, rest).await, "doctor" => doctor(&db, &secret).await, "-h" | "--help" | "help" => { print!("{USAGE}"); @@ -367,6 +370,36 @@ async fn create_admin_wallet(db: &Database, secret: &str, args: &[String]) -> Re Ok(()) } +/// Mint single-use registration invite codes. Generates random 128-bit codes, +/// stores only their sha256 hashes, and prints the RAW codes to stdout once for +/// distribution — they are not recoverable afterward (only the hash is kept). +async fn mint_invite(db: &Database, args: &[String]) -> Result<(), String> { + let count: u32 = match flag(args, "--count") { + Some(s) => s + .parse() + .map_err(|_| "--count must be a number".to_string())?, + None => 1, + } + .clamp(1, 1000); + let label = flag(args, "--label"); + eprintln!( + "Minting {count} single-use invite code(s){}:", + label + .as_deref() + .map(|l| format!(" [{l}]")) + .unwrap_or_default() + ); + for _ in 0..count { + let code = generate_invite_code(); + db.insert_invite_code(&hash_invite_code(&code), label.as_deref()) + .await + .map_err(|e| format!("insert invite: {e}"))?; + println!("{code}"); + } + eprintln!("\nDistribute now — only the hash is stored; raw codes are not recoverable."); + Ok(()) +} + async fn doctor(db: &Database, secret: &str) -> Result<(), String> { println!("database: {}", health(db).await); let live = db diff --git a/src/config.rs b/src/config.rs index 0f8ff33..6b37552 100644 --- a/src/config.rs +++ b/src/config.rs @@ -129,6 +129,7 @@ pub struct Config { pub landing: LandingConfig, pub retention: RetentionConfig, pub restore: RestoreConfig, + pub registration: RegistrationConfig, } #[derive(Clone)] @@ -392,6 +393,16 @@ impl RestoreConfig { } } +/// Registration gates beyond PoW — composable, operator-configured, advertised +/// via `/capabilities`. Each is a gate the wallet must satisfy to create a NEW +/// identity; returning wallets bypass them, and self-hosting bypasses all of +/// them (run your own backend). PoW lives in [`PowConfig`]; this holds the rest. +#[derive(Clone, Copy)] +pub struct RegistrationConfig { + /// Require a valid operator-minted invite code to register a new wallet. + pub require_invite: bool, +} + impl Config { /// Load configuration from the environment and validate it (fail-closed). pub fn from_env() -> Result { @@ -560,6 +571,9 @@ impl Config { max_depth_days: env_parse("WALLET_MAX_RESTORE_DEPTH_DAYS", 365u32)?, } }, + registration: RegistrationConfig { + require_invite: env_bool("REGISTRATION_REQUIRE_INVITE", false), + }, }; cfg.validate()?; @@ -857,6 +871,9 @@ mod tests { policy: RestorePolicy::Bounded, max_depth_days: 365, }, + registration: RegistrationConfig { + require_invite: false, + }, } } diff --git a/src/core/invite.rs b/src/core/invite.rs new file mode 100644 index 0000000..cfe3f96 --- /dev/null +++ b/src/core/invite.rs @@ -0,0 +1,46 @@ +//! Registration invite codes: generation + hashing. +//! +//! A code is 16 random bytes (128 bits) rendered as base58 for legible operator +//! distribution. Only `sha256(code)` is ever stored (see [`crate::infra::db`]); +//! the raw code is shown once, at mint time. 128 bits of entropy makes the +//! stored hash non-brute-forceable, so no pepper is needed (unlike user-chosen +//! secrets). Single-use is enforced by an atomic claim in the db layer. + +use rand::RngCore; +use sha2::{Digest, Sha256}; + +/// Generate a fresh random invite code (128-bit, base58). +pub fn generate_invite_code() -> String { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + bs58::encode(bytes).into_string() +} + +/// Hex `sha256` of a raw invite code — the stored form and the lookup key. +pub fn hash_invite_code(code: &str) -> String { + hex::encode(Sha256::digest(code.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_is_deterministic_and_hex64() { + let h = hash_invite_code("abc"); + assert_eq!(h, hash_invite_code("abc")); + assert_eq!(h.len(), 64); + assert!(h.bytes().all(|b| b.is_ascii_hexdigit())); + assert_ne!(h, hash_invite_code("abd")); + } + + #[test] + fn generated_codes_are_unique_and_nonempty() { + let a = generate_invite_code(); + let b = generate_invite_code(); + assert!(!a.is_empty()); + assert_ne!(a, b); + // base58 of 16 bytes is ~22 chars; never trivially short. + assert!(a.len() >= 16); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 23df108..ce27ce0 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -5,6 +5,7 @@ pub mod admin_session; pub mod crypto; +pub mod invite; pub mod pow; pub mod secret; pub mod session; diff --git a/src/infra/db/invite_codes.rs b/src/infra/db/invite_codes.rs new file mode 100644 index 0000000..28a87dd --- /dev/null +++ b/src/infra/db/invite_codes.rs @@ -0,0 +1,60 @@ +//! Registration invite-code queries — one of the composable registration gates. +//! +//! Single-use, operator-minted. Only `sha256(code)` is stored (the raw code is a +//! bearer secret, shown once at mint time). Redemption is an atomic +//! single-statement claim (`UPDATE ... WHERE used_at IS NULL ... RETURNING`) so +//! two concurrent registrations presenting the same code can never both succeed. + +use tracing::instrument; + +use crate::error::AppError; + +use super::Database; + +impl Database { + /// Store a freshly-minted code by its `sha256` hash with an optional operator + /// `label`. A duplicate hash (re-mint of the same raw code — astronomically + /// unlikely for a 128-bit code) surfaces as a unique-violation error. + #[instrument(skip(self, code_hash))] + pub async fn insert_invite_code( + &self, + code_hash: &str, + label: Option<&str>, + ) -> Result<(), AppError> { + sqlx::query("INSERT INTO invite_codes (code_hash, label) VALUES ($1, $2)") + .bind(code_hash) + .bind(label) + .execute(self.pool()) + .await?; + Ok(()) + } + + /// Atomically redeem a single-use code by its hash. Returns `true` iff a + /// still-unused, unexpired code was consumed by THIS call. The + /// `WHERE used_at IS NULL` predicate and `RETURNING` make check-and-consume a + /// single statement, so concurrent redemptions race safely — exactly one wins. + #[instrument(skip(self, code_hash))] + pub async fn claim_invite_code(&self, code_hash: &str) -> Result { + let claimed = sqlx::query_scalar::<_, String>( + "UPDATE invite_codes SET used_at = NOW() \ + WHERE code_hash = $1 AND used_at IS NULL \ + AND (expires_at IS NULL OR expires_at > NOW()) \ + RETURNING code_hash", + ) + .bind(code_hash) + .fetch_optional(self.pool()) + .await?; + Ok(claimed.is_some()) + } + + /// Count of still-spendable (unused, unexpired) codes — for `smirk-admin doctor`. + #[instrument(skip(self))] + pub async fn unused_invite_code_count(&self) -> Result { + Ok(sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM invite_codes \ + WHERE used_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())", + ) + .fetch_one(self.pool()) + .await?) + } +} diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index 1c1a656..13bf27a 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -13,6 +13,7 @@ mod audit; mod challenges; mod erasure; mod grin_slatepacks; +mod invite_codes; mod login_events; mod restore_attempts; mod server_config; diff --git a/tests/invite_codes.rs b/tests/invite_codes.rs new file mode 100644 index 0000000..c58720e --- /dev/null +++ b/tests/invite_codes.rs @@ -0,0 +1,89 @@ +//! L1: invite-code registration gate. +//! +//! The security-critical invariant is **atomic single-use**: a code redeems at +//! most ONE registration, even under concurrent submission (otherwise one invite +//! grants unbounded Sybil registrations). The redemption is a single-statement +//! `UPDATE ... WHERE used_at IS NULL ... RETURNING`, so the database enforces it; +//! these tests lock that in (incl. a concurrency race). + +mod common; + +use std::sync::Arc; + +use smirk_backend_core::core::invite::{generate_invite_code, hash_invite_code}; + +#[tokio::test] +async fn invite_code_is_single_use() { + let app = require_app!(); + let hash = hash_invite_code(&generate_invite_code()); + app.state + .db + .insert_invite_code(&hash, Some("test-batch")) + .await + .unwrap(); + + assert!( + app.state.db.claim_invite_code(&hash).await.unwrap(), + "first redemption must succeed" + ); + assert!( + !app.state.db.claim_invite_code(&hash).await.unwrap(), + "a reused code must be rejected" + ); +} + +#[tokio::test] +async fn unknown_invite_code_is_rejected() { + let app = require_app!(); + let unknown = hash_invite_code(&generate_invite_code()); + assert!( + !app.state.db.claim_invite_code(&unknown).await.unwrap(), + "a never-minted code must be rejected" + ); +} + +#[tokio::test] +async fn concurrent_claims_redeem_at_most_once() { + let app = require_app!(); + let hash = hash_invite_code(&generate_invite_code()); + app.state.db.insert_invite_code(&hash, None).await.unwrap(); + + // Fire many concurrent redemptions of the SAME code; exactly one may win. + let mut handles = Vec::new(); + for _ in 0..8 { + let state = Arc::clone(&app.state); + let h = hash.clone(); + handles.push(tokio::spawn(async move { + state.db.claim_invite_code(&h).await.unwrap() + })); + } + let mut wins = 0; + for handle in handles { + if handle.await.unwrap() { + wins += 1; + } + } + assert_eq!( + wins, 1, + "exactly one concurrent claim may redeem a single-use code" + ); +} + +#[tokio::test] +async fn unused_count_is_queryable_and_nonnegative() { + // The unused count is a global doctor metric, so under parallel tests we + // can't assert exact deltas (siblings mint/claim concurrently). We assert it + // is queryable and reflects at least our own still-unused code; the "claimed + // codes are excluded" property is the same `used_at IS NULL` predicate the + // single-use test already locks in. + let app = require_app!(); + app.state + .db + .insert_invite_code(&hash_invite_code(&generate_invite_code()), None) + .await + .unwrap(); + assert!( + app.state.db.unused_invite_code_count().await.unwrap() >= 1, + "count must include the just-minted unused code" + ); +}