diff --git a/.env.example b/.env.example index a806fd2..eb2db48 100644 --- a/.env.example +++ b/.env.example @@ -347,8 +347,15 @@ PUBLIC_EXPOSE_VERSION=false PUBLIC_EXPOSE_CHAINS=false PUBLIC_EXPOSE_PRICE_FEED=false PUBLIC_EXPOSE_UPTIME=false +# Aggregate public stats on `GET /api/v1/server-info` (requires the landing +# surface above). Currently one number: how many users hold a handle on this +# instance, rounded down to a coarse bucket and held still for CACHE_HOURS so +# repeated polling cannot difference out individual registrations. PUBLIC_STATS_ENABLED=false PUBLIC_STATS_CACHE_HOURS=24 +# k-anonymity floor. Below this many handles the block says only that some +# exist, never how many. +PUBLIC_STATS_MIN_USERS=20 # ── Data retention / erasure ────────────────────────────────────────────── # When ERASURE_ENABLED=true, self-service erasure needs ADMIN_KEY_INTEGRITY_SECRET diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cadc26..21f2902 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,10 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo test --locked --test auth_flow --test db_queries + # landing is here, not only in the DB-less job, because the public stats + # block reads the database: without a DB those tests self-skip and the + # "no identifier on the public surface" assertion never actually runs. + - run: cargo test --locked --test auth_flow --test db_queries --test landing supply-chain: name: cargo-deny diff --git a/src/api/landing.rs b/src/api/landing.rs index 22c3c85..d02491d 100644 --- a/src/api/landing.rs +++ b/src/api/landing.rs @@ -8,6 +8,7 @@ //! NEVER emitted anonymously here (connected clients use `/capabilities`). use std::sync::Arc; +use std::time::{Duration, Instant}; use axum::{ extract::State, @@ -17,6 +18,7 @@ use axum::{ Json, Router, }; use serde::Serialize; +use tokio::sync::RwLock; use crate::config::Config; use crate::AppState; @@ -33,6 +35,27 @@ pub struct PriceFeedInfo { pub enabled: bool, } +/// Aggregate public stats. Every field is a bool or a count and NOTHING here is +/// a string, so no handle, npub or row id can reach this unauthenticated surface +/// even by a careless later edit. The NIP-05 handler next door deliberately +/// answers identically for known and unknown names; a stats field that named or +/// narrowed to a person would be the enumeration oracle that endpoint refuses to +/// be. +#[derive(Debug, Clone, Serialize)] +pub struct StatsInfo { + /// Whether ANY handle has been claimed on this instance. Below the + /// k-anonymity floor this is the only thing the block says. + pub any_handles: bool, + /// Claimed handles, rounded DOWN to a coarse bucket: the real count is at or + /// above this. Omitted entirely below `PUBLIC_STATS_MIN_USERS`. + #[serde(skip_serializing_if = "Option::is_none")] + pub handles_at_least: Option, +} + +/// Last computed stats block and when it was computed. See [`MIN_STATS_CACHE`] +/// for why the value is held still rather than recomputed per request. +pub type StatsCache = Arc>>; + /// The public projection. Optional fields are omitted unless explicitly opted in, /// so the default JSON is exactly `{ "software": { "name": ... }, "status": ... }`. #[derive(Debug, Serialize)] @@ -48,6 +71,10 @@ pub struct ServerInfo { /// Boolean `up` only (never an uptime duration — reboot timing is recon). #[serde(skip_serializing_if = "Option::is_none")] pub up: Option, + /// Aggregate stats; present only when `PUBLIC_STATS_ENABLED` is on. Filled by + /// the handler, not by [`build_server_info`], because it needs a database read. + #[serde(skip_serializing_if = "Option::is_none")] + pub stats: Option, } /// Coarse `major.minor` (patch is recon-only). @@ -85,17 +112,90 @@ pub fn build_server_info(config: &Config) -> ServerInfo { enabled: config.features.prices, }), up: l.expose_uptime.then_some(true), + stats: None, } } +/// Floor the stats block is held at, whatever the operator sets. A live counter +/// answers "did someone register in the last minute?" by subtraction, which is +/// exactly what the bucketing exists to prevent, so `PUBLIC_STATS_CACHE_HOURS` +/// is clamped UP to this and never down. +const MIN_STATS_CACHE: Duration = Duration::from_secs(3600); + +/// Coarse step for [`bucket_floor`]: every step is a multiple of ten, so a +/// published number can never move by a single registration. +fn bucket_step(n: i64) -> i64 { + match n { + n if n < 1_000 => 10, + n if n < 10_000 => 100, + _ => 1_000, + } +} + +/// Round DOWN to a coarse bucket. Down, never nearest: the published number is +/// then a floor the instance can always stand behind, and it never implies +/// registrations that did not happen. +fn bucket_floor(n: i64) -> i64 { + let step = bucket_step(n); + n - n.rem_euclid(step) +} + +/// The stats block, served from cache while that is still fresh. +/// +/// `None` means the block is omitted: a count that cannot be read must not take +/// the whole landing read model down with it. +async fn stats_block(state: &AppState) -> Option { + let (ttl, min_users) = { + let c = state.cfg(); + ( + Duration::from_secs(c.landing.stats_cache_hours.saturating_mul(3600)) + .max(MIN_STATS_CACHE), + c.landing.stats_min_users, + ) + }; + + if let Some((computed_at, info)) = state.stats_cache.read().await.as_ref() { + if computed_at.elapsed() < ttl { + return Some(info.clone()); + } + } + + let handles = match state.db.get_handle_count().await { + Ok(n) => n, + Err(e) => { + tracing::warn!(error = %e, "handle count failed; omitting the stats block"); + return None; + } + }; + + // Under the floor even a bucketed count narrows to a handful of people, and a + // bucket that rounds to zero says nothing anyway; publish only the boolean. + let info = StatsInfo { + any_handles: handles > 0, + handles_at_least: (handles >= min_users) + .then(|| bucket_floor(handles)) + .filter(|bucketed| *bucketed > 0), + }; + *state.stats_cache.write().await = Some((Instant::now(), info.clone())); + Some(info) +} + /// `GET /api/v1/server-info` — the landing read model. A BARE `404` when landing /// is off (matching an unmatched route — the `{error,code}` envelope would itself /// hint the route exists, so it is deliberately not used here). pub async fn server_info(State(state): State>) -> Response { - if !state.cfg().landing.enabled { + let (enabled, stats_enabled) = { + let c = state.cfg(); + (c.landing.enabled, c.landing.stats_enabled) + }; + if !enabled { return StatusCode::NOT_FOUND.into_response(); } - Json(build_server_info(&state.cfg())).into_response() + let mut info = build_server_info(&state.cfg()); + if stats_enabled { + info.stats = stats_block(&state).await; + } + Json(info).into_response() } /// `GET /` — minimal HTML rendered from the read model; bare `404` when off. @@ -146,4 +246,78 @@ mod tests { "<script>alert(1)</script>" ); } + + /// Walk every leaf of the value, asserting none of them is a string. A string + /// leaf is how an identifier (a handle, an npub, a row id) would reach a + /// public surface, so the shape itself is the guarantee. + fn assert_no_string_leaves(v: &serde_json::Value, path: &str) { + match v { + serde_json::Value::String(s) => { + panic!("stats leaked a string leaf at {path}: {s:?}") + } + serde_json::Value::Array(items) => { + for (i, item) in items.iter().enumerate() { + assert_no_string_leaves(item, &format!("{path}[{i}]")); + } + } + serde_json::Value::Object(map) => { + for (k, item) in map { + assert_no_string_leaves(item, &format!("{path}.{k}")); + } + } + _ => {} + } + } + + #[test] + fn stats_block_is_aggregate_only_and_names_no_one() { + let json = serde_json::to_value(StatsInfo { + any_handles: true, + handles_at_least: Some(bucket_floor(1_234)), + }) + .expect("serialize"); + assert_no_string_leaves(&json, "stats"); + // The published count is coarse, so it cannot move by one registration. + let published = json["handles_at_least"].as_i64().expect("count"); + assert_eq!(published % 10, 0, "published count is coarse: {published}"); + } + + #[test] + fn suppressed_stats_block_still_names_no_one() { + // The below-the-floor shape: a bare boolean, nothing else. + let json = serde_json::to_value(StatsInfo { + any_handles: true, + handles_at_least: None, + }) + .expect("serialize"); + assert_no_string_leaves(&json, "stats"); + assert!( + json.get("handles_at_least").is_none(), + "suppressed count is omitted, not zeroed: {json}" + ); + } + + #[test] + fn bucket_floor_never_overstates_and_stays_coarse() { + for n in [0i64, 1, 9, 10, 19, 137, 999, 1_000, 9_999, 10_000, 123_456] { + let bucketed = bucket_floor(n); + assert!(bucketed <= n, "{bucketed} overstates {n}"); + assert!(bucketed >= 0, "{bucketed} went negative for {n}"); + assert_eq!(bucketed % 10, 0, "{bucketed} is not coarse for {n}"); + assert!( + n - bucketed < bucket_step(n), + "{bucketed} is more than one step below {n}" + ); + } + } + + #[test] + fn bucket_floor_is_monotonic_so_the_count_cannot_run_backwards() { + let mut previous = 0; + for n in 0..3_000 { + let bucketed = bucket_floor(n); + assert!(bucketed >= previous, "bucket fell from {previous} at {n}"); + previous = bucketed; + } + } } diff --git a/src/api/website.rs b/src/api/website.rs index f2c7695..cbe8e41 100644 --- a/src/api/website.rs +++ b/src/api/website.rs @@ -33,15 +33,26 @@ //! messages. "User not found" and "signature invalid" deliberately do not //! leak which one occurred beyond their distinct, fixed literals. //! * **No user creation.** Website sign-in resolves an ALREADY-registered -//! wallet (by the proven identity key's `pubkey_hash`); an unknown key is a -//! 401 directing the user to register with the extension first. It never -//! mints an identity from a bare signature. +//! wallet from the proven key; an unknown key is a 401 with a fixed literal. +//! It never mints an identity from a bare signature, and it never names a +//! client the user is supposedly missing: the wallet that can sign here is by +//! definition the right client. //! -//! Identity in this backend is the wallet's BTC key (`pubkey_hash`); the signed -//! asset's public key is hashed and resolved via -//! [`Database::get_user_by_pubkey_hash`]. The verifier still branches by asset -//! so the cross-asset signing contract holds, but only a key that hashes to a -//! stored identity resolves a user. +//! ## Resolving the signer +//! +//! The proven key is looked up as itself, against the `primary` `user_keys` row +//! for the asset that signed ([`Database::get_user_by_asset_pubkey`]): the very +//! value `/auth/extension` stored at registration. +//! +//! This used to resolve only through `users.pubkey_hash`, which holds the BTC +//! key's hash and nothing else, so LTC/XMR/WOW/Grin sign-in could not succeed +//! for anyone: the lookup had no row that a non-BTC key could ever match, and an +//! unregistered key is indistinguishable from an unknown user, so the failure +//! surfaced as "register first" to users who were already registered. +//! +//! `pubkey_hash` remains the BTC-only fallback. Identities imported by +//! `migrate-legacy` carry `pubkey_hash` but no `user_keys` rows until their +//! owner unlocks a v0.3 wallet, and they must keep signing in meanwhile. //! //! Routes are registered RELATIVE to `/api/v1` (e.g. `/auth/website/challenge`); //! see [`routes`]. The app nests this router under `/api/v1`. @@ -270,22 +281,41 @@ pub async fn website_verify( )?; // The signature is valid: the caller controls this key. Resolve the - // ALREADY-registered wallet by the proven key's pubkey hash. We never create - // a user here — website sign-in is for wallets already registered via the - // extension. An unknown key is a 401 with a literal, registration-pointing - // message (the same outcome as a non-matching signature: no enumeration). - let pubkey_hash = hash_public_key(&req.signature.public_key); - let user = state + // ALREADY-registered wallet from THAT key. We never create a user here. + // + // Resolve by the proven asset's own registered key first. Resolving only by + // `users.pubkey_hash` used to be the whole lookup, and `pubkey_hash` only + // ever holds the BTC key's hash (see `auth::extension_register`), so a + // signature from any other coin could not match anything no matter how + // correct it was. Users reported "User not found" while their wallet, + // handle and desktop session all worked; picking a different coin on the + // sign-in page was the only thing that changed the outcome. + // + // The `pubkey_hash` lookup stays as the BTC fallback: rows imported by + // `migrate-legacy` carry an identity but no `user_keys` (keys are + // re-registered on the first v0.3 unlock), and dropping the fallback would + // lock every one of them out. + let resolved = match state .db - .get_user_by_pubkey_hash(&pubkey_hash) + .get_user_by_asset_pubkey(asset, &req.signature.public_key) .await? - .ok_or_else(|| { - info!("website_verify: no user for proven key"); - AppError::AuthError( - "No wallet is registered for this key. Register with the Smirk extension first." - .into(), - ) - })?; + { + Some(user) => Some(user), + None if asset == AssetType::Btc => { + let pubkey_hash = hash_public_key(&req.signature.public_key); + state.db.get_user_by_pubkey_hash(&pubkey_hash).await? + } + None => None, + }; + let user = resolved.ok_or_else(|| { + info!(asset = %asset, "website_verify: no user for proven key"); + AppError::AuthError( + "We could not match this coin's key to a Smirk account on this server. \ + Unlock your Smirk wallet once so it publishes its keys here, then try \ + again with this or another coin." + .into(), + ) + })?; // Mint a Web session: token pair + revocable DB session row (peppered // refresh hash). Mirrors `issue_session` in the auth module so the session diff --git a/src/config.rs b/src/config.rs index 3ee532a..f8c9342 100644 --- a/src/config.rs +++ b/src/config.rs @@ -430,6 +430,10 @@ pub struct LandingConfig { pub expose_uptime: bool, pub stats_enabled: bool, pub stats_cache_hours: u64, + /// k-anonymity floor for the public stats block. Under this many handles the + /// block says only "some exist": on a young instance an exact-ish count plus + /// a timestamp correlates trivially with the registrations that produced it. + pub stats_min_users: i64, } #[derive(Clone)] @@ -930,6 +934,7 @@ impl Config { expose_uptime: env_bool("PUBLIC_EXPOSE_UPTIME", false), stats_enabled: env_bool("PUBLIC_STATS_ENABLED", false), stats_cache_hours: env_parse("PUBLIC_STATS_CACHE_HOURS", 24u64)?, + stats_min_users: env_parse("PUBLIC_STATS_MIN_USERS", 20i64)?, }, retention: RetentionConfig { login_events_days: env_parse("RETENTION_LOGIN_EVENTS_DAYS", 90u64)?, @@ -1586,6 +1591,7 @@ mod tests { expose_uptime: false, stats_enabled: false, stats_cache_hours: 24, + stats_min_users: 20, }, retention: RetentionConfig { login_events_days: 90, diff --git a/src/config_overlay.rs b/src/config_overlay.rs index 09904ea..444b77c 100644 --- a/src/config_overlay.rs +++ b/src/config_overlay.rs @@ -41,6 +41,7 @@ pub struct LandingOverlay { pub expose_uptime: Option, pub stats_enabled: Option, pub stats_cache_hours: Option, + pub stats_min_users: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -229,6 +230,9 @@ impl Config { if let Some(v) = l.stats_cache_hours { c.landing.stats_cache_hours = v; } + if let Some(v) = l.stats_min_users { + c.landing.stats_min_users = v; + } } if let Some(r) = &ov.retention { @@ -429,6 +433,7 @@ pub const EDITABLE_FIELDS: &[(&str, &str)] = &[ ("landing", "expose_uptime"), ("landing", "stats_enabled"), ("landing", "stats_cache_hours"), + ("landing", "stats_min_users"), ("retention", "login_events_days"), ("retention", "audit_days"), ("retention", "erasure_enabled"), @@ -490,6 +495,7 @@ impl Config { expose_uptime: Some(self.landing.expose_uptime), stats_enabled: Some(self.landing.stats_enabled), stats_cache_hours: Some(self.landing.stats_cache_hours), + stats_min_users: Some(self.landing.stats_min_users), }), retention: Some(RetentionOverlay { login_events_days: Some(self.retention.login_events_days), diff --git a/src/infra/db/users.rs b/src/infra/db/users.rs index 7531916..c9acfe4 100644 --- a/src/infra/db/users.rs +++ b/src/infra/db/users.rs @@ -36,6 +36,16 @@ fn registration_conflict(e: sqlx::Error) -> AppError { const USER_COLS: &str = "id, username, pubkey_hash, nostr_pubkey, wallet_birthday, \ seed_fingerprint, xmr_start_height, wow_start_height, created_at, updated_at, last_seen_at"; +/// [`USER_COLS`] qualified to the `users` alias `u`, for any query that JOINs. +/// +/// `user_keys` also has `id`, `created_at` and `updated_at`, so an unqualified +/// list against a join is `column reference "id" is ambiguous` from Postgres: +/// a 500 at runtime, invisible at compile time because these queries are built +/// as strings. +const USER_COLS_U: &str = "u.id, u.username, u.pubkey_hash, u.nostr_pubkey, u.wallet_birthday, \ + u.seed_fingerprint, u.xmr_start_height, u.wow_start_height, u.created_at, u.updated_at, \ + u.last_seen_at"; + impl Database { /// Create a new user. `pubkey_hash` / `seed_fingerprint` are peppered here. #[instrument(skip(self, input))] @@ -232,7 +242,7 @@ impl Database { #[instrument(skip(self, address))] pub async fn find_user_by_grin_address(&self, address: &str) -> Result, AppError> { let sql = format!( - "SELECT {USER_COLS} FROM users u \ + "SELECT {USER_COLS_U} FROM users u \ JOIN user_keys k ON k.user_id = u.id \ WHERE k.asset = 'grin' AND k.public_key = $1" ); @@ -242,6 +252,36 @@ impl Database { .await?) } + /// Resolve a user by the public key they registered for one asset. + /// + /// Website sign-in proves control of ONE coin's key, which is not + /// necessarily the BTC key that anchors `users.pubkey_hash`. Resolving only + /// by `pubkey_hash` therefore made ltc/xmr/wow/grin sign-in structurally + /// impossible on this backend: only a BTC signature could ever match. That + /// is the 2026-09-13 "User not found" report, where the wallet, the handle + /// and the desktop session were all fine and only the website refused. + /// + /// Matched verbatim against `user_keys.public_key` for the `primary` key of + /// `asset`, which is exactly the value `/auth/extension` stored at + /// registration, so nothing is re-hashed or re-encoded on either side. + #[instrument(skip(self, public_key))] + pub async fn get_user_by_asset_pubkey( + &self, + asset: AssetType, + public_key: &str, + ) -> Result, AppError> { + let sql = format!( + "SELECT {USER_COLS_U} FROM users u \ + JOIN user_keys k ON k.user_id = u.id \ + WHERE k.asset = $1 AND k.public_key = $2 AND k.key_type = 'primary'" + ); + Ok(sqlx::query_as::<_, User>(&sql) + .bind(asset) + .bind(public_key) + .fetch_optional(self.pool()) + .await?) + } + #[instrument(skip(self, username))] pub async fn get_user_by_username(&self, username: &str) -> Result, AppError> { let sql = format!("SELECT {USER_COLS} FROM users WHERE username = $1"); @@ -629,4 +669,18 @@ impl Database { .fetch_one(self.pool()) .await?) } + + /// Count users holding a claimed handle (`name@`). + /// + /// Returns a scalar and never SELECTs `username` itself: the only caller is + /// the unauthenticated public stats block, and a query that can only produce + /// a number cannot be widened into a handle directory by a later edit. + #[instrument(skip(self))] + pub async fn get_handle_count(&self) -> Result { + Ok( + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username IS NOT NULL") + .fetch_one(self.pool()) + .await?, + ) + } } diff --git a/src/lib.rs b/src/lib.rs index 7d3aaec..95fc86a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,6 +79,10 @@ pub struct AppState { /// Latest fetched fiat prices, refreshed by a background task when the price /// feed is enabled. Stays empty (and `/prices` 404s) otherwise. pub prices: Arc>, + /// Cached public stats block (the handle count), recomputed at most once per + /// `PUBLIC_STATS_CACHE_HOURS`. Empty until the first request that needs it, + /// so an instance with stats off never runs the query at all. + pub stats_cache: crate::api::landing::StatsCache, /// Admin token minter/verifier. `Some` only when the admin surface is /// enabled; the admin plane and guard refuse all requests otherwise. pub admin_sessions: Option, diff --git a/src/main.rs b/src/main.rs index 48bef53..703a4f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -117,6 +117,7 @@ async fn main() -> Result<(), Box> { relay, web_challenges: Arc::default(), prices: prices_cache, + stats_cache: Arc::default(), admin_sessions, shutdown: Arc::new(tokio::sync::Notify::new()), }); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 80f22af..fcbdf84 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -98,6 +98,7 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option { relay, web_challenges: Arc::default(), prices, + stats_cache: Arc::default(), admin_sessions, shutdown: Arc::default(), }); @@ -247,6 +248,29 @@ impl TestApp { user.id } + /// Create a user holding a claimed handle. Returns the handle so a test can + /// assert it never appears on a public surface. + pub async fn create_user_with_handle(&self) -> String { + use smirk_backend_core::models::db::NewUser; + // Shaped like a real handle (lowercase, <= 32 chars) but unmistakable in + // a response body, so a leak is a match and not a coincidence. + let handle = format!("h{}", &Uuid::new_v4().simple().to_string()[..16]); + self.state + .db + .create_user(NewUser { + username: Some(handle.clone()), + pubkey_hash: Some(format!("pk-{}", Uuid::new_v4())), + nostr_pubkey: None, + wallet_birthday: None, + seed_fingerprint: None, + xmr_start_height: None, + wow_start_height: None, + }) + .await + .expect("create user with handle"); + handle + } + /// Create a user + an active session, returning `(user_id, access, refresh)`. /// Bypasses the signed-registration handshake so session-gated routes can be /// exercised without forging a BIP-137 signature. diff --git a/tests/landing.rs b/tests/landing.rs index d5ebfdb..dc9da53 100644 --- a/tests/landing.rs +++ b/tests/landing.rs @@ -1,6 +1,10 @@ //! L1 integration: the public landing surface (operator §2.3). Default-OFF; when //! on, the default projection is the minimal `{ software.name, status }` and `/` -//! serves HTML. One sequential test (it toggles process env). +//! serves HTML. +//! +//! `landing_off_then_on_minimal` toggles process env and must stay the only test +//! here that does; the stats tests drive config directly via `try_app_with` so +//! they cannot race it. mod common; @@ -44,3 +48,123 @@ async fn landing_off_then_on_minimal() { std::env::remove_var("PUBLIC_LANDING_ENABLED"); } + +/// Walk every leaf, asserting none is a string. A string leaf is how an +/// identifier (a handle, an npub, a row id) would reach this unauthenticated +/// surface, so the shape itself is the guarantee, not a field-by-field allowlist +/// that the next field to be added would quietly escape. +fn assert_no_string_leaves(v: &serde_json::Value, path: &str) { + match v { + serde_json::Value::String(s) => panic!("stats leaked a string leaf at {path}: {s:?}"), + serde_json::Value::Array(items) => { + for (i, item) in items.iter().enumerate() { + assert_no_string_leaves(item, &format!("{path}[{i}]")); + } + } + serde_json::Value::Object(map) => { + for (k, item) in map { + assert_no_string_leaves(item, &format!("{path}.{k}")); + } + } + _ => {} + } +} + +/// The stats block reports an aggregate and never an identifier: no handle it +/// counted appears anywhere in the response, and the block carries no strings at +/// all. Drives config directly, so it does not touch process env. +#[tokio::test] +async fn stats_report_an_aggregate_and_never_an_identifier() { + let Some(app) = common::try_app_with(|c| { + c.landing.enabled = true; + c.landing.stats_enabled = true; + // Floor of 1: the assertions below are about shape, and a test database + // shared with other cases cannot be relied on to clear the real floor. + c.landing.stats_min_users = 1; + }) + .await + else { + return; + }; + + // Enough handles to clear the smallest bucket, so the published-count branch + // below actually runs instead of passing vacuously on an empty database. + let handles: Vec = { + let mut created = Vec::new(); + for _ in 0..12 { + created.push(app.create_user_with_handle().await); + } + created + }; + + let (st, body) = app.request("GET", "/api/v1/server-info", None, None).await; + assert_eq!(st, StatusCode::OK); + + let stats = body.get("stats").expect("stats block when stats are on"); + assert_no_string_leaves(stats, "stats"); + assert!( + stats["any_handles"].as_bool().unwrap_or(false), + "handles were just claimed: {body}" + ); + + // Nothing we created is quotable from the whole response, not just the block. + let rendered = body.to_string(); + for handle in &handles { + assert!( + !rendered.contains(handle.as_str()), + "a claimed handle reached the public surface: {body}" + ); + } + + // The published number is a floor, never more than the truth, and coarse + // enough that it cannot name the registration that moved it. + let actual = app + .state + .db + .get_handle_count() + .await + .expect("count handles"); + let published = stats + .get("handles_at_least") + .and_then(|v| v.as_i64()) + .expect("a count is published once the floor is cleared"); + assert!( + published <= actual, + "published {published} overstates {actual}" + ); + assert_eq!(published % 10, 0, "published {published} is not coarse"); + + // One more registration must not move the published block at all. A public + // counter that ticks by one answers "did someone just register?" by + // subtraction, which is the whole reason the value is bucketed and held. + let _ = app.create_user_with_handle().await; + let (st, after) = app.request("GET", "/api/v1/server-info", None, None).await; + assert_eq!(st, StatusCode::OK); + assert_eq!( + after.get("stats"), + Some(stats), + "the stats block moved with a single registration" + ); +} + +/// Stats stay dark unless the operator turns them on, even with the landing +/// surface itself enabled: a new public capability defaults closed. +#[tokio::test] +async fn stats_absent_unless_enabled() { + let Some(app) = common::try_app_with(|c| { + c.landing.enabled = true; + c.landing.stats_enabled = false; + }) + .await + else { + return; + }; + + let _ = app.create_user_with_handle().await; + let (st, body) = app.request("GET", "/api/v1/server-info", None, None).await; + assert_eq!(st, StatusCode::OK); + assert!( + body.get("stats").is_none(), + "stats block present with stats off: {body}" + ); +} diff --git a/tests/website_auth.rs b/tests/website_auth.rs new file mode 100644 index 0000000..e13d5d0 --- /dev/null +++ b/tests/website_auth.rs @@ -0,0 +1,264 @@ +//! L1 integration: website ("Sign in with your wallet") auth, end to end. +//! +//! The regression these pin is the 2026-09-13 report: a user whose wallet +//! worked, whose handle resolved and who was signed in on desktop was told to +//! register, because `website_verify` resolved the signer ONLY through +//! `users.pubkey_hash`. That column holds the BTC key's hash and nothing else, +//! so a signature from LTC/XMR/WOW/Grin matched no row for anyone, and an +//! unregistered key is indistinguishable from an unknown user. +//! +//! Key material here is built in exactly the representation the wallet +//! registers and later signs with (`@smirk/core` `buildKeysList` and the +//! extension's dapp signers): compressed SEC1 hex signed BIP-137 for BTC/LTC, +//! 32-byte Ed25519 hex for XMR/WOW/Grin. + +mod common; + +use axum::http::StatusCode; +use base64::Engine; +use ed25519_dalek::Signer as _; +use k256::ecdsa::signature::hazmat::PrehashSigner; +use k256::ecdsa::{Signature, SigningKey}; +use k256::SecretKey; +use rand::rngs::OsRng; +use serde_json::json; +use sha2::{Digest, Sha256}; +use smirk_backend_core::models::db::NewUser; +use uuid::Uuid; + +/// A wallet's key for one asset, able to produce the two things the sign-in +/// wire carries: the public key as registered, and a signature over a message. +enum AssetKey { + /// secp256k1 (BTC, LTC): BIP-137 base64 over the Bitcoin signed-message hash. + Secp(SigningKey), + /// Ed25519 (XMR, WOW, Grin): 64-byte hex over the raw message bytes. + Ed(ed25519_dalek::SigningKey), +} + +impl AssetKey { + /// The exact string registered as this asset's `public_key`. + fn public_key(&self) -> String { + match self { + AssetKey::Secp(sk) => hex::encode(sk.verifying_key().to_encoded_point(true).as_bytes()), + AssetKey::Ed(sk) => hex::encode(sk.verifying_key().to_bytes()), + } + } + + /// Sign `message` the way this asset's wallet path signs it. + fn sign(&self, message: &str) -> String { + match self { + AssetKey::Secp(sk) => { + let mut preimage = Vec::new(); + preimage.extend_from_slice(b"\x18Bitcoin Signed Message:\n"); + // Every message signed in these tests is < 253 bytes. + preimage.push(message.len() as u8); + preimage.extend_from_slice(message.as_bytes()); + let hash = Sha256::digest(Sha256::digest(&preimage)); + let sig: Signature = sk.sign_prehash(&hash[..]).expect("sign prehash"); + let mut out = vec![0x1fu8]; + out.extend_from_slice(&sig.to_bytes()[..]); + base64::engine::general_purpose::STANDARD.encode(out) + } + AssetKey::Ed(sk) => hex::encode(sk.sign(message.as_bytes()).to_bytes()), + } + } +} + +fn secp_key() -> AssetKey { + AssetKey::Secp(SigningKey::from(&SecretKey::random(&mut OsRng))) +} + +/// A fresh Ed25519 key. Seeded from a v4 UUID rather than `SigningKey::generate` +/// so the test does not depend on the crate's optional `rand_core` feature. +fn ed_key() -> AssetKey { + let seed: [u8; 32] = Sha256::digest(Uuid::new_v4().as_bytes()).into(); + AssetKey::Ed(ed25519_dalek::SigningKey::from_bytes(&seed)) +} + +/// The server's identity handle for a public key: `hex(SHA256(public_key))`. +fn pubkey_hash(public_key: &str) -> String { + hex::encode(Sha256::digest(public_key.as_bytes())) +} + +/// The coins a wallet onboards with, each with its own fresh key, in the order +/// `buildKeysList` sends them (BTC first: it is the identity). +fn fresh_wallet() -> Vec<(&'static str, AssetKey)> { + vec![ + ("btc", secp_key()), + ("ltc", secp_key()), + ("xmr", ed_key()), + ("wow", ed_key()), + ("grin", ed_key()), + ] +} + +fn key_for<'a>(wallet: &'a [(&'static str, AssetKey)], asset: &str) -> &'a AssetKey { + &wallet + .iter() + .find(|(a, _)| *a == asset) + .expect("wallet holds the asset") + .1 +} + +/// Complete onboarding: `POST /auth/extension` with every key in `wallet`, +/// proving control of the BTC key with a freshly signed timestamp. +async fn onboard( + app: &common::TestApp, + wallet: &[(&'static str, AssetKey)], +) -> (StatusCode, serde_json::Value) { + let ts = chrono::Utc::now().timestamp(); + let keys: Vec<_> = wallet + .iter() + .map(|(asset, key)| json!({ "asset": asset, "public_key": key.public_key() })) + .collect(); + let body = json!({ + "keys": keys, + "signed_timestamp": ts, + "signature": key_for(wallet, "btc").sign(&format!("smirk-auth-{ts}")), + }); + app.request("POST", "/api/v1/auth/extension", None, Some(body)) + .await +} + +/// The full website handshake for one coin: request a challenge, sign it with +/// that coin's key, and present the proof. +async fn website_sign_in( + app: &common::TestApp, + asset: &str, + key: &AssetKey, +) -> (StatusCode, serde_json::Value) { + let (status, challenge) = app + .request( + "POST", + "/api/v1/auth/website/challenge", + None, + Some(json!({ "origin": "https://smirk.cash" })), + ) + .await; + assert_eq!(status, StatusCode::OK, "challenge must issue"); + + let message = challenge["challenge"].as_str().expect("challenge message"); + let body = json!({ + "challenge_id": challenge["challenge_id"], + "signature": { + "asset": asset, + "signature": key.sign(message), + "public_key": key.public_key(), + }, + }); + app.request("POST", "/api/v1/auth/website/verify", None, Some(body)) + .await +} + +/// THE property: a wallet that finished onboarding can sign in to the website +/// with each coin it registered, and every coin resolves the SAME identity. +/// +/// Driven by the set the wallet actually registered, so supporting one more (or +/// one fewer) coin changes the coverage without anyone editing this assertion. +#[tokio::test] +async fn onboarded_wallet_signs_in_with_every_coin_it_registered() { + let app = require_app!(); + let wallet = fresh_wallet(); + + let (status, registered) = onboard(&app, &wallet).await; + assert_eq!(status, StatusCode::OK, "onboarding must succeed"); + let user_id = registered["user"]["id"] + .as_str() + .expect("registration returns the user") + .to_string(); + + for (asset, key) in &wallet { + let (status, body) = website_sign_in(&app, asset, key).await; + assert_eq!( + status, + StatusCode::OK, + "{asset}: a registered coin must sign in, got {body}" + ); + assert!( + body["access_token"].as_str().is_some_and(|t| !t.is_empty()), + "{asset}: sign-in must issue a session" + ); + assert_eq!( + body["user"]["id"].as_str(), + Some(user_id.as_str()), + "{asset}: must resolve the wallet that registered the key" + ); + assert_eq!( + body["is_new"], false, + "{asset}: website sign-in must never mint an identity" + ); + } +} + +/// An identity that exists with NO `user_keys` rows still signs in with BTC. +/// +/// This is the shape `smirk-admin migrate-legacy` leaves behind: it imports +/// identity (including `pubkey_hash`) and lets the v0.3 client re-register keys +/// on first unlock. Resolving by asset key alone would lock every one of those +/// users out, so the BTC `pubkey_hash` fallback is load-bearing, not legacy +/// residue. +#[tokio::test] +async fn an_imported_identity_without_keys_still_signs_in_with_btc() { + let app = require_app!(); + let btc = secp_key(); + + app.state + .db + .create_user(NewUser { + username: None, + pubkey_hash: Some(pubkey_hash(&btc.public_key())), + nostr_pubkey: None, + wallet_birthday: None, + seed_fingerprint: None, + xmr_start_height: None, + wow_start_height: None, + }) + .await + .expect("create imported identity"); + + let (status, body) = website_sign_in(&app, "btc", &btc).await; + assert_eq!( + status, + StatusCode::OK, + "an imported identity must not be locked out, got {body}" + ); +} + +/// No enumeration: a key that cannot be resolved is refused the same way +/// whether or not an account exists behind it. +/// +/// Compares the refusal for a coin key held by a wallet the server has never +/// seen against the refusal for a coin the registered wallet chose not to +/// register. Those are the two cases a prober would use to test for an account, +/// and they must be indistinguishable in both status and body. +#[tokio::test] +async fn an_unresolvable_key_is_refused_identically_whether_or_not_the_account_exists() { + let app = require_app!(); + + // A wallet that onboarded with BTC only, so its LTC key is genuinely + // unregistered while the account behind it very much exists. + let partial = vec![("btc", secp_key()), ("ltc", secp_key())]; + let (status, _) = onboard(&app, &partial[..1]).await; + assert_eq!(status, StatusCode::OK, "partial onboarding must succeed"); + + let (known_account, known_body) = website_sign_in(&app, "ltc", key_for(&partial, "ltc")).await; + + // A wallet the server has never seen at all. + let stranger = fresh_wallet(); + let (no_account, no_account_body) = + website_sign_in(&app, "ltc", key_for(&stranger, "ltc")).await; + + assert_eq!( + known_account, + StatusCode::UNAUTHORIZED, + "an unregistered coin key must be refused" + ); + assert_eq!( + known_account, no_account, + "refusal status must not disclose whether the account exists" + ); + assert_eq!( + known_body["error"], no_account_body["error"], + "refusal message must not disclose whether the account exists" + ); +}