Files
jw 81a3e4cf58 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.
2026-08-13 10:31:02 -04:00

147 lines
4.4 KiB
Rust

//! L1 integration: the db query layer against a real Postgres. Validates the
//! migrations, peppered identity columns, UNIQUE->409 mapping, and the
//! session/key lifecycles that unit tests cannot reach without a database.
mod common;
use chrono::{Duration, Utc};
use smirk_backend_core::error::AppError;
use smirk_backend_core::models::db::{AssetType, NewSession, NewUser, NewUserKey};
use uuid::Uuid;
fn new_user(pubkey_hash: &str) -> NewUser {
NewUser {
username: None,
pubkey_hash: Some(pubkey_hash.to_string()),
nostr_pubkey: None,
wallet_birthday: None,
seed_fingerprint: None,
xmr_start_height: None,
wow_start_height: None,
}
}
#[tokio::test]
async fn create_user_then_peppered_lookup() {
let app = require_app!();
let db = &app.state.db;
let raw = format!("pk-{}", Uuid::new_v4());
let user = db.create_user(new_user(&raw)).await.unwrap();
// Raw pubkey hash resolves the row (peppering is applied symmetrically).
let found = db
.get_user_by_pubkey_hash(&raw)
.await
.unwrap()
.expect("resolved by pubkey_hash");
assert_eq!(found.id, user.id);
// The value stored at rest is peppered, NOT the raw input (seed-oracle fix).
assert_ne!(found.pubkey_hash.as_deref(), Some(raw.as_str()));
assert_eq!(
db.get_user_by_id(user.id).await.unwrap().unwrap().id,
user.id
);
}
#[tokio::test]
async fn username_unique_violation_is_conflict() {
let app = require_app!();
let db = &app.state.db;
let name = format!("u{}", Uuid::new_v4().simple());
let a = db
.create_user(new_user(&format!("pk-{}", Uuid::new_v4())))
.await
.unwrap();
let b = db
.create_user(new_user(&format!("pk-{}", Uuid::new_v4())))
.await
.unwrap();
db.set_username(a.id, &name).await.unwrap();
let err = db.set_username(b.id, &name).await.unwrap_err();
assert!(
matches!(err, AppError::Conflict(_)),
"expected 409 Conflict, got {err:?}"
);
}
#[tokio::test]
async fn session_create_lookup_revoke() {
let app = require_app!();
let db = &app.state.db;
let user = db
.create_user(new_user(&format!("pk-{}", Uuid::new_v4())))
.await
.unwrap();
let hash = format!("rh-{}", Uuid::new_v4());
let sess = db
.create_session(NewSession {
id: Uuid::new_v4(),
user_id: user.id,
refresh_token_hash: hash.clone(),
platform: "extension".to_string(),
device_info: None,
expires_at: Utc::now() + Duration::days(30),
})
.await
.unwrap();
assert!(db.get_session_by_token_hash(&hash).await.unwrap().is_some());
// First revoke succeeds (was active); the lookup then excludes it.
assert!(db.revoke_session(sess.id).await.unwrap());
assert!(db.get_session_by_token_hash(&hash).await.unwrap().is_none());
// Second revoke is a no-op (already revoked) -> false, disambiguating races.
assert!(!db.revoke_session(sess.id).await.unwrap());
}
#[tokio::test]
async fn user_key_upsert_list_delete() {
let app = require_app!();
let db = &app.state.db;
let user = db
.create_user(new_user(&format!("pk-{}", Uuid::new_v4())))
.await
.unwrap();
let mk = |pk: &str| NewUserKey {
user_id: user.id,
asset: AssetType::Btc,
public_key: pk.to_string(),
public_spend_key: None,
key_type: "primary".to_string(),
};
db.upsert_user_key(mk("pk1")).await.unwrap();
// Same (user, asset, key_type) upserts in place rather than duplicating.
db.upsert_user_key(mk("pk2")).await.unwrap();
let keys = db.get_user_keys(user.id).await.unwrap();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].public_key, "pk2");
assert!(db.delete_user_key(user.id, AssetType::Btc).await.unwrap());
assert!(db.get_user_keys(user.id).await.unwrap().is_empty());
}
#[tokio::test]
async fn restore_attempts_count_and_pepper() {
let app = require_app!();
let db = &app.state.db;
let fp = format!("fp-{}", Uuid::new_v4());
assert_eq!(db.count_failed_restore_attempts(&fp).await.unwrap(), 0);
db.record_restore_attempt(&fp, Some("203.0.113.7"), false)
.await
.unwrap();
db.record_restore_attempt(&fp, Some("203.0.113.7"), false)
.await
.unwrap();
assert_eq!(db.count_failed_restore_attempts(&fp).await.unwrap(), 2);
}