Files
smirk-backend-core/src/api/erasure.rs
T
jwinterm 8120d93fb8 fix(erasure): harden per adversarial review (Subsystem 5)
6 confirmed findings (review wqrd2gka3), 3 root-cause fixes:

- HIGH: execute_erasure had no status recheck and the sweeper's SKIP LOCKED row
  lock was released before execution (separate connection), so (a) a request
  CANCELLED after the sweep's SELECT was still executed, and (b) a fleet could
  double-execute one erasure (duplicate hash-chained audit row, double count).
  Fixed by making execution atomically CLAIM the row as its first statement —
  `UPDATE ... SET status='completed' WHERE id=$1 AND status='confirmed'
  RETURNING user_id` — and skipping (returns false) when 0 rows match. Only the
  first executor of a still-confirmed request proceeds; a cancelled/already-done
  row is a no-op; on any later failure the whole tx (claim included) rolls back.
- account-existence oracle: the absent-user request path returned a FRESH random
  erasure_id + advancing scheduled_for each call, while a real account returned a
  STABLE get-or-create id — a re-call distinguisher. The request response is now
  constant-shape: a STABLE id (real, or a deterministic integrity-secret-keyed
  synthetic for the absent path), a fixed "pending" status, and NO scheduled_for
  (it's meaningful only at confirm, where grace starts, and is returned there).
- config: ERASURE_GRACE_PERIOD_HOURS is bounded (<= 10 years) in validate(), so
  the u64->i64 cast can't wrap negative (defeating grace) and Duration::hours
  can't panic.

Dismissed after verification: ERASURE_ENABLED without FEATURE_NOSTR_IDENTITY
(validate already requires PUBLIC_API_URL; the nostr_identity coupling is a
nicety). Tests extended: cancelled-request-not-executed (HIGH regression),
double-execute no-op. 140 lib + 140 integration green, no drift, scans clean.
2026-06-28 07:59:52 -04:00

414 lines
14 KiB
Rust

//! Self-service erasure (operator §5) — the one sensitive operation reachable on
//! the public plane, so a user who no longer trusts the backend can reach in.
//!
//! Every action is gated by a fresh NIP-98 *signed action* (a session token is
//! NEVER sufficient), bound to a single-use server nonce + a canonical request
//! descriptor + (for confirm/cancel) the explicit `erasure_id`. Erasure may only
//! target the `users.id` that the proof's key resolves to (via the linked
//! `nostr_pubkey`) — a leaked identifier is never a selector. Two-phase
//! (request -> confirm) with a grace window; a background sweeper executes.
//!
//! Threat model (documented): erasure authority == key control == account
//! compromise already, so this is an operator-distrust/regret exit, not an
//! anti-theft control (key rotation is that). The grace window's value is
//! operator error/regret.
use std::sync::Arc;
use axum::{
extract::{Path, State},
routing::{delete, post},
Json, Router,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tracing::{info, instrument, warn};
use uuid::Uuid;
use crate::core::crypto::nip98::{descriptor_sha256, request_descriptor, verify_signed_action};
use crate::error::AppError;
use crate::models::db::{NewAdminAudit, User};
use crate::AppState;
const CHALLENGE_TTL_SECS: i64 = 300;
const ACTION_MAX_AGE_SECS: i64 = 30;
const PURPOSE_REQUEST: &str = "erasure_request";
const PURPOSE_CONFIRM: &str = "erasure_confirm";
const PURPOSE_CANCEL: &str = "erasure_cancel";
const PURPOSE_EXPORT: &str = "erasure_export";
// ── helpers ──────────────────────────────────────────────────────────────────
fn ensure_enabled(state: &AppState) -> Result<(), AppError> {
if state.config.retention.erasure_enabled {
Ok(())
} else {
Err(AppError::NotFound("erasure is not enabled".into()))
}
}
/// The signed-action `u`-tag base for `path` (e.g. `/account/erasure`), from
/// `PUBLIC_API_URL` (which already includes the `/api/v1` prefix).
fn erasure_url(state: &AppState, path: &str) -> Result<String, AppError> {
let base = state
.config
.identity
.public_api_url
.as_deref()
.ok_or_else(|| AppError::NotFound("erasure is not enabled".into()))?;
Ok(format!("{}{}", base.trim_end_matches('/'), path))
}
fn subject_hash(npub: &str) -> String {
hex::encode(Sha256::digest(npub.as_bytes()))
}
fn integrity_secret(state: &AppState) -> &str {
&state.config.admin.key_integrity_secret
}
#[derive(Debug, Deserialize)]
pub struct ChallengeRequest {
pub purpose: String,
}
#[derive(Debug, Serialize)]
pub struct ChallengeResponse {
pub challenge: String,
pub expires_in: i64,
}
#[derive(Debug, Deserialize)]
pub struct ProofRequest {
/// `Nostr <base64(event)>` signed-action token.
pub token: String,
/// The server-issued single-use nonce the action binds.
pub nonce: String,
}
#[derive(Debug, Serialize)]
pub struct ErasureRequestResponse {
pub erasure_id: String,
pub status: String,
}
#[derive(Debug, Serialize)]
pub struct StatusResponse {
pub status: String,
/// When execution is scheduled (set on confirm, when grace starts).
#[serde(skip_serializing_if = "Option::is_none")]
pub scheduled_for: Option<String>,
}
/// Deterministic, unguessable erasure id for the absent-user path — STABLE across
/// repeated calls (keyed by the integrity secret), so it is indistinguishable
/// from a real get-or-create-stable id and cannot be probed as an existence
/// oracle.
fn synthetic_erasure_id(pubkey: &str, secret: &str) -> String {
let mut h = Sha256::new();
h.update(secret.as_bytes());
h.update([0x1f]);
h.update(b"erasure-synthetic");
h.update([0x1f]);
h.update(pubkey.as_bytes());
let digest = h.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest[..16]);
Uuid::from_bytes(bytes).to_string()
}
/// Verify a proof for `purpose` over `action_path`, consume the nonce, and
/// resolve the signer to a user (if any). The opaque `Err` is a single 401.
async fn prove(
state: &Arc<AppState>,
body: &ProofRequest,
purpose: &str,
action_path: &str,
target: Option<(&str, &str)>,
) -> Result<(String, Option<User>), AppError> {
let fail = || AppError::AuthError("Invalid erasure proof".into());
let url = erasure_url(state, action_path)?;
let descriptor_path = format!("/api/v1{action_path}");
let payload = descriptor_sha256(&request_descriptor("POST", &descriptor_path, "", b""));
let pubkey = verify_signed_action(
&body.token,
&url,
"POST",
purpose,
&body.nonce,
&payload,
target,
None,
Utc::now().timestamp(),
ACTION_MAX_AGE_SECS,
)
.map_err(|_| fail())?;
// Single-use: consume AFTER the signature checks (a forged sig can't burn a
// victim's nonce; a replay loses the race).
if state
.db
.consume_challenge(&body.nonce, purpose)
.await?
.is_none()
{
return Err(fail());
}
let user = state.db.find_user_by_nostr_pubkey(&pubkey).await?;
Ok((pubkey, user))
}
// ── handlers ─────────────────────────────────────────────────────────────────
/// Issue a single-use nonce for one of the erasure purposes.
#[instrument(skip(state, req))]
pub async fn challenge(
State(state): State<Arc<AppState>>,
Json(req): Json<ChallengeRequest>,
) -> Result<Json<ChallengeResponse>, AppError> {
ensure_enabled(&state)?;
if !matches!(
req.purpose.as_str(),
PURPOSE_REQUEST | PURPOSE_CONFIRM | PURPOSE_CANCEL | PURPOSE_EXPORT
) {
return Err(AppError::ValidationError("unknown erasure purpose".into()));
}
let challenge = state
.db
.issue_challenge(&req.purpose, None, CHALLENGE_TTL_SECS)
.await?;
Ok(Json(ChallengeResponse {
challenge,
expires_in: CHALLENGE_TTL_SECS,
}))
}
/// Phase 1: request erasure. The absent-user path returns the identical shape
/// (no row written) so it does not become an account-existence oracle.
#[instrument(skip(state, body))]
pub async fn request_erasure(
State(state): State<Arc<AppState>>,
Json(body): Json<ProofRequest>,
) -> Result<Json<ErasureRequestResponse>, AppError> {
ensure_enabled(&state)?;
let grace = state.config.retention.grace_period_hours as i64;
let (pubkey, user) = prove(&state, &body, PURPOSE_REQUEST, "/account/erasure", None).await?;
// Constant-shape across present/absent AND across repeated calls: a STABLE
// erasure_id (real for a user, deterministic-synthetic otherwise) and a fixed
// "pending" status. No scheduled_for here (it is meaningful only at confirm,
// when grace starts) — echoing a stable-vs-advancing timestamp would leak
// account existence. The real scheduled_for is returned by confirm.
let erasure_id = match user {
Some(u) => {
let req = state
.db
.request_erasure(u.id, &subject_hash(&pubkey), grace)
.await?;
let _ = state
.db
.record_admin_audit(
&audit("account_erasure_requested", &req.id),
integrity_secret(&state),
)
.await;
info!(erasure_id = %req.id, "erasure requested");
req.id.to_string()
}
None => synthetic_erasure_id(&pubkey, integrity_secret(&state)),
};
Ok(Json(ErasureRequestResponse {
erasure_id,
status: "pending".into(),
}))
}
/// Phase 2: confirm (a second fresh proof bound to this `erasure_id`). Revokes
/// all the user's live sessions and starts the grace clock.
#[instrument(skip(state, body))]
pub async fn confirm_erasure(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(body): Json<ProofRequest>,
) -> Result<Json<StatusResponse>, AppError> {
ensure_enabled(&state)?;
let id_str = id.to_string();
let path = format!("/account/erasure/{id_str}/confirm");
let (_pubkey, user) = prove(
&state,
&body,
PURPOSE_CONFIRM,
&path,
Some(("erasure_id", &id_str)),
)
.await?;
let user = user.ok_or_else(|| AppError::NotFound("no such erasure request".into()))?;
let grace = state.config.retention.grace_period_hours as i64;
let req = state
.db
.confirm_erasure_request(id, user.id, grace)
.await?
.ok_or_else(|| AppError::NotFound("no pending erasure request".into()))?;
// Revoke live sessions immediately on confirmation.
let _ = state.db.revoke_all_user_sessions(user.id).await;
let _ = state
.db
.record_admin_audit(
&audit("account_erasure_confirmed", &req.id),
integrity_secret(&state),
)
.await;
info!(erasure_id = %req.id, "erasure confirmed");
Ok(Json(StatusResponse {
status: req.status,
scheduled_for: Some(req.scheduled_for.to_rfc3339()),
}))
}
/// Cancel a live erasure during grace (a fresh `erasure_cancel` proof).
#[instrument(skip(state, body))]
pub async fn cancel_erasure(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(body): Json<ProofRequest>,
) -> Result<Json<StatusResponse>, AppError> {
ensure_enabled(&state)?;
let id_str = id.to_string();
let path = format!("/account/erasure/{id_str}");
let (_pubkey, user) = prove(
&state,
&body,
PURPOSE_CANCEL,
&path,
Some(("erasure_id", &id_str)),
)
.await?;
let user = user.ok_or_else(|| AppError::NotFound("no such erasure request".into()))?;
let req = state
.db
.cancel_erasure_request(id, user.id)
.await?
.ok_or_else(|| AppError::NotFound("no live erasure request".into()))?;
let _ = state
.db
.record_admin_audit(
&audit("account_erasure_cancelled", &req.id),
integrity_secret(&state),
)
.await;
Ok(Json(StatusResponse {
status: req.status,
scheduled_for: None,
}))
}
#[derive(Debug, Serialize)]
pub struct ExportKey {
pub asset: String,
pub public_key: String,
pub public_spend_key: Option<String>,
pub key_type: String,
}
#[derive(Debug, Serialize)]
pub struct ExportResponse {
pub user_id: String,
pub username: Option<String>,
pub nostr_pubkey: Option<String>,
pub created_at: String,
pub keys: Vec<ExportKey>,
/// Deliberately omits the server's copy of any view key (the holder already
/// has the seed; echoing it would create a new exfil path).
pub note: &'static str,
}
/// See-before-delete export of what the backend holds about the account. Strong
/// proof; omits view keys. (A hard per-day rate cap is a documented follow-up;
/// the signed-action proof is the load-bearing control.)
#[instrument(skip(state, body))]
pub async fn export(
State(state): State<Arc<AppState>>,
Json(body): Json<ProofRequest>,
) -> Result<Json<ExportResponse>, AppError> {
ensure_enabled(&state)?;
let (_pubkey, user) = prove(&state, &body, PURPOSE_EXPORT, "/account/export", None).await?;
let user = user.ok_or_else(|| AppError::AuthError("Invalid erasure proof".into()))?;
let keys = state
.db
.get_user_keys(user.id)
.await?
.into_iter()
.map(|k| ExportKey {
asset: k.asset.to_string(),
public_key: k.public_key,
public_spend_key: k.public_spend_key,
key_type: k.key_type,
})
.collect();
let _ = state
.db
.record_admin_audit(
&NewAdminAudit {
action: "account_exported".into(),
actor_kind: "user".into(),
actor_pubkey_prefix: None,
target: Some(user.id.to_string()),
details: None,
ip_address: None,
},
integrity_secret(&state),
)
.await;
Ok(Json(ExportResponse {
user_id: user.id.to_string(),
username: user.username,
nostr_pubkey: user.nostr_pubkey,
created_at: user.created_at.to_rfc3339(),
keys,
note: "the backend stores no spend key or seed; view keys are not echoed",
}))
}
fn audit(action: &str, erasure_id: &Uuid) -> NewAdminAudit {
NewAdminAudit {
action: action.into(),
actor_kind: "user".into(),
actor_pubkey_prefix: None,
target: Some(erasure_id.to_string()),
details: None,
ip_address: None,
}
}
/// Run one execution sweep: delete confirmed-past-grace accounts. Called by the
/// background sweeper. Returns the number executed.
#[instrument(skip(state))]
pub async fn run_erasure_sweep(state: &Arc<AppState>, batch: i64) -> Result<u64, AppError> {
let due = state.db.due_erasure_requests(batch).await?;
let purge = state.config.retention.purge_login_events;
let secret = integrity_secret(state);
let mut done = 0;
for req in due {
match state.db.execute_erasure(req.id, purge, secret).await {
Ok(true) => done += 1,
// false = another node/sweep already claimed it, or it was cancelled.
Ok(false) => {}
Err(e) => warn!(erasure_id = %req.id, error = %e, "erasure execution failed"),
}
}
Ok(done)
}
/// Erasure routes, RELATIVE to the `/api/v1` mount point. Public (proof-gated).
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/account/erasure/challenge", post(challenge))
.route("/account/erasure", post(request_erasure))
.route("/account/erasure/:id/confirm", post(confirm_erasure))
.route("/account/erasure/:id", delete(cancel_erasure))
.route("/account/export", post(export))
}