mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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 inc64cd9band 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 bugc64cd9bfixed.
This commit is contained in:
+24
-1
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+21
-7
@@ -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 {
|
||||
//
|
||||
// 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?;
|
||||
state.cfg().restore.enforce("grin", h, tip)?;
|
||||
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,
|
||||
h,
|
||||
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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user