mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-28 11:48:38 +01: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:
+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))
|
||||
}
|
||||
|
||||
|
||||
+28
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user