Files
smirk-backend-core/src/main.rs
T
jw 81a3e4cf58 fix: pre-ship audit remediation for auth identity, abuse control and retention
Fixes every confirmed backend finding from the v0.3.0 pre-ship review.

Identity (the two that corrupt accounts):
- nostr_register creates a row with pubkey_hash NULL, so that wallet's later
  /auth/extension call matched nothing, fell through the rotation path (which no
  client ever satisfies, since nothing sends rotation_signature) and minted a
  SECOND account. A fingerprint match on a NULL-pubkey_hash row now adopts the
  submitted pubkey_hash onto that row, but only when the row's on-file BTC key
  equals the submitted one, so a bare fingerprint (which check_restore discloses,
  and is therefore not a secret) still cannot claim an account.
- The fingerprint-ownership check no longer short-circuits on returning_by_pubkey.
  It was skipped for returning users, so a fingerprint owned by another row got
  backfilled, violated the UNIQUE index, and 500'd that account on every
  subsequent login.
- The rotation proof is verified over a distinct, instance- and target-bound
  message. It shared the login message, so a captured login signature was a
  replayable rotation proof against another instance.

Abuse control:
- The rate limiters key on the real client IP, honouring X-Forwarded-For only
  from a trusted proxy. Behind the TLS terminator every request looked like
  127.0.0.1, so the whole internet shared one bucket and ~3 req/s 429'd every
  wallet unlock globally.
- A PoW solution is single-use instead of replayable for the challenge TTL.
- Delegated /verify is opt-in and rate-limited; it was an unauthenticated
  amplifier into Electrum, LWS and a full-genesis grin scan.
- A grin scan with no start_height gets the same depth policy and PoW price as
  one that passes a height, instead of an unbounded free scan.

Retention and logging:
- The retention sweep is implemented AND scheduled. RETENTION_* was advertised
  and inert, which made the privacy policy's retention promise untrue. Zero
  means keep forever, and each statement is batched so a backlog drains over
  ticks rather than holding row locks.
- login_events.ip_hash is no longer written, and legacy values are scrubbed:
  nothing ever read it, so it was retention without a purpose.
- Tracing spans log user_id only, not the raw npub and username.
- secret-scan.sh matches the coverage its comment claims.

Also fixes the test suite, which did not compile on this commit: NewSession
gained an id in c64cd9b and two call sites were never updated. mint_session now
binds one session_id for both the token and the row, since a different id there
would silently recreate the bug c64cd9b fixed.
2026-08-13 10:31:02 -04:00

473 lines
21 KiB
Rust

//! smirk-backend-core server entry point.
//!
//! Loads and validates configuration (fail-closed), connects the database and
//! runs migrations, builds the shared [`AppState`], and serves the HTTP API.
//! Routes are added as handler modules land; for now it serves health.
use std::sync::Arc;
use sqlx::postgres::PgPoolOptions;
use smirk_backend_core::{
admin_router, build_router, config::Config, core::admin_session::AdminSessionManager,
core::session::SessionManager, infra::chains::ChainClients, infra::db::Database, infra::prices,
infra::prices::PriceSnapshot, AppState,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();
let _ = dotenvy::dotenv();
// Fail-closed: aborts on a weak/missing/inconsistent secret. This is the env-only
// truth; the effective config below layers the validated DB overlay on top.
let config_base = Config::from_env()?;
tracing::info!(environment = %config_base.environment, "configuration loaded");
let pool = PgPoolOptions::new()
.max_connections(20)
.connect(&config_base.database_url)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let db = Database::new(
pool,
config_base.secrets.seed_fingerprint_pepper.clone(),
config_base.secrets.ip_salt.clone(),
);
// Effective config = env-derived base + validated DB overlay (operator settings).
// Overlay rows are MAC'd under ADMIN_KEY_INTEGRITY_SECRET; without that anchor
// (admin disabled) we stay env-only. A persisted overlay that no longer validates
// is ignored (boot env-only + loud log) rather than bricking the instance. Every
// client/worker/router below is built from `config` (the effective view).
let overlay = if config_base.admin.key_integrity_secret.is_empty() {
smirk_backend_core::config_overlay::SettingsOverlay::default()
} else {
db.load_settings_overlay(&config_base.admin.key_integrity_secret)
.await?
};
let config = match config_base.apply_overlay(&overlay) {
Ok(effective) => effective,
Err(e) => {
tracing::error!(error = %e, "persisted config overlay invalid; ignoring it, booting env-only");
config_base.clone()
}
};
let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours);
let chains = ChainClients::from_config(&config)?;
// Payment processor for the pay-to-register gate (None unless it's enabled).
let payment = smirk_backend_core::infra::payment::from_config(&config)?;
// Optional Nostr relay (messaging plane); None unless RELAY_ENABLED.
let relay = smirk_backend_core::infra::relay::from_config(&config)?;
// First-run bootstrap latch (operator §3.2). Only meaningful with the admin
// surface enabled. Fail closed on a tampered latch (restore-to-pre-bootstrap).
if config.admin.enabled {
use smirk_backend_core::infra::db::SetupState;
let secret = &config.admin.key_integrity_secret;
match db.read_setup_state(secret).await? {
SetupState::Fresh => {
// Adopt an existing deployment as already-bootstrapped (so a live
// upgrade never exposes a setup window); a truly empty DB begins
// uninitialized, awaiting `smirk-admin setup`. Adopt on EITHER
// wallet users OR admin keys, so deleting the latch row on a
// CLI-bootstrapped-but-no-users instance re-latches Locked rather
// than silently downgrading to uninitialized.
let adopt = db.has_any_users().await? || db.has_any_admin_keys().await?;
db.init_server_config(secret, adopt).await?;
if adopt {
tracing::info!("existing deployment adopted: bootstrap latched (locked)");
} else {
tracing::warn!(
"fresh install: no admin yet — run `smirk-admin setup --pubkey <hex>`"
);
}
}
SetupState::Uninitialized => {
tracing::warn!("not yet bootstrapped — run `smirk-admin setup --pubkey <hex>`")
}
SetupState::Locked => tracing::info!("bootstrap latch: locked"),
SetupState::Tampered => {
return Err(
"server_config bootstrap latch MAC invalid (tamper or restore-to-pre-bootstrap); \
run `smirk-admin reset-setup --i-understand` if this is intentional"
.into(),
);
}
}
}
let addr = format!("{}:{}", config.server_host, config.server_port);
let prices_cache = Arc::new(tokio::sync::RwLock::new(PriceSnapshot::empty(
&config.features.prices_currency,
)));
let admin_sessions = config
.admin
.enabled
.then(|| AdminSessionManager::new(&config.admin.jwt_secret));
let state = Arc::new(AppState {
config: Arc::new(arc_swap::ArcSwap::from_pointee(config)),
config_base: Arc::new(config_base),
db,
sessions,
chains,
payment,
relay,
web_challenges: Arc::default(),
prices: prices_cache,
admin_sessions,
shutdown: Arc::new(tokio::sync::Notify::new()),
});
// Periodic GC of expired website-auth challenges. Bounds the single-node
// in-memory store so the unauthenticated challenge endpoint cannot grow it
// without limit. (The fleet path moves this state to a shared store.)
{
let challenges = state.web_challenges.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(120));
loop {
tick.tick().await;
challenges.write().await.retain(|_, c| !c.is_expired());
}
});
}
// Periodic expiry of stale Grin relay slatepacks (only when the relay is
// enabled). The respond/finalize paths already reject expired relays in their
// UPDATE guard; this sweep flips past-TTL rows to Expired so the table stays
// bounded and the lifecycle reflects reality.
if state.cfg().features.grin_relay {
let db = state.db.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600));
loop {
tick.tick().await;
if let Err(e) = db.expire_old_slatepacks().await {
tracing::warn!(error = %e, "grin slatepack expiry sweep failed");
}
}
});
}
// Periodic expiry sweep for the unified challenge store. The consume query
// already ignores expired rows; this just bounds the table.
{
let db = state.db.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(600));
loop {
tick.tick().await;
if let Err(e) = db.delete_expired_challenges().await {
tracing::warn!(error = %e, "challenge expiry sweep failed");
}
}
});
}
// Background erasure execution: confirmed requests past their grace window
// are deleted (per-table policy + cascade + hash-chained audit, one tx each).
if state.cfg().retention.erasure_enabled {
let state = state.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600));
loop {
tick.tick().await;
match smirk_backend_core::api::erasure::run_erasure_sweep(&state, 50).await {
Ok(n) if n > 0 => tracing::info!(executed = n, "erasure sweep"),
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "erasure sweep failed"),
}
}
});
}
// 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
// tick fires immediately, so prices populate at startup.
if state.cfg().features.prices {
let f = &state.cfg().features;
match prices::PriceClient::new(&f.prices_provider, &f.prices_currency, &f.prices_assets) {
Ok(client) if !client.is_empty() => {
let cache = state.prices.clone();
let period = prices::refresh_interval(f.prices_interval_secs);
tokio::spawn(async move {
let mut tick = tokio::time::interval(period);
loop {
tick.tick().await;
match client.fetch().await {
Ok(prices) => {
let mut snap = cache.write().await;
snap.prices = prices;
snap.updated_at = Some(chrono::Utc::now());
}
Err(e) => tracing::warn!(error = %e, "price refresh failed"),
}
}
});
}
Ok(_) => tracing::info!("price feed enabled but no assets configured; serving none"),
Err(e) => tracing::warn!(error = %e, "price client init failed; feed disabled"),
}
}
// Admin plane: a SEPARATE loopback listener (confidentiality is by socket,
// not middleware ordering). The fail-closed non-loopback bind guard and the
// browser/Host hardening land with the admin-posture subsystem; for now bind
// to the configured (loopback-default) address and warn if it isn't local.
if state.cfg().admin.enabled {
let admin_addr = state.cfg().admin.bind.clone();
// Fail-closed bind guard: refuse a non-loopback admin bind unless the
// operator explicitly opts in (confidentiality is by socket). Resolve the
// address (so "localhost" works) and require EVERY resolved addr to be
// loopback — a prefix check would accept "localhost.evil.com".
use std::net::ToSocketAddrs;
let is_loopback = match admin_addr.to_socket_addrs() {
Ok(addrs) => {
let addrs: Vec<_> = addrs.collect();
!addrs.is_empty() && addrs.iter().all(|a| a.ip().is_loopback())
}
Err(_) => false,
};
if !is_loopback && !state.cfg().admin.allow_public_bind {
return Err(format!(
"ADMIN_BIND {admin_addr} is not loopback; set ADMIN_ALLOW_PUBLIC_BIND=true to override \
(and front it with Tor client-auth / a trusted proxy)"
)
.into());
}
let admin_app = admin_router(state.clone());
let admin_listener = tokio::net::TcpListener::bind(&admin_addr).await?;
tracing::info!("admin plane listening on http://{admin_addr}");
tokio::spawn(async move {
if let Err(e) = axum::serve(
admin_listener,
admin_app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
{
tracing::error!(error = %e, "admin plane server exited");
}
});
}
// Nostr relay event-admission (nauthz) gRPC service — only for a non-`open`
// write policy (open needs no admission). Loopback socket, like the admin
// plane; nostr-rs-relay is configured to call it per inbound event.
if let Some(relay) = state.relay.clone() {
use smirk_backend_core::infra::relay::WritePolicy;
if relay.write_policy() != WritePolicy::Open {
let addr: std::net::SocketAddr = state
.cfg()
.messaging
.relay
.admission_bind
.parse()
.map_err(|e| format!("invalid RELAY_ADMISSION_BIND: {e}"))?;
let db = state.db.clone();
tracing::info!("relay event-admission (nauthz) listening on {addr}");
tokio::spawn(async move {
let result = smirk_backend_core::infra::relay::nauthz::serve(addr, relay, db).await;
// nostr-rs-relay fails OPEN if the admission service is unreachable
// (accepts every event). So a dead admission = an unrestricted
// relay. Fail SAFE: take the backend down so the operator's
// supervisor restarts a clean relay+admission pair, rather than
// leaving an open relay running behind a healthy-looking API.
tracing::error!(
?result,
"relay admission service stopped — exiting to avoid an unrestricted relay"
);
std::process::exit(1);
});
}
}
// Public-tips "money-in" worker: one funding pass per 60s (confirmation
// counting for XMR/WOW, then the amount verifier across all
// pending_confirmation tips). Only runs when tips are enabled. A panic in a
// single pass is caught + backed off so the loop survives (visible in logs)
// rather than silently dying and stranding every pending tip.
if state.cfg().features.tips {
use futures::FutureExt;
let state = state.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
let cycle = std::panic::AssertUnwindSafe(
smirk_backend_core::tips::run_tip_confirmation_cycle(state.clone()),
);
if cycle.catch_unwind().await.is_err() {
tracing::error!("tip confirmation cycle panicked; continuing after backoff");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
});
}
// Public-tips "money-out" reconciler: the settle-on-sweep-confirmation pass
// (probe each claiming tip's ADDRESS, settle claiming -> claimed once the
// sweep confirms to the per-asset depth). Sole new writer of 'claimed'. Runs
// an immediate startup pass (so a restart doesn't wait a full interval before
// settling anything that confirmed while we were down) then every 60s. Same
// catch_unwind guard as the money-in worker.
if state.cfg().features.tips {
use futures::FutureExt;
let state = state.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
let cycle = std::panic::AssertUnwindSafe(
smirk_backend_core::tips::run_sweep_reconcile_cycle(state.clone()),
);
if cycle.catch_unwind().await.is_err() {
tracing::error!("sweep reconcile cycle panicked; continuing after backoff");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
});
}
// Public-tips reorg-check pass: re-probe settled tips and revert any whose
// recorded sweep was orphaned by a chain reorg. Lowest-frequency, novel, and
// recoverable, so it runs on a slower cadence (300s) than the confirm pass.
if state.cfg().features.tips {
use futures::FutureExt;
let state = state.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
tick.tick().await;
let cycle = std::panic::AssertUnwindSafe(
smirk_backend_core::tips::run_sweep_reorg_cycle(state.clone()),
);
if cycle.catch_unwind().await.is_err() {
tracing::error!("sweep reorg cycle panicked; continuing after backoff");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
});
}
// Public-tips draft GC: hourly, cancel `draft` rows abandoned mid-flow (> 7
// days, never funded-attached) and warn per cancelled row. Same catch_unwind
// guard as the money-in/out workers.
if state.cfg().features.tips {
use futures::FutureExt;
let state = state.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(3600));
loop {
tick.tick().await;
let cycle = std::panic::AssertUnwindSafe(
smirk_backend_core::tips::run_tip_draft_gc_cycle(state.clone()),
);
if cycle.catch_unwind().await.is_err() {
tracing::error!("tip draft GC cycle panicked; continuing after backoff");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
});
}
// Public-tips lifecycle GC: every 5 min, cancel stuck `pending_confirmation`
// rows (> 7 days) and warn-only-scan stuck `claiming` rows (> 15 min). Same
// catch_unwind guard.
if state.cfg().features.tips {
use futures::FutureExt;
let state = state.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
tick.tick().await;
let cycle = std::panic::AssertUnwindSafe(
smirk_backend_core::tips::run_tip_lifecycle_gc_cycle(state.clone()),
);
if cycle.catch_unwind().await.is_err() {
tracing::error!("tip lifecycle GC cycle panicked; continuing after backoff");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
});
}
let shutdown = state.shutdown.clone();
let app = build_router(state);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("smirk-backend-core listening on http://{addr}");
// ConnectInfo carries the peer IP that the per-IP rate limiter keys on.
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal(shutdown))
.await?;
Ok(())
}
/// Completes on SIGTERM / Ctrl-C, or when the app signals a restart (an operator config
/// change in `auto` mode). Lets axum drain in-flight requests before exit; systemd
/// `Restart=always` then brings the instance back with the new config.
async fn shutdown_signal(app_triggered: Arc<tokio::sync::Notify>) {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let term = async {
if let Ok(mut sig) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
sig.recv().await;
}
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {}
_ = term => {}
_ = app_triggered.notified() => {}
}
tracing::info!("shutdown signal received; draining in-flight requests");
}