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:
+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})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user