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