fix: pre-ship audit remediation for auth identity, abuse control and retention

Fixes every confirmed backend finding from the v0.3.0 pre-ship review.

Identity (the two that corrupt accounts):
- nostr_register creates a row with pubkey_hash NULL, so that wallet's later
  /auth/extension call matched nothing, fell through the rotation path (which no
  client ever satisfies, since nothing sends rotation_signature) and minted a
  SECOND account. A fingerprint match on a NULL-pubkey_hash row now adopts the
  submitted pubkey_hash onto that row, but only when the row's on-file BTC key
  equals the submitted one, so a bare fingerprint (which check_restore discloses,
  and is therefore not a secret) still cannot claim an account.
- The fingerprint-ownership check no longer short-circuits on returning_by_pubkey.
  It was skipped for returning users, so a fingerprint owned by another row got
  backfilled, violated the UNIQUE index, and 500'd that account on every
  subsequent login.
- The rotation proof is verified over a distinct, instance- and target-bound
  message. It shared the login message, so a captured login signature was a
  replayable rotation proof against another instance.

Abuse control:
- The rate limiters key on the real client IP, honouring X-Forwarded-For only
  from a trusted proxy. Behind the TLS terminator every request looked like
  127.0.0.1, so the whole internet shared one bucket and ~3 req/s 429'd every
  wallet unlock globally.
- A PoW solution is single-use instead of replayable for the challenge TTL.
- Delegated /verify is opt-in and rate-limited; it was an unauthenticated
  amplifier into Electrum, LWS and a full-genesis grin scan.
- A grin scan with no start_height gets the same depth policy and PoW price as
  one that passes a height, instead of an unbounded free scan.

Retention and logging:
- The retention sweep is implemented AND scheduled. RETENTION_* was advertised
  and inert, which made the privacy policy's retention promise untrue. Zero
  means keep forever, and each statement is batched so a backlog drains over
  ticks rather than holding row locks.
- login_events.ip_hash is no longer written, and legacy values are scrubbed:
  nothing ever read it, so it was retention without a purpose.
- Tracing spans log user_id only, not the raw npub and username.
- secret-scan.sh matches the coverage its comment claims.

Also fixes the test suite, which did not compile on this commit: NewSession
gained an id in c64cd9b and two call sites were never updated. mint_session now
binds one session_id for both the token and the row, since a different id there
would silently recreate the bug c64cd9b fixed.
This commit is contained in:
jw
2026-08-13 10:31:02 -04:00
parent 23659dfd5a
commit 81a3e4cf58
15 changed files with 989 additions and 81 deletions
+250 -1
View File
@@ -1,13 +1,73 @@
//! L1 integration: HTTP flows through the real router against a real database.
//! Exercises routing, the auth gate, session refresh rotation, the username +
//! key endpoints, and the NIP-05 directory end to end.
//! key endpoints, the NIP-05 directory, and the `/auth/extension` identity rules
//! (seed-fingerprint ownership, and the domain separation that keeps a login
//! signature from doubling as a derivation-rotation proof) end to end.
mod common;
use axum::http::StatusCode;
use base64::Engine;
use k256::ecdsa::signature::hazmat::PrehashSigner;
use k256::ecdsa::{Signature, SigningKey};
use k256::elliptic_curve::sec1::ToEncodedPoint;
use k256::SecretKey;
use rand::rngs::OsRng;
use serde_json::json;
use sha2::{Digest, Sha256};
use smirk_backend_core::models::db::{AssetType, NewUser, NewUserKey};
use uuid::Uuid;
/// A fresh secp256k1 wallet identity: the ECDSA signing key plus its compressed
/// SEC1 public key hex (the exact string the server hashes into `pubkey_hash`).
fn btc_identity() -> (SigningKey, String) {
let secret = SecretKey::random(&mut OsRng);
let public = hex::encode(secret.public_key().to_encoded_point(true).as_bytes());
(SigningKey::from(&secret), public)
}
/// 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()))
}
/// A fresh 64-hex seed fingerprint (the wallet-side format).
fn fresh_fingerprint() -> String {
hex::encode(Sha256::digest(Uuid::new_v4().as_bytes()))
}
/// BIP-137 base64 signature over the Bitcoin signed-message hash of `message`:
/// 65 bytes, a header byte (which the verifier drops) followed by r||s.
fn sign_bip137(sk: &SigningKey, message: &str) -> String {
let mut preimage = Vec::new();
preimage.extend_from_slice(b"\x18Bitcoin Signed Message:\n");
preimage.push(message.len() as u8); // every message signed here is < 253 bytes
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)
}
/// POST `/auth/extension` with a freshly signed timestamp proof.
async fn extension_login(
app: &common::TestApp,
sk: &SigningKey,
public_key: &str,
seed_fingerprint: Option<&str>,
) -> (StatusCode, serde_json::Value) {
let ts = chrono::Utc::now().timestamp();
let body = json!({
"keys": [{ "asset": "btc", "public_key": public_key }],
"seed_fingerprint": seed_fingerprint,
"signed_timestamp": ts,
"signature": sign_bip137(sk, &format!("smirk-auth-{ts}")),
});
app.request("POST", "/api/v1/auth/extension", None, Some(body))
.await
}
#[tokio::test]
async fn health_is_ok() {
let app = require_app!();
@@ -144,3 +204,192 @@ async fn nip05_unknown_name_is_empty() {
.unwrap_or(true);
assert!(empty, "unknown name must resolve to no entry");
}
#[tokio::test]
async fn returning_login_never_takes_another_rows_seed_fingerprint() {
// Regression: the fingerprint-ownership check was skipped for a RETURNING
// user, so a login carrying someone else's fingerprint tried to backfill it,
// violated the UNIQUE index, and 500d that account on every later login.
let app = require_app!();
// Wallet A registers and owns the fingerprint.
let (sk_a, pk_a) = btc_identity();
let fp = fresh_fingerprint();
let (s_a, body_a) = extension_login(&app, &sk_a, &pk_a, Some(&fp)).await;
assert_eq!(s_a, StatusCode::OK, "body={body_a}");
let id_a = body_a["user"]["id"].as_str().unwrap().to_string();
// A signs in again with its OWN fingerprint: same row, no conflict.
let (s_a2, body_a2) = extension_login(&app, &sk_a, &pk_a, Some(&fp)).await;
assert_eq!(s_a2, StatusCode::OK, "body={body_a2}");
assert_eq!(body_a2["user"]["id"].as_str().unwrap(), id_a);
assert_eq!(body_a2["is_new"], false);
// Wallet B registers WITHOUT a fingerprint, then returns carrying A's.
let (sk_b, pk_b) = btc_identity();
let (s_b, body_b) = extension_login(&app, &sk_b, &pk_b, None).await;
assert_eq!(s_b, StatusCode::OK, "body={body_b}");
let id_b = body_b["user"]["id"].as_str().unwrap().to_string();
assert_ne!(id_a, id_b);
let (s_b2, body_b2) = extension_login(&app, &sk_b, &pk_b, Some(&fp)).await;
assert_eq!(
s_b2,
StatusCode::OK,
"a returning login must not 500 on a foreign fingerprint: body={body_b2}"
);
assert_eq!(body_b2["user"]["id"].as_str().unwrap(), id_b);
// A still owns the fingerprint; B never acquired one.
let owner = app
.state
.db
.get_user_by_seed_fingerprint(&fp)
.await
.unwrap()
.expect("the fingerprint still resolves");
assert_eq!(owner.id.to_string(), id_a);
let row_b = app
.state
.db
.get_user(Uuid::parse_str(&id_b).unwrap())
.await
.unwrap()
.expect("B still present");
assert!(
row_b.seed_fingerprint.is_none(),
"B must not have taken another wallet's fingerprint"
);
}
#[tokio::test]
async fn a_login_signature_is_rejected_as_a_rotation_proof() {
// SECURITY: the derivation-rotation proof signs a DEDICATED message bound to
// this instance and to the NEW pubkey_hash, so an ordinary login signature
// (`smirk-auth-{ts}`) is worthless as one. Without that separation a hostile
// or compromised instance could capture a victim's login signature and replay
// it, within the drift window, to re-point the victim's payout keys.
let app = require_app!();
// Victim registers, so their BTC key is on file under their fingerprint.
let (sk_v, pk_v) = btc_identity();
let fp = fresh_fingerprint();
let (s_v, body_v) = extension_login(&app, &sk_v, &pk_v, Some(&fp)).await;
assert_eq!(s_v, StatusCode::OK, "body={body_v}");
let victim_id = body_v["user"]["id"].as_str().unwrap().to_string();
// Attacker: their own BTC key, the victim's (non-secret) fingerprint, and a
// captured victim LOGIN signature presented as the rotation proof.
let ts = chrono::Utc::now().timestamp();
let captured_login = sign_bip137(&sk_v, &format!("smirk-auth-{ts}"));
let (sk_x, pk_x) = btc_identity();
let (status, out) = app
.request(
"POST",
"/api/v1/auth/extension",
None,
Some(json!({
"keys": [{ "asset": "btc", "public_key": pk_x }],
"seed_fingerprint": fp,
"signed_timestamp": ts,
"signature": sign_bip137(&sk_x, &format!("smirk-auth-{ts}")),
"rotation_signature": captured_login,
})),
)
.await;
// The rotation is refused: the caller gets a brand-new identity instead.
assert_eq!(status, StatusCode::OK, "body={out}");
assert_ne!(
out["user"]["id"].as_str().unwrap(),
victim_id,
"a login signature must never rotate someone else's account"
);
assert_eq!(out["is_new"], true);
// The victim's row is untouched: same pubkey_hash, same fingerprint.
let victim = app
.state
.db
.get_user_by_pubkey_hash(&pubkey_hash(&pk_v))
.await
.unwrap()
.expect("victim still resolves by its original pubkey_hash");
assert_eq!(victim.id.to_string(), victim_id);
let owner = app
.state
.db
.get_user_by_seed_fingerprint(&fp)
.await
.unwrap()
.expect("the fingerprint still resolves");
assert_eq!(owner.id.to_string(), victim_id);
}
#[tokio::test]
async fn extension_login_adopts_a_keyless_row_only_for_the_on_file_btc_key() {
// Regression: `nostr_register` mints its row with pubkey_hash NULL, so the
// same wallet's first /auth/extension matched nothing by pubkey and minted a
// SECOND row, stranding the handle, tips and premium state on the first. The
// adoption is gated on the BTC key ALREADY ON FILE, so a bare fingerprint
// (which check-restore discloses, and which is therefore not a secret) still
// cannot claim someone else's account.
let app = require_app!();
let db = &app.state.db;
// Exactly the state an npub-native registration leaves behind: an npub-keyed
// row with a seed fingerprint and a BTC key, but no pubkey_hash.
let (sk, pk) = btc_identity();
let fp = fresh_fingerprint();
let npub = hex::encode(Sha256::digest(Uuid::new_v4().as_bytes()));
let keyless = db
.create_user(NewUser {
username: None,
pubkey_hash: None,
nostr_pubkey: Some(npub.clone()),
wallet_birthday: None,
seed_fingerprint: Some(fp.clone()),
xmr_start_height: None,
wow_start_height: None,
})
.await
.unwrap();
let keyless_id = keyless.id.to_string();
let btc_on_file = NewUserKey {
user_id: keyless.id,
asset: AssetType::Btc,
public_key: pk.clone(),
public_spend_key: None,
key_type: "primary".to_string(),
};
db.upsert_user_key(btc_on_file).await.unwrap();
// A DIFFERENT wallet that knows only the fingerprint gets its own identity;
// the keyless row is left alone.
let (sk_x, pk_x) = btc_identity();
let (s_x, body_x) = extension_login(&app, &sk_x, &pk_x, Some(&fp)).await;
assert_eq!(s_x, StatusCode::OK, "body={body_x}");
assert_eq!(body_x["is_new"], true);
assert_ne!(body_x["user"]["id"].as_str().unwrap(), keyless_id);
let after = db.get_user(keyless.id).await.unwrap();
assert!(
after.is_some_and(|u| u.pubkey_hash.is_none()),
"a bare fingerprint must not claim the keyless row"
);
// The real wallet (same seed AND the BTC key on file) adopts that row.
let (s_ok, body_ok) = extension_login(&app, &sk, &pk, Some(&fp)).await;
assert_eq!(s_ok, StatusCode::OK, "body={body_ok}");
assert_eq!(body_ok["is_new"], false);
assert_eq!(body_ok["user"]["id"].as_str().unwrap(), keyless_id);
// One identity, not two: the row now answers to the pubkey_hash and keeps
// the Nostr identity it registered with.
let adopted = db
.get_user_by_pubkey_hash(&pubkey_hash(&pk))
.await
.unwrap()
.expect("the pubkey_hash now resolves");
assert_eq!(adopted.id, keyless.id);
assert_eq!(adopted.nostr_pubkey.as_deref(), Some(npub.as_str()));
}
+5 -1
View File
@@ -254,10 +254,13 @@ impl TestApp {
use smirk_backend_core::core::session::{hash_refresh_token, Platform};
use smirk_backend_core::models::db::NewSession;
let user_id = self.create_user().await;
// The access token embeds this as `sid`, so the row MUST carry the same id
// or every authenticated request fails the session-liveness check.
let session_id = Uuid::new_v4();
let pair = self
.state
.sessions
.create_token_pair(user_id, Platform::Web, Uuid::new_v4())
.create_token_pair(user_id, Platform::Web, session_id)
.expect("mint token pair");
let hash = hash_refresh_token(
&pair.refresh_token,
@@ -266,6 +269,7 @@ impl TestApp {
self.state
.db
.create_session(NewSession {
id: session_id,
user_id,
refresh_token_hash: hash,
platform: "web".to_string(),
+1
View File
@@ -81,6 +81,7 @@ async fn session_create_lookup_revoke() {
let sess = db
.create_session(NewSession {
id: Uuid::new_v4(),
user_id: user.id,
refresh_token_hash: hash.clone(),
platform: "extension".to_string(),
+129 -4
View File
@@ -2,14 +2,20 @@
//! create-by-npub path (no BTC signature) and the `seed_fingerprint` handling.
//! A fingerprint match onto a pre-existing row is REFUSED: the endpoint
//! proves only npub control, so it must not bind an npub onto an account keyed by
//! another (on-file) credential without proof. Skips without `TEST_DATABASE_URL`
//! (see `common`).
//! another (on-file) credential without proof. The reverse direction is here too:
//! the same wallet arriving at `/auth/extension` afterwards ADOPTS its npub-native
//! row (it proves control of the BTC key already on file) instead of splitting the
//! identity in two. Skips without `TEST_DATABASE_URL` (see `common`).
mod common;
use axum::http::StatusCode;
use base64::Engine;
use k256::ecdsa::signature::hazmat::PrehashSigner;
use k256::ecdsa::{Signature as EcdsaSignature, SigningKey as EcdsaSigningKey};
use k256::elliptic_curve::sec1::ToEncodedPoint;
use k256::schnorr::SigningKey;
use k256::SecretKey;
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};
@@ -17,6 +23,28 @@ use smirk_backend_core::core::crypto::nip98::{descriptor_sha256, request_descrip
const KIND: u32 = 27235;
/// A fresh secp256k1 wallet identity: the ECDSA signing key plus its compressed
/// SEC1 public key hex (the exact string the server hashes into `pubkey_hash`).
fn btc_identity() -> (EcdsaSigningKey, String) {
let secret = SecretKey::random(&mut OsRng);
let public = hex::encode(secret.public_key().to_encoded_point(true).as_bytes());
(EcdsaSigningKey::from(&secret), public)
}
/// BIP-137 base64 signature over the Bitcoin signed-message hash of `message`:
/// 65 bytes, a header byte (which the verifier drops) followed by r||s.
fn sign_bip137(sk: &EcdsaSigningKey, message: &str) -> String {
let mut preimage = Vec::new();
preimage.extend_from_slice(b"\x18Bitcoin Signed Message:\n");
preimage.push(message.len() as u8); // every message signed here is < 253 bytes
preimage.extend_from_slice(message.as_bytes());
let hash = Sha256::digest(Sha256::digest(&preimage));
let sig: EcdsaSignature = 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)
}
fn random_signer() -> SigningKey {
loop {
let mut b = [0u8; 32];
@@ -65,6 +93,26 @@ async fn register(
sk: &SigningKey,
base: &str,
seed_fingerprint: Option<&str>,
) -> (StatusCode, serde_json::Value) {
let pk = hex::encode(sk.verifying_key().to_bytes());
register_with_btc(
app,
sk,
base,
seed_fingerprint,
&format!("btcpub-{}", &pk[..16]),
)
.await
}
/// Like [`register`], but with an explicit BTC public key: the adoption test
/// needs a REAL secp256k1 key it can sign `/auth/extension` with afterwards.
async fn register_with_btc(
app: &common::TestApp,
sk: &SigningKey,
base: &str,
seed_fingerprint: Option<&str>,
btc_public_key: &str,
) -> (StatusCode, serde_json::Value) {
let (_s, chal) = app
.request("GET", "/api/v1/auth/nostr/register-challenge", None, None)
@@ -72,11 +120,10 @@ async fn register(
let nonce = chal["nonce"].as_str().unwrap().to_string();
let url = format!("{}/auth/nostr/register", base);
let token = sign_register(sk, &url, &nonce);
let pk = hex::encode(sk.verifying_key().to_bytes());
let body = serde_json::json!({
"nostr_token": token,
"nonce": nonce,
"keys": [{ "asset": "btc", "public_key": format!("btcpub-{}", &pk[..16]) }],
"keys": [{ "asset": "btc", "public_key": btc_public_key }],
"seed_fingerprint": seed_fingerprint,
});
app.request("POST", "/api/v1/auth/nostr/register", None, Some(body))
@@ -220,3 +267,81 @@ async fn register_refuses_to_bind_npub_onto_existing_seed_fingerprint_row_withou
"the victim's row must not have gained an npub"
);
}
#[tokio::test]
async fn extension_login_adopts_the_npub_native_row_instead_of_splitting_it() {
// Regression: `nostr_register` mints its row with pubkey_hash NULL, so the
// SAME wallet's first /auth/extension matched nothing by pubkey and minted a
// SECOND row, stranding the handle, tips and premium state on the first.
// Same seed AND the BTC key already on file (whose control the extension
// request proves): one wallet, so the row adopts the pubkey_hash.
let app = require_app!();
let base = app
.state
.cfg()
.identity
.public_api_url
.clone()
.expect("PUBLIC_API_URL");
let sk = random_signer();
let npub = hex::encode(sk.verifying_key().to_bytes());
let (btc_sk, btc_pk) = btc_identity();
let fp = hex::encode(Sha256::digest(uuid::Uuid::new_v4().as_bytes()));
// npub-native registration: the row carries the BTC key but no pubkey_hash.
let (status, body) = register_with_btc(&app, &sk, &base, Some(&fp), &btc_pk).await;
assert_eq!(status, StatusCode::OK, "body={body}");
let user_id = body["user"]["id"].as_str().unwrap().to_string();
let row = app
.state
.db
.find_user_by_nostr_pubkey(&npub)
.await
.unwrap()
.expect("npub row");
assert!(
row.pubkey_hash.is_none(),
"an npub-native row starts without a pubkey_hash"
);
// The same wallet now signs in through the extension path.
let ts = chrono::Utc::now().timestamp();
let (s2, b2) = app
.request(
"POST",
"/api/v1/auth/extension",
None,
Some(serde_json::json!({
"keys": [{ "asset": "btc", "public_key": btc_pk }],
"seed_fingerprint": fp,
"signed_timestamp": ts,
"signature": sign_bip137(&btc_sk, &format!("smirk-auth-{ts}")),
})),
)
.await;
assert_eq!(s2, StatusCode::OK, "body={b2}");
assert_eq!(b2["is_new"], false, "this wallet already had an account");
assert_eq!(
b2["user"]["id"].as_str().unwrap(),
user_id,
"the extension login must resolve to the SAME user row"
);
// One identity, not two: the row now answers to the pubkey_hash as well, and
// it kept its Nostr identity.
let pkh = hex::encode(Sha256::digest(btc_pk.as_bytes()));
let by_pubkey = app
.state
.db
.get_user_by_pubkey_hash(&pkh)
.await
.unwrap()
.expect("the pubkey_hash now resolves");
assert_eq!(by_pubkey.id.to_string(), user_id);
assert_eq!(
by_pubkey.nostr_pubkey.as_deref(),
Some(npub.as_str()),
"the adopted row keeps its Nostr identity"
);
}
+65 -1
View File
@@ -1,4 +1,5 @@
//! L1 integration: the per-IP rate limiter actually throttles a burst.
//! L1 integration: the per-IP rate limiter actually throttles a burst, and it
//! keys on the client IP the trusted-proxy policy resolves.
//!
//! The strict tier wraps the unauthenticated auth/website surface. The governor
//! runs before the handler, so an unauthenticated burst to a strict-tier route
@@ -8,6 +9,8 @@
mod common;
use axum::http::StatusCode;
use ipnetwork::IpNetwork;
use std::net::IpAddr;
#[tokio::test]
async fn strict_tier_throttles_a_burst() {
@@ -25,3 +28,64 @@ async fn strict_tier_throttles_a_burst() {
"a rapid burst to a strict-tier endpoint should be rate-limited (429)"
);
}
#[tokio::test]
async fn spoofed_forwarded_for_from_an_untrusted_peer_shares_one_bucket() {
let app = require_app!();
// Default config trusts no proxies, so the loopback peer the harness injects
// is untrusted and its X-Forwarded-For must be ignored. If the limiter keyed
// on the header, every request below would mint a fresh bucket and nothing
// would ever throttle.
let mut saw_429 = false;
for i in 0..40 {
let spoofed = format!("203.0.113.{i}");
let (status, _, _) = app
.request_full(
"GET",
"/api/v1/auth/me",
&[("x-forwarded-for", spoofed.as_str())],
None,
)
.await;
if status == StatusCode::TOO_MANY_REQUESTS {
saw_429 = true;
break;
}
}
assert!(
saw_429,
"a spoofed X-Forwarded-For from an untrusted peer must not buy its own bucket"
);
}
#[tokio::test]
async fn trusted_proxy_gets_a_bucket_per_forwarded_client() {
// With the peer inside `trusted_proxies`, X-Forwarded-For IS the client, so
// distinct clients behind the proxy must not share (and exhaust) one bucket:
// that is the whole point of keying on the resolved client IP.
let maybe_app = common::try_app_with(|cfg| {
cfg.trusted_proxies = vec![IpNetwork::from(IpAddr::from([127, 0, 0, 1]))];
})
.await;
let Some(app) = maybe_app else {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
};
for i in 0..40 {
let client = format!("198.51.100.{i}");
let (status, _, _) = app
.request_full(
"GET",
"/api/v1/auth/me",
&[("x-forwarded-for", client.as_str())],
None,
)
.await;
assert_ne!(
status,
StatusCode::TOO_MANY_REQUESTS,
"one request per forwarded client must not trip the limiter ({client})"
);
}
}