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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
jw
2026-08-13 10:31:02 -04:00
co-authored by Claude Opus 5
parent 23659dfd5a
commit 0a6ae18f80
15 changed files with 989 additions and 81 deletions
+24 -1
View File
@@ -17,10 +17,33 @@ speak for you. What your instance receives from wallets that connect to it:
- A one-way **seed fingerprint** (SHA-256) as a non-reversible wallet identifier.
- If enabled: the **username to npub** mapping published in your NIP-05 directory,
and, for the relay, encrypted message envelopes (ciphertext only).
- IP addresses only as a **salted one-way hash**, for rate-limiting.
- IP addresses **never in raw form**. The per-IP rate limiter keeps its counters
in memory, and the only IP-derived value written to disk is a salted one-way
hash on `restore_attempts`, which the seed-restore abuse limiter reads back.
Login events record no IP at all, and the raw-IP columns that older schemas
carried on `sessions` and `audit_logs` were dropped in migration
`20260722000001_privacy_drop_pii`. One exception, and it is about you rather
than your users: the tamper-evident operator audit chain (`admin_audit_logs`)
records the IP of privileged admin actions.
Private keys and seed phrases are never transmitted to the backend.
## Retention
Retention is enforced by the backend, not merely advertised. A background sweep
deletes `login_events` older than `RETENTION_LOGIN_EVENTS_DAYS` and `audit_logs`
older than `RETENTION_AUDIT_DAYS`, in bounded batches so a long-running instance
drains its backlog without stalling writers. Setting either knob to `0` means
keep indefinitely, so an unset value never destroys data by surprise.
The `admin_audit_logs` chain is deliberately exempt: it is hash-chained and
MAC'd, so a deleted row reads as tampering to the verifier. Prune it on purpose
if your policy calls for it.
Self-service erasure (`ERASURE_ENABLED`) is separate and user-initiated: a
confirmed request deletes the account and its owned rows after a grace window,
purging or anonymizing that user's login events per `ERASURE_PURGE_LOGIN_EVENTS`.
If you offer your instance to others, publish your own privacy policy describing
how you handle this data, your retention, and your jurisdiction. The knobs that
affect retention and data exposure (login-event and audit retention, erasure,
+137 -25
View File
@@ -9,7 +9,9 @@
//! keyed by `pubkey_hash`. A derivation-scheme rotation (a known
//! `seed_fingerprint` at a new `pubkey_hash`) re-points an EXISTING user row
//! ONLY when the request additionally proves control of the BTC key already on
//! file for that user — never on a bare (unauthenticated) fingerprint.
//! file for that user — never on a bare (unauthenticated) fingerprint. The same
//! rule adopts a `pubkey_hash` onto a row that has none yet (an npub-native
//! registration): the BTC key on file must be the submitted one.
//! * [`nostr_login`] — NIP-98 (login grade) over the `Authorization` header.
//! Resolves an already-linked npub to its user; it NEVER creates a user.
//! * [`refresh_token`] — rotates a valid, still-active refresh token.
@@ -192,6 +194,31 @@ fn nip98_url(state: &AppState, path: &str) -> Result<String, AppError> {
Ok(format!("{}{}", base.trim_end_matches('/'), path))
}
/// The message a derivation-rotation proof must sign.
///
/// Deliberately DISTINCT from the ordinary login message (`smirk-auth-{ts}`), so
/// a captured login signature is useless as a rotation proof. It also commits to
/// the NEW `pubkey_hash` (a proof cannot be re-aimed at a different key) and to
/// this instance's canonical public API URL (a proof captured by a hostile
/// self-hosted instance cannot be replayed against another one).
///
/// `None` when the instance has no canonical URL configured: the proof cannot be
/// instance-bound, so we decline to rotate rather than accept a replayable proof.
fn rotation_message(
state: &AppState,
new_pubkey_hash: &str,
signed_timestamp: i64,
) -> Option<String> {
let cfg = state.cfg();
let instance = cfg.identity.public_api_url.as_deref()?;
Some(format!(
"smirk-rotate-v1|{}|{}|{}",
instance.trim_end_matches('/'),
new_pubkey_hash,
signed_timestamp
))
}
/// Build [`UserInfo`] from a DB user.
fn user_info(user: &crate::models::db::User) -> UserInfo {
UserInfo {
@@ -296,7 +323,9 @@ pub struct ExtensionRegisterRequest {
pub wallet_birthday: Option<i64>,
/// Seed fingerprint `hex(SHA256(SHA256(seed))[..])`. Used for restore and to
/// LOCATE a candidate user row for the derivation-rotation path. By itself it
/// is NOT authority: a rotation also requires `rotation_signature` below.
/// is NOT authority: re-pointing an existing row additionally requires either
/// `rotation_signature` below or that the row's on-file BTC key is exactly the
/// one submitted in `keys` (whose control this request already proves).
pub seed_fingerprint: Option<String>,
pub xmr_start_height: Option<i64>,
pub wow_start_height: Option<i64>,
@@ -305,12 +334,18 @@ pub struct ExtensionRegisterRequest {
/// BIP-137 base64 signature of `smirk-auth-{signed_timestamp}` under the
/// SUBMITTED (new) BTC key. Proves control of the key in `keys`.
pub signature: String,
/// Derivation-rotation proof: BIP-137 base64 signature of the SAME
/// `smirk-auth-{signed_timestamp}` message under the BTC key ALREADY ON FILE
/// for the user identified by `seed_fingerprint`. Required to re-point an
/// existing user row; without it (or if it does not verify against the stored
/// key) a fingerprint match is treated as a brand-new identity and the
/// existing row is never touched. See [`extension_register`].
/// Derivation-rotation proof: BIP-137 base64 signature under the BTC key
/// ALREADY ON FILE for the user identified by `seed_fingerprint`, over the
/// DEDICATED rotation message
/// `smirk-rotate-v1|{public_api_url}|{pubkey_hash}|{signed_timestamp}`, where
/// `public_api_url` is this instance's canonical API base with any trailing
/// `/` removed and `pubkey_hash` is `hex(SHA256(submitted btc public_key))`.
/// It is deliberately NOT the login message: a captured login signature must
/// be useless as a rotation proof, and a proof minted for one instance must
/// not verify on another. Required to re-point an existing user row; without
/// it (or if it does not verify against the stored key) a fingerprint match is
/// treated as a brand-new identity and the existing row is never touched. See
/// [`extension_register`].
#[serde(default)]
pub rotation_signature: Option<String>,
/// Optional proof-of-work solution. Required when the PoW gate applies to
@@ -346,10 +381,24 @@ pub struct ExtensionRegisterRequest {
/// request also carries a `rotation_signature` that verifies against the BTC key
/// already on file for that user (proving control of the seed-derived key, not
/// merely knowledge of the fingerprint, which `check_restore` discloses and is
/// not secret). A bare fingerprint match WITHOUT a valid rotation proof is
/// treated as a brand-new identity: a fresh user row is created on the new
/// `pubkey_hash` and the matched victim row is never modified. The rotation path
/// is gated by PoW exactly like any other new-pubkey registration.
/// not secret). That proof signs a dedicated message, NOT the login message: see
/// [`rotation_message`], which binds a rotation-specific prefix, the new
/// `pubkey_hash`, the timestamp and this instance's canonical public API URL, so
/// a captured login signature is worthless here and a proof minted for one
/// instance does not verify on another. A bare fingerprint match WITHOUT a valid
/// rotation proof is treated as a brand-new identity: a fresh user row is created
/// on the new `pubkey_hash` and the matched victim row is never modified. The
/// rotation path is gated by PoW exactly like any other new-pubkey registration.
///
/// ## Adopting a `pubkey_hash` onto a keyless row
///
/// A row minted by [`nostr_register`] carries no `pubkey_hash`, so that wallet's
/// first call here matches nothing by pubkey. When the fingerprint matches such a
/// row AND the BTC key on file for it is exactly the key submitted (whose control
/// this request already proved), it is the same wallet: the submitted
/// `pubkey_hash` is adopted onto that row (`is_new=false`) instead of minting a
/// second identity that would strand the handle, tips and premium state. If the
/// row already has a `pubkey_hash`, or the BTC keys differ, nothing is adopted.
#[utoipa::path(
post,
path = "/auth/extension",
@@ -395,12 +444,11 @@ pub async fn extension_register(
// literal-messaged, so this is not an oracle.
verify_bitcoin_signature(&message, &req.signature, &btc_key.public_key)?;
// Is the exact pubkey_hash already known? (Plain returning user.)
let returning_by_pubkey = state
.db
.get_user_by_pubkey_hash(&pubkey_hash)
.await?
.is_some();
// Is the exact pubkey_hash already known? (Plain returning user.) Keep the
// ROW, not just the boolean: the fingerprint-ownership check below needs its
// id to tell this user's own fingerprint from someone else's.
let existing_by_pubkey = state.db.get_user_by_pubkey_hash(&pubkey_hash).await?;
let returning_by_pubkey = existing_by_pubkey.is_some();
let wallet_birthday = req
.wallet_birthday
@@ -418,12 +466,66 @@ pub async fn extension_register(
// we cannot authenticate a rotation -> fall through to new-identity.
let stored_btc = state.db.get_user_key(target.id, AssetType::Btc).await?;
let rotation_proven = match (&req.rotation_signature, &stored_btc) {
(Some(sig), Some(stored)) => {
// Adopt the submitted pubkey_hash onto a row that has none yet.
// `nostr_register` mints its row with pubkey_hash NULL, so the
// same wallet's first /auth/extension matches nothing by pubkey
// and would otherwise mint a SECOND row, stranding the handle,
// tips and premium state on the first. Same seed, same BTC key:
// it is one wallet, so adopt rather than split the identity.
//
// The submitted `signature` was already verified against the
// submitted BTC key above, so requiring the on-file key to EQUAL
// it means the caller proved control of the key this row already
// carries. A bare fingerprint (which check_restore discloses, so
// it is not a secret) can never claim an account this way.
let adoptable = target.pubkey_hash.is_none()
&& stored_btc
.as_ref()
.is_some_and(|stored| stored.public_key == btc_key.public_key);
if adoptable {
// A move, not a mint (`is_new=false`): this row was already
// gated when it was created, so the invite / pay-to-register
// gates do not re-run here, exactly as on the rotation branch
// below. PoW still applies to the new pubkey.
enforce_pow(&state, &pubkey_hash_lc, false, req.altcha_solution.as_ref())?;
info!(user_id = %target.id, "adopting pubkey_hash onto keyless row");
state.db.update_pubkey_hash(target.id, &pubkey_hash).await?;
upsert_all_keys(&state, target.id, &req.keys).await?;
let pair =
issue_session(&state, target.id, Platform::Extension, "Browser Extension")
.await?;
let _ = state
.db
.record_login_event(
Some(target.id),
"btc",
Platform::Extension.as_str(),
None,
Some(&ip.to_string()),
)
.await;
return Ok(Json(AuthResponse {
access_token: pair.access_token,
refresh_token: pair.refresh_token,
expires_in: pair.expires_in,
user: user_info(&target),
is_new: false,
}));
}
// The rotation proof signs a DEDICATED message (see
// [`rotation_message`]), never the login message: a captured
// login signature must be useless as a rotation proof, and a
// proof minted for one instance must not verify on another.
let rotation_msg = rotation_message(&state, &pubkey_hash, req.signed_timestamp);
let rotation_proven = match (&req.rotation_signature, &stored_btc, &rotation_msg) {
(Some(sig), Some(stored), Some(msg)) => {
// Control of the on-file (seed-derived) key proves seed
// ownership. A bad signature is rejected (not an oracle:
// we simply decline to rotate and create a new identity).
verify_bitcoin_signature(&message, sig, &stored.public_key).is_ok()
verify_bitcoin_signature(msg, sig, &stored.public_key).is_ok()
}
_ => false,
};
@@ -489,13 +591,23 @@ pub async fn extension_register(
req.altcha_solution.as_ref(),
)?;
// Never attach a seed_fingerprint that already belongs to ANOTHER user (it
// would collide on the UNIQUE or hijack the lookup); a returning user only
// backfills NULLs anyway. Used as the new row's fingerprint below.
// would collide on the UNIQUE or hijack the lookup). This runs on EVERY path,
// returning users included: backfilling a fingerprint owned by another row
// violates that UNIQUE and then 500s every later login for this account. A
// fingerprint already on THIS user's row is its own, so it is kept.
let fingerprint_for_row = match &req.seed_fingerprint {
Some(fp) => {
let belongs_to_other =
!returning_by_pubkey && state.db.get_user_by_seed_fingerprint(fp).await?.is_some();
let owner = state.db.get_user_by_seed_fingerprint(fp).await?;
let belongs_to_other = match (&owner, &existing_by_pubkey) {
(Some(owner), Some(me)) => owner.id != me.id,
(Some(_), None) => true,
(None, _) => false,
};
if belongs_to_other {
warn!(
"extension_register: submitted seed_fingerprint belongs to another row; \
not attaching it"
);
None
} else {
Some(fp.clone())
+24 -1
View File
@@ -12,6 +12,13 @@
//! at deploy time by an nginx allow-list to moon.vote's mesh IP; there is no code
//! auth here on purpose (adding one would reject the fixed peer client).
//!
//! OFF BY DEFAULT (see [`routes`]): unauthenticated plus resource-expensive (it
//! drives Electrum, the LWS backends and a full grin-wallet rewind scan on
//! caller-supplied input) makes this an amplifier pointed at the operator's own
//! infrastructure, so an instance that has not negotiated the moon.vote peering
//! does not mount the route at all. When it IS enabled it sits on the strict
//! rate-limit tier; the nginx allow-list remains the primary containment.
//!
//! Response discipline: EVERY business outcome — including a failed check — is a
//! `200 OK` carrying `{verified, sender, amount, reason}`, where `reason` is drawn
//! from the fixed vocabulary in [`reason`] that the moon.vote client maps to typed
@@ -424,8 +431,24 @@ pub async fn verify_handler(
// ── router ────────────────────────────────────────────────────────────────────
/// The verify route, RELATIVE to the `/api/v1` mount point.
/// Whether the operator has turned the delegated-verify endpoint on. Default OFF:
/// the handler is unauthenticated and spends the operator's own Electrum / LWS /
/// grin-wallet capacity on caller-supplied addresses and view credentials, so an
/// instance that has not asked for the moon.vote peering must not expose it.
/// Read through the one flag parser every feature flag uses, so "on" means the
/// same thing here as everywhere else; an unset or unparseable value is off.
pub(crate) fn delegated_verify_enabled() -> bool {
crate::api::capabilities::env_flag_enabled("FEATURE_DELEGATED_VERIFY")
}
/// The verify route, RELATIVE to the `/api/v1` mount point. Returns an EMPTY
/// router unless the operator enabled the feature, so a request to `/verify`
/// then 404s (the route does not exist) rather than reaching the handler. The
/// caller mounts this on the strict rate-limit tier.
pub fn routes() -> Router<Arc<AppState>> {
if !delegated_verify_enabled() {
return Router::new();
}
Router::new().route("/verify", post(verify_handler))
}
+28 -14
View File
@@ -52,6 +52,12 @@ fn grin_client(state: &AppState) -> Result<&GrinClient, AppError> {
pub struct GrinScanRequest {
/// The wallet's `rewind_hash` (64 hex). Forwarded to grin-wallet; not stored.
pub rewind_hash: String,
// Omitting this is NOT a free full-chain scan: `scan` resolves it to the
// deepest height this instance permits and gates/prices THAT (see the handler).
// A client that must solve the restore PoW should send an explicit height,
// because the nonce binds to the height actually scanned. Kept out of the doc
// comment so the committed `openapi.json` (drift-gated in CI) stays byte-equal;
// regenerate the spec if this is ever promoted to `///`.
/// Scan from this block height (wallet birthday / last scanned). Omit for full.
pub start_height: Option<u64>,
/// Restore proof-of-work nonce. Required when the instance prices the
@@ -200,23 +206,30 @@ pub async fn scan(
// Restore-depth + PoW gate runs ONCE, before path selection, and is TERMINAL:
// a policy rejection returns 400 directly. It must never be masked by the
// grin-wallet fallback, so it is enforced before either scan path is chosen.
if let Some(h) = req.start_height {
let tip = grin_tip(&state).await?;
state.cfg().restore.enforce("grin", h, tip)?;
state.cfg().restore.enforce_restore_pow(
"grin",
&req.rewind_hash,
h,
tip,
req.restore_pow_nonce,
)?;
}
//
// An OMITTED start_height used to skip this block entirely, so leaving the
// field out bought an unbounded full-chain scan for free while an honest
// caller paid for depth. Resolve the omission to the deepest height this
// instance permits and gate/price THAT, then scan from it: both callers now
// pay the same for the same work.
let tip = grin_tip(&state).await?;
let start_height = req
.start_height
.unwrap_or_else(|| state.cfg().restore.min_start_height("grin", tip));
state.cfg().restore.enforce("grin", start_height, tip)?;
state.cfg().restore.enforce_restore_pow(
"grin",
&req.rewind_hash,
start_height,
tip,
req.restore_pow_nonce,
)?;
// Default to grin-lws when configured. It is trusted ONLY when provably synced
// to the tip; otherwise (and on any transport error) the scan falls back to
// the authoritative grin-wallet scan below.
if let Some(lws) = state.chains.grin_lws.as_ref() {
match scan_via_grin_lws(lws, &req).await {
match scan_via_grin_lws(lws, &req, start_height).await {
Ok(Some(resp)) => return Ok(Json(resp)),
Ok(None) => {} // still backfilling — fall through to grin-wallet
Err(e) => {
@@ -229,7 +242,7 @@ pub async fn scan(
// synthesize an empty/zero success.
let client = grin_client(&state)?;
let view = client
.scan_rewind_hash(&req.rewind_hash, req.start_height)
.scan_rewind_hash(&req.rewind_hash, Some(start_height))
.await?;
Ok(Json(GrinScanResponse {
outputs: view.output_result.into_iter().map(output_dto).collect(),
@@ -265,8 +278,9 @@ async fn grin_tip(state: &AppState) -> Result<u64, AppError> {
async fn scan_via_grin_lws(
lws: &GrinLwsClient,
req: &GrinScanRequest,
start_height: u64,
) -> Result<Option<GrinScanResponse>, AppError> {
lws.register(&req.rewind_hash, req.start_height).await?;
lws.register(&req.rewind_hash, Some(start_height)).await?;
let bal = lws.get_balance(&req.rewind_hash).await?;
// Trust grin-lws only when its scan has effectively reached the tip.
// `saturating_add` so a hostile/garbled `scanned_height` can never overflow
+83 -2
View File
@@ -6,7 +6,9 @@
//! enabled (config validation rejects a missing/placeholder key — there is no
//! source-visible fallback). Wire protocol matches the wallet's `altcha-lib`.
use std::time::{SystemTime, UNIX_EPOCH};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use altcha::{
create_challenge, verify_solution, CreateChallengeOptions, Payload, VerifySolutionOptions,
@@ -43,8 +45,47 @@ pub fn issue_challenge(cfg: &PowConfig) -> Result<altcha::Challenge, AppError> {
.map_err(|e| AppError::Internal(format!("PoW challenge creation failed: {}", e)))
}
/// Solutions already spent, keyed by the challenge's HMAC signature (unique per
/// issued challenge, and covered by the HMAC we verify, so a replay cannot alter
/// it) and valued by when the spend was recorded. Entries older than the
/// challenge TTL are dropped: past that point the signed expiry rejects the
/// payload anyway, so the map is bounded by the number of DISTINCT solves inside
/// one TTL window, each of which cost an attacker a full solve.
///
/// In-process, like the website-challenge store on `AppState`: the verify path is
/// synchronous and holds no database handle, so a shared store is the
/// load-balanced-fleet path. Within one node a solve is spent exactly once.
fn spent_solutions() -> &'static Mutex<HashMap<String, Instant>> {
static SPENT: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
SPENT.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Record `signature` as spent, returning `false` if it already was. The prune
/// and the spend happen inside ONE locked section, so two concurrent
/// verifications of the same solution cannot both win: the loser's insert finds
/// the winner's entry and is rejected.
fn spend_solution(signature: &str) -> bool {
let now = Instant::now();
let ttl = Duration::from_secs(CHALLENGE_TTL_SECONDS);
// A poisoned lock only means some other thread panicked while holding it; the
// map itself is still consistent, and refusing to serve would turn a panic
// elsewhere into a permanent registration outage.
let lock = spent_solutions().lock();
let mut spent = lock.unwrap_or_else(PoisonError::into_inner);
spent.retain(|_, seen| now.duration_since(*seen) < ttl);
// `insert` hands back the previous value, so `None` proves this solve had not
// been spent yet: the first caller through the lock wins, every later one is
// told to re-fetch a challenge.
spent.insert(signature.to_string(), now).is_none()
}
/// Verify a client-submitted solution. `Ok(())` only on a valid, unexpired,
/// correctly-signed solution.
/// correctly-signed solution that has NOT been presented before.
///
/// Single use matters as much as the work itself: the signature stays valid for
/// the whole [`CHALLENGE_TTL_SECONDS`] window, so without spending the challenge
/// one solve would pay for unlimited registrations and the gate would cost an
/// attacker nothing per account.
pub fn verify_payload(cfg: &PowConfig, payload: &Payload) -> Result<(), AppError> {
let opts =
VerifySolutionOptions::new(&payload.challenge, &payload.solution, cfg.hmac_key.as_str());
@@ -65,6 +106,18 @@ pub fn verify_payload(cfg: &PowConfig, payload: &Payload) -> Result<(), AppError
reason
)));
}
// The signature names the challenge this solution belongs to. `verify_solution`
// has just proved it is ours and unexpired, so a missing one is unreachable;
// fail closed anyway rather than spending an empty key.
let signature = payload.challenge.signature.as_deref().ok_or_else(|| {
AppError::ValidationError("PoW solution rejected (missing signature)".into())
})?;
if !spend_solution(signature) {
return Err(AppError::ValidationError(
"PoW solution rejected (already used; re-fetch the challenge)".into(),
));
}
Ok(())
}
@@ -100,6 +153,34 @@ mod tests {
verify_payload(&cfg, &solve(&cfg)).expect("valid solution verifies");
}
#[test]
fn solution_is_single_use() {
let cfg = test_cfg();
let p = solve(&cfg);
verify_payload(&cfg, &p).expect("first use accepted");
// The signed challenge stays valid for its whole TTL, so replay protection
// is the only thing between one solve and unlimited registrations.
assert!(
verify_payload(&cfg, &p).is_err(),
"a spent solution must not verify a second time"
);
}
#[test]
fn concurrent_uses_of_one_solution_have_one_winner() {
let cfg = test_cfg();
let p = solve(&cfg);
// Both racers are spawned before either is joined, so they contend for the
// same solve; the spend is atomic, so the loser must be rejected outright
// rather than both being let through.
let (first, second) = std::thread::scope(|scope| {
let a = scope.spawn(|| verify_payload(&cfg, &p).is_ok());
let b = scope.spawn(|| verify_payload(&cfg, &p).is_ok());
(a.join().unwrap(), b.join().unwrap())
});
assert_ne!(first, second, "exactly one racer may spend a solve");
}
#[test]
fn tampered_signature_rejected() {
let cfg = test_cfg();
+137 -14
View File
@@ -1,8 +1,13 @@
//! Login event analytics.
//!
//! Privacy-minded: the client IP is stored only as a salted hash (via
//! [`Database::hash_ip`]), never raw. Rows carry a soft FK to the user so they
//! can be purged on erasure; a retention sweep removes old rows.
//! Privacy-minded: no client IP is stored here at all. The salted `ip_hash` this
//! table used to carry was never read by anything (the per-IP governor is
//! in-memory and the restore limiter keys on `restore_attempts.ip_hash`), so it
//! was retained personal data with no purpose; [`Database::run_retention_sweep`]
//! also NULLs any values earlier builds wrote. Rows carry a soft FK to the user
//! so they can be purged on erasure, and the sweep ages the rest out.
use std::sync::atomic::{AtomicBool, Ordering};
use sqlx::FromRow;
use tracing::instrument;
@@ -12,6 +17,20 @@ use crate::error::AppError;
use super::Database;
/// Ceiling on a configured retention window, in days (~2700 years). A nonsense
/// operator value must widen the window, never push `NOW() - interval` out of
/// the timestamp range, which would abort the sweep instead of keeping rows.
const MAX_RETENTION_DAYS: u64 = 1_000_000;
/// Latched once the legacy `ip_hash` scrub has nothing left to do, so a converged
/// deployment stops paying for a scan of an unindexed column every tick.
static IP_HASH_SCRUB_DONE: AtomicBool = AtomicBool::new(false);
/// Days as the `int` `make_interval` wants, saturating rather than wrapping.
fn interval_days(days: u64) -> i32 {
days.min(MAX_RETENTION_DAYS) as i32
}
/// Aggregated login stats (analytics / optional public landing).
#[derive(Debug, FromRow)]
pub struct LoginStats {
@@ -22,26 +41,30 @@ pub struct LoginStats {
}
impl Database {
/// Record a login event. `ip` is the raw client IP; it is salted-hashed here.
#[instrument(skip(self, origin, ip))]
/// Record a login event.
///
/// `_ip` is accepted and deliberately dropped: the sign-in call sites already
/// hold the peer address, but nothing ever consumed the hash this used to
/// store, and an unread per-user IP hash is retention without a purpose.
/// Abuse control that does need an IP reads `restore_attempts.ip_hash` or the
/// in-memory governor.
#[instrument(skip(self, origin, _ip))]
pub async fn record_login_event(
&self,
user_id: Option<Uuid>,
asset: &str,
platform: &str,
origin: Option<&str>,
ip: Option<&str>,
_ip: Option<&str>,
) -> Result<(), AppError> {
let ip_hash = ip.map(|v| self.hash_ip(v));
sqlx::query(
"INSERT INTO login_events (user_id, asset, platform, origin, ip_hash) \
VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO login_events (user_id, asset, platform, origin) \
VALUES ($1, $2, $3, $4)",
)
.bind(user_id)
.bind(asset)
.bind(platform)
.bind(origin)
.bind(ip_hash)
.execute(self.pool())
.await?;
Ok(())
@@ -74,15 +97,115 @@ impl Database {
Ok(result.rows_affected())
}
/// Retention sweep: delete events older than `days`.
/// Retention: delete login events older than `days`, at most `batch` per call.
///
/// `days == 0` means keep forever and a non-positive `batch` is likewise a
/// no-op: an absent or zero operator setting must never be read as "delete
/// everything", so both directions fail towards keeping data. The bound
/// matters because the table is unpartitioned: one unbounded DELETE over a
/// year of backlog would hold row locks for the whole pass.
#[instrument(skip(self))]
pub async fn cleanup_old_login_events(&self, days: i32) -> Result<u64, AppError> {
pub async fn cleanup_old_login_events(&self, days: u64, batch: i64) -> Result<u64, AppError> {
if days == 0 || batch <= 0 {
return Ok(0);
}
let result = sqlx::query(
"DELETE FROM login_events WHERE created_at < NOW() - INTERVAL '1 day' * $1",
"DELETE FROM login_events WHERE id IN ( \
SELECT id FROM login_events \
WHERE created_at < NOW() - make_interval(days => $1) \
ORDER BY created_at LIMIT $2 FOR UPDATE SKIP LOCKED)",
)
.bind(days)
.bind(interval_days(days))
.bind(batch)
.execute(self.pool())
.await?;
Ok(result.rows_affected())
}
/// Retention: delete `audit_logs` rows older than `days`, same bounds and same
/// keep-forever-on-zero rule as the login sweep.
///
/// Deliberately NOT `admin_audit_logs`: that is a MAC'd hash chain whose
/// verifier walks every row, so a deletion there is indistinguishable from
/// tampering. Operator forensics is a separate policy from user retention.
#[instrument(skip(self))]
pub async fn cleanup_old_audit_logs(&self, days: u64, batch: i64) -> Result<u64, AppError> {
if days == 0 || batch <= 0 {
return Ok(0);
}
let result = sqlx::query(
"DELETE FROM audit_logs WHERE id IN ( \
SELECT id FROM audit_logs \
WHERE created_at < NOW() - make_interval(days => $1) \
ORDER BY created_at LIMIT $2 FOR UPDATE SKIP LOCKED)",
)
.bind(interval_days(days))
.bind(batch)
.execute(self.pool())
.await?;
Ok(result.rows_affected())
}
/// NULL any `ip_hash` left by builds that still wrote one, `batch` rows per
/// call. Covers rows the anonymizing erasure branch leaves behind too: it
/// clears `user_id`/`origin`/`asset` but not this column. Unconditional
/// (not retention-gated), because with retention set to keep-forever those
/// hashes would otherwise never go away.
#[instrument(skip(self))]
pub async fn scrub_legacy_login_ip_hashes(&self, batch: i64) -> Result<u64, AppError> {
if batch <= 0 || IP_HASH_SCRUB_DONE.load(Ordering::Relaxed) {
return Ok(0);
}
let result = sqlx::query(
"UPDATE login_events SET ip_hash = NULL WHERE id IN ( \
SELECT id FROM login_events WHERE ip_hash IS NOT NULL LIMIT $1)",
)
.bind(batch)
.execute(self.pool())
.await?;
let scrubbed = result.rows_affected();
// Short of a full batch means the LIMIT was never reached, so nothing is
// left to scrub and later passes can skip the scan.
if scrubbed < batch as u64 {
IP_HASH_SCRUB_DONE.store(true, Ordering::Relaxed);
}
Ok(scrubbed)
}
/// One retention pass: age out `login_events` and `audit_logs` per the
/// operator's configured windows, then scrub any legacy `ip_hash`. Returns the
/// rows deleted, for the caller to log.
///
/// Each statement is capped at `batch` rows, so a backlog drains over
/// successive ticks instead of in one long-running lock. An error aborts the
/// pass and is reported to the caller, which retries on the next tick.
#[instrument(skip(self))]
pub async fn run_retention_sweep(
&self,
login_events_days: u64,
audit_days: u64,
batch: i64,
) -> Result<u64, AppError> {
let logins = self.cleanup_old_login_events(login_events_days, batch).await?;
let audits = self.cleanup_old_audit_logs(audit_days, batch).await?;
let scrubbed = self.scrub_legacy_login_ip_hashes(batch).await?;
if scrubbed > 0 {
tracing::info!(rows = scrubbed, "scrubbed legacy login_events.ip_hash");
}
Ok(logins + audits)
}
}
#[cfg(test)]
mod tests {
use super::interval_days;
#[test]
fn interval_days_saturates_instead_of_wrapping() {
assert_eq!(interval_days(0), 0);
assert_eq!(interval_days(90), 90);
// A nonsense-large window must clamp to something Postgres can subtract
// from NOW(), never wrap negative (which would delete everything).
assert_eq!(interval_days(u64::MAX), 1_000_000);
}
}
+7 -7
View File
@@ -80,7 +80,7 @@ impl Database {
/// The payment invoice's settled-check is non-consuming and already done by the
/// caller; the invite has no non-consuming check, so it is claimed here and a
/// failed claim rolls the tx back with the caller-facing literal.
#[instrument(skip(self, new_user, keys))]
#[instrument(skip(self, invite_code_hash, payment, new_user, keys))]
pub async fn create_user_consuming_gates(
&self,
invite_code_hash: Option<&str>,
@@ -242,7 +242,7 @@ impl Database {
.await?)
}
#[instrument(skip(self))]
#[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");
Ok(sqlx::query_as::<_, User>(&sql)
@@ -253,7 +253,7 @@ impl Database {
/// Find a user by their linked Nostr pubkey (x-only hex; not peppered — it is
/// public and discoverable via NIP-05). `None` if unlinked.
#[instrument(skip(self))]
#[instrument(skip(self, nostr_pubkey))]
pub async fn find_user_by_nostr_pubkey(
&self,
nostr_pubkey: &str,
@@ -370,7 +370,7 @@ impl Database {
/// Link a Nostr pubkey to a user (NIP-98 sign-in). The UNIQUE constraint is
/// the atomic claim; a collision surfaces as 409 CONFLICT.
#[instrument(skip(self))]
#[instrument(skip(self, nostr_pubkey))]
pub async fn set_nostr_pubkey(
&self,
user_id: Uuid,
@@ -390,7 +390,7 @@ impl Database {
/// Get or create a user by pubkey hash (extension registration). For an
/// existing user, backfills only NULL `wallet_birthday` / `seed_fingerprint`
/// / chain start-heights; never overwrites an existing value.
#[instrument(skip(self, pubkey_hash, seed_fingerprint))]
#[instrument(skip(self, pubkey_hash, username, seed_fingerprint))]
pub async fn get_or_create_user_by_pubkey_hash(
&self,
pubkey_hash: &str,
@@ -458,7 +458,7 @@ impl Database {
/// npub onto THAT row. If the row already carries a DIFFERENT npub the
/// wallet is already registered under another identity -> fail closed.
/// 3. else create a fresh npub-keyed row (`pubkey_hash` NULL).
#[instrument(skip(self))]
#[instrument(skip(self, nostr_pubkey, username, seed_fingerprint))]
pub async fn get_or_create_user_by_nostr_pubkey(
&self,
nostr_pubkey: &str,
@@ -587,7 +587,7 @@ impl Database {
}
/// Update a user's username. UNIQUE collision -> 409 CONFLICT.
#[instrument(skip(self))]
#[instrument(skip(self, username))]
pub async fn update_username(
&self,
user_id: Uuid,
+48 -6
View File
@@ -18,13 +18,16 @@ pub mod models;
pub mod tips;
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use axum::extract::DefaultBodyLimit;
use axum::extract::{ConnectInfo, DefaultBodyLimit};
use axum::http::{header, HeaderValue, Method};
use axum::{routing::get, Router};
use tokio::sync::RwLock;
use tower_governor::errors::GovernorError;
use tower_governor::key_extractor::KeyExtractor;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use tower_http::compression::CompressionLayer;
use tower_http::cors::{Any, CorsLayer};
@@ -99,6 +102,33 @@ impl AppState {
}
}
/// Rate-limit key: the REAL client IP, resolved exactly like the audit/auth
/// surface resolves it ([`api::middleware::client_ip`]), so `X-Forwarded-For` is
/// honoured only when the immediate peer is inside `config.trusted_proxies`.
///
/// The default `PeerIpKeyExtractor` keys on the TCP peer, which behind a
/// TLS-terminating reverse proxy is ALWAYS the proxy: every client on the
/// internet then shares one bucket, so a few requests per second from one host
/// throttle everyone and there is no per-client abuse control at all.
#[derive(Clone)]
struct ClientIpKeyExtractor(Arc<AppState>);
impl KeyExtractor for ClientIpKeyExtractor {
type Key = IpAddr;
fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, GovernorError> {
// No ConnectInfo means the request cannot be attributed to a peer, so we
// fail closed rather than silently collapsing every caller into one bucket.
let peer = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| *addr)
.or_else(|| req.extensions().get::<SocketAddr>().copied())
.ok_or(GovernorError::UnableToExtractKey)?;
Ok(api::middleware::client_ip(&self.0, req.headers(), peer))
}
}
/// Assemble the full application router with every route mounted and state
/// applied. Shared by `main` and the integration-test harness so both exercise
/// the exact same wiring: `/health` and the NIP-05 directory at the root, the
@@ -106,11 +136,18 @@ impl AppState {
pub fn build_router(state: Arc<AppState>) -> Router {
let cors = cors_layer(&state.cfg());
// Per-IP rate limiting (PeerIpKeyExtractor reads ConnectInfo — `main` serves
// with `into_make_service_with_connect_info`; the test harness injects it).
// Per-IP rate limiting keyed by [`ClientIpKeyExtractor`], which reads
// ConnectInfo (`main` serves with `into_make_service_with_connect_info`; the
// test harness injects it) and the trusted-proxy-gated `X-Forwarded-For`, so
// each client gets its own bucket even behind the TLS terminator.
// Tiered: stricter on the unauthenticated auth/website surface (closes the
// website-challenge growth vector), looser on the wallet/identity API.
let mut strict_cfg = GovernorConfigBuilder::default();
let client_ip_key = ClientIpKeyExtractor(state.clone());
// `key_extractor` hands back a NEW builder (the extractor is part of its type),
// so it is applied before the tier's period/burst.
let mut strict_base = GovernorConfigBuilder::default();
let mut strict_cfg = strict_base.key_extractor(client_ip_key.clone());
strict_cfg
.per_millisecond(STRICT_PERIOD_MS)
.burst_size(STRICT_BURST);
@@ -118,7 +155,8 @@ pub fn build_router(state: Arc<AppState>) -> Router {
config: Arc::new(strict_cfg.finish().expect("valid strict governor config")),
};
let mut normal_cfg = GovernorConfigBuilder::default();
let mut normal_base = GovernorConfigBuilder::default();
let mut normal_cfg = normal_base.key_extractor(client_ip_key);
normal_cfg
.per_millisecond(NORMAL_PERIOD_MS)
.burst_size(NORMAL_BURST);
@@ -128,6 +166,11 @@ pub fn build_router(state: Arc<AppState>) -> Router {
let api_v1 = api::auth::routes()
.merge(api::website::routes())
// Delegated verification is unauthenticated and drives Electrum / the LWS
// backends / a grin-wallet scan on caller-supplied input, so it belongs on
// the strict tier. It is mounted only when the operator opts in; see
// [`api::verify::routes`].
.merge(api::verify::routes())
.layer(strict)
.merge(
api::users::routes()
@@ -138,7 +181,6 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.merge(api::erasure::routes())
.merge(api::premium::routes())
.merge(api::tips::routes())
.merge(api::verify::routes())
.layer(normal),
);
+36
View File
@@ -184,6 +184,42 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Retention sweep: age `login_events` and `audit_logs` out per the operator's
// configured windows, and scrub any legacy `login_events.ip_hash` earlier
// builds wrote. Without this the RETENTION_* settings are advertised but inert,
// which is what made the privacy policy's retention promise untrue.
//
// Both windows are re-read every tick so an operator edit applies without a
// restart. `days == 0` means keep forever and is enforced in the DB layer, so
// an absent setting can never be read as "delete everything". Each statement is
// capped at `RETENTION_BATCH` rows, so a backlog drains over successive ticks
// instead of holding row locks for one long pass.
{
const RETENTION_BATCH: i64 = 1_000;
let state = state.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
tick.tick().await;
// Copy the windows out before any await: never hold a config guard
// across one.
let (login_days, audit_days) = {
let r = &state.cfg().retention;
(r.login_events_days, r.audit_days)
};
match state
.db
.run_retention_sweep(login_days, audit_days, RETENTION_BATCH)
.await
{
Ok(n) if n > 0 => tracing::info!(rows = n, "retention sweep"),
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "retention sweep failed"),
}
}
});
}
// Background price refresh (only when the feed is enabled). On each tick we
// fetch the configured feeds and replace the snapshot; a failure logs and
// keeps the last good values rather than blanking them. The first interval
+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})"
);
}
}
+15 -4
View File
@@ -18,11 +18,22 @@ else
fi
# Secret shapes (not names), covering the classes this backend actually handles:
# - PEM/OpenSSH/PGP private-key blocks and seed-phrase markers
# - PEM/OpenSSH/PGP private-key blocks
# - seed-phrase markers (mnemonic / seed phrase), any capitalisation
# - nsec1... Nostr SECRET keys (bech32)
# - a *_PEPPER / *_SALT (or admin key/secret) assigned a 32+ hex-char value, i.e.
# an `openssl rand -hex 32` peppered secret pasted in cleartext
PATTERNS='(-----BEGIN ([A-Z ]+ )?PRIVATE KEY-----|-----BEGIN PGP PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY|\b(mnemonic|seed[_ ]?phrase)\b\s*[:=]|nsec1[0-9a-z]{20,}|\b[A-Z_]*(PEPPER|SALT|INTEGRITY_SECRET)[A-Z_]*\s*=\s*[0-9a-fA-F]{32,})'
# - a secret-shaped env assignment given a 32+ hex-char value, i.e. an
# `openssl rand -hex 32` value pasted in cleartext. The name alternation is
# spelled out so it really does cover every secret this backend takes:
# *_PEPPER, *_SALT, *_SECRET (incl. *_INTEGRITY_SECRET), *_PASSWORD, *_TOKEN,
# *_API_KEY, *_ADMIN_KEY, *_HMAC_KEY, *_PRIVATE_KEY, *_SIGNING_KEY. A bare
# *KEY* is NOT in the list on purpose: it would fire on ADMIN_PUBKEYS and
# friends, whose values are public.
#
# NOT covered, and left to gitleaks rather than implied here: secret VALUES that
# are not hex (base64 or random alphanumeric API keys, real passwords). Catching
# those needs entropy heuristics, which in this tree would fire on every address,
# hash and test vector and train people to ignore the scanner.
PATTERNS='(-----BEGIN ([A-Z ]+ )?PRIVATE KEY-----|-----BEGIN PGP PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY|\b([Mm]nemonic|MNEMONIC|[Ss]eed[_ ]?[Pp]hrase|SEED[_ ]?PHRASE)\b\s*[:=]|nsec1[0-9a-z]{20,}|\b[A-Z_]*(PEPPER|SALT|SECRET|PASSWORD|TOKEN|API_?KEY|ADMIN_KEY|HMAC_KEY|PRIVATE_KEY|SIGNING_KEY)[A-Z_]*\s*=\s*[0-9a-fA-F]{32,})'
fail=0
for f in "${FILES[@]}"; do