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
+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,