fix(website): resolve sign-in by the asset that signed, not only the BTC hash

website_verify resolved every asset through get_user_by_pubkey_hash, but
users.pubkey_hash only ever holds the BTC key's hash. On this backend only BTC
could ever resolve; ltc, xmr, wow and grin were structurally impossible, and the
failure was indistinguishable from an unregistered user, so the page told people
to register with an extension they already had.

That did not surface while smirk.cash pointed at the legacy backend, which
resolves by asset key against its own database. It becomes the whole bug the
moment the site is repointed here, so it has to land with that change, not after.

get_user_by_pubkey_hash stays as the BTC fallback: of 95 users on this backend,
84 arrived through migrate-legacy, which imported identity without keys, and
would otherwise stop resolving entirely.

The rejection message is unchanged and still says nothing about whether an
account exists.
This commit is contained in:
2026-09-14 22:07:20 -04:00
parent 1d7e1d5a78
commit 985a05adf8
12 changed files with 724 additions and 27 deletions
+7
View File
@@ -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
+4 -1
View File
@@ -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
+176 -2
View File
@@ -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<i64>,
}
/// 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<RwLock<Option<(Instant, StatsInfo)>>>;
/// 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<bool>,
/// 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<StatsInfo>,
}
/// 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<StatsInfo> {
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<Arc<AppState>>) -> 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 {
"&lt;script&gt;alert(1)&lt;/script&gt;"
);
}
/// 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;
}
}
}
+52 -22
View File
@@ -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
+6
View File
@@ -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,
+6
View File
@@ -41,6 +41,7 @@ pub struct LandingOverlay {
pub expose_uptime: Option<bool>,
pub stats_enabled: Option<bool>,
pub stats_cache_hours: Option<u64>,
pub stats_min_users: Option<i64>,
}
#[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),
+55 -1
View File
@@ -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<Option<User>, 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<Option<User>, 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<Option<User>, 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@<instance host>`).
///
/// 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<i64, AppError> {
Ok(
sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username IS NOT NULL")
.fetch_one(self.pool())
.await?,
)
}
}
+4
View File
@@ -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<RwLock<PriceSnapshot>>,
/// 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<AdminSessionManager>,
+1
View File
@@ -117,6 +117,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
relay,
web_challenges: Arc::default(),
prices: prices_cache,
stats_cache: Arc::default(),
admin_sessions,
shutdown: Arc::new(tokio::sync::Notify::new()),
});
+24
View File
@@ -98,6 +98,7 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option<TestApp> {
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.
+125 -1
View File
@@ -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<String> = {
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}"
);
}
+264
View File
@@ -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"
);
}