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