feat(registration): composable gate framework + invite codes (gate #1)

First of the operator-configurable registration gates (PoW-priced restore and
pay-to-register follow). A NEW wallet must present a valid invite code when
REGISTRATION_REQUIRE_INVITE is on; returning wallets and self-hosting bypass it,
and the active gates are advertised via /capabilities so the wallet shapes
onboarding.

- migration + db: invite_codes table storing only sha256(code) (128-bit codes,
  no pepper needed). Redemption is an atomic single-statement claim
  (UPDATE ... WHERE used_at IS NULL RETURNING) — single-use even under concurrent
  submission (L1 test fires 8 concurrent claims; exactly one wins).
- core::invite: generate (base58, 128-bit) + sha256 hash.
- enforce_invite in extension_register, mirroring enforce_pow (returning bypass;
  ONE literal error for not-found/used/expired — never an oracle).
- RegistrationConfig.require_invite + RegistrationCapability in /capabilities.
- smirk-admin mint-invite --count/--label (prints raw codes once; stores hashes).
- L1 tests (single-use + concurrency race), .env.example, OpenAPI (drift clean).
This commit is contained in:
jwinterm
2026-06-30 17:14:53 -04:00
parent 1e08119c5e
commit 610819bb5d
13 changed files with 371 additions and 1 deletions
+7
View File
@@ -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 <n>`. 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
@@ -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;
+31 -1
View File
@@ -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.",
+48
View File
@@ -303,6 +303,11 @@ pub struct ExtensionRegisterRequest {
#[serde(default)]
#[schema(value_type = Object)]
pub altcha_solution: Option<altcha::Payload>,
/// 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<String>,
}
/// 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};
+17
View File
@@ -60,6 +60,17 @@ pub struct RestoreCapability {
pub max_depth_days: Option<u32>,
}
/// 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,
},
}
}
+1
View File
@@ -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,
+33
View File
@@ -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 <uuid>
replace-key --old <uuid> --pubkey <64hex> [--revoke-all]
create-admin-wallet --out <path>
mint-invite [--count <n>] [--label <s>] (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
+17
View File
@@ -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<Self, AppError> {
@@ -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,
},
}
}
+46
View File
@@ -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);
}
}
+1
View File
@@ -5,6 +5,7 @@
pub mod admin_session;
pub mod crypto;
pub mod invite;
pub mod pow;
pub mod secret;
pub mod session;
+60
View File
@@ -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<bool, AppError> {
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<i64, AppError> {
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?)
}
}
+1
View File
@@ -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;
+89
View File
@@ -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"
);
}