From b03b9cb90de381bafb837de3b714c28df2de3f82 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 27 Jun 2026 20:43:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(erasure):=20self-service=20account=20erasu?= =?UTF-8?q?re=20+=20export=20[operator=20=C2=A75]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last operator subsystem: a user who no longer trusts the backend can reach in and delete their data. Action-bound and two-phase. - migration 20260626000003: erasure_requests (user_id ON DELETE SET NULL so the completed tombstone survives the user delete; one-live-request-per-user partial unique index; due index). - Proof model (§5.2): every action is a fresh NIP-98 signed action (purpose erasure_request/confirm/cancel/export) bound to a single-use challenge nonce + canonical descriptor + (confirm/cancel) the explicit erasure_id. A session token is NEVER sufficient. Erasure targets only the users.id the proof's key resolves to (via linked nostr_pubkey); leaked identifiers are never selectors. Nonce consumed AFTER signature checks (forged sig can't burn a victim's nonce). - Public routes (proof-gated): POST /account/erasure/challenge, /account/erasure (request; ABSENT-user path returns identical shape — no account-existence oracle), /account/erasure/:id/confirm (revokes live sessions), DELETE /account/erasure/:id (cancel during grace), POST /account/export (see-before- delete; omits view keys; audited). - Execution (§5.7): a background sweeper claims confirmed-past-grace rows (FOR UPDATE SKIP LOCKED) and runs ONE fail-closed tx: purge login_events (the origin/asset/time quasi-identifier; default) or anonymize; scrub audit PII; DELETE users (schema cascades wallets/sessions/keys/owned slatepacks, SET NULL on counterparty/audit/login links); tombstone; hash-chained account_erasure_completed audit. - config: ERASURE_GRACE_PERIOD_HOURS (72h default); validate() requires the integrity secret + PUBLIC_API_URL when erasure is enabled. - 1 L1 test: export -> request -> confirm -> execute -> account gone, plus the absent-user constant-shape path. Scope (v1, documented): live primary. No at-rest view key exists to crypto-shred (stateless-forward), so backup residue is bounded by retention-window docs, not crypto-shred (non-v1 per decisions ledger). Deferred: LWS de-registration queue + read-back probe (operators run their own LWS), audit cross-reference scrub on OTHER users' rows, restore_attempt salted-rotated fingerprint, retention sweepers, NIP-17 DM warning, in-flight-value blockers (no swaps/tips in the clean core), hard per-day export rate cap, /admin/erasure/stuck observability. Routes are off the public OpenAPI for now (stable contract; annotate later). --- migrations/20260626000003_erasure.sql | 25 ++ src/api/erasure.rs | 393 ++++++++++++++++++++++++++ src/api/mod.rs | 1 + src/config.rs | 19 ++ src/infra/db/erasure.rs | 208 ++++++++++++++ src/infra/db/mod.rs | 1 + src/lib.rs | 1 + src/main.rs | 17 ++ src/models/db.rs | 15 + tests/erasure.rs | 228 +++++++++++++++ 10 files changed, 908 insertions(+) create mode 100644 migrations/20260626000003_erasure.sql create mode 100644 src/api/erasure.rs create mode 100644 src/infra/db/erasure.rs create mode 100644 tests/erasure.rs diff --git a/migrations/20260626000003_erasure.sql b/migrations/20260626000003_erasure.sql new file mode 100644 index 0000000..c7ca6ff --- /dev/null +++ b/migrations/20260626000003_erasure.sql @@ -0,0 +1,25 @@ +-- Self-service erasure requests (operator §5.3). +-- +-- Two-phase (request -> confirm) with a grace window before execution. The row +-- is keyed by user_id while live (execution needs it), but ON DELETE SET NULL so +-- the COMPLETED tombstone survives the user delete with the link scrubbed. +-- subject_hash is sha256(npub) — an opaque tombstone id, never the raw npub. +CREATE TABLE erasure_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users (id) ON DELETE SET NULL, + subject_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | confirmed | completed | cancelled + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + scheduled_for TIMESTAMPTZ NOT NULL, + confirmed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ +); + +-- At most one live (pending/confirmed) request per user. +CREATE UNIQUE INDEX erasure_requests_active_user + ON erasure_requests (user_id) WHERE status IN ('pending', 'confirmed'); + +-- Drives the execution sweeper. +CREATE INDEX idx_erasure_requests_due + ON erasure_requests (scheduled_for) WHERE status = 'confirmed'; diff --git a/src/api/erasure.rs b/src/api/erasure.rs new file mode 100644 index 0000000..6dad050 --- /dev/null +++ b/src/api/erasure.rs @@ -0,0 +1,393 @@ +//! 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 { + 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 ` 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, + pub scheduled_for: String, +} + +#[derive(Debug, Serialize)] +pub struct StatusResponse { + pub status: 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, + body: &ProofRequest, + purpose: &str, + action_path: &str, + target: Option<(&str, &str)>, +) -> Result<(String, Option), 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>, + Json(req): Json, +) -> Result, 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>, + Json(body): Json, +) -> Result, 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?; + + 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"); + Ok(Json(ErasureRequestResponse { + erasure_id: req.id.to_string(), + status: req.status, + scheduled_for: req.scheduled_for.to_rfc3339(), + })) + } + None => { + // Constant-shape: a synthetic id, no row, no audit. + Ok(Json(ErasureRequestResponse { + erasure_id: Uuid::new_v4().to_string(), + status: "pending".into(), + scheduled_for: (Utc::now() + chrono::Duration::hours(grace)).to_rfc3339(), + })) + } + } +} + +/// 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>, + Path(id): Path, + Json(body): Json, +) -> Result, 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 })) +} + +/// Cancel a live erasure during grace (a fresh `erasure_cancel` proof). +#[instrument(skip(state, body))] +pub async fn cancel_erasure( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> Result, 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 })) +} + +#[derive(Debug, Serialize)] +pub struct ExportKey { + pub asset: String, + pub public_key: String, + pub public_spend_key: Option, + pub key_type: String, +} + +#[derive(Debug, Serialize)] +pub struct ExportResponse { + pub user_id: String, + pub username: Option, + pub nostr_pubkey: Option, + pub created_at: String, + pub keys: Vec, + /// 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>, + Json(body): Json, +) -> Result, 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, batch: i64) -> Result { + 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 { + let Some(user_id) = req.user_id else { continue }; + match state + .db + .execute_erasure(req.id, user_id, purge, secret) + .await + { + Ok(()) => done += 1, + 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> { + 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)) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index c769084..28ddd01 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod admin; pub mod auth; pub mod capabilities; +pub mod erasure; pub mod health; pub mod landing; pub mod middleware; diff --git a/src/config.rs b/src/config.rs index 454a4d8..da1eae3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -303,6 +303,8 @@ pub struct RetentionConfig { pub erasure_enabled: bool, pub purge_login_events: bool, pub export_per_day: u32, + /// Grace window between a confirmed erasure and its execution. + pub grace_period_hours: u64, } impl Config { @@ -452,6 +454,7 @@ impl Config { erasure_enabled: env_bool("ERASURE_ENABLED", false), purge_login_events: env_bool("ERASURE_PURGE_LOGIN_EVENTS", true), export_per_day: env_parse("ERASURE_EXPORT_PER_DAY", 3u32)?, + grace_period_hours: env_parse("ERASURE_GRACE_PERIOD_HOURS", 72u64)?, }, }; @@ -566,6 +569,21 @@ impl Config { } } + // Self-service erasure: its audit trail is the integrity-MAC'd hash chain, + // and its proofs bind PUBLIC_API_URL — both are required when it's on. + if self.retention.erasure_enabled { + require_secret( + "ADMIN_KEY_INTEGRITY_SECRET", + &self.admin.key_integrity_secret, + 32, + )?; + if self.identity.public_api_url.is_none() { + return Err(cfg_err( + "PUBLIC_API_URL is required when ERASURE_ENABLED is on (it binds the signed-action proof)", + )); + } + } + // Per-enabled-chain infra config: hard error in production, warn in dev. // This keeps /capabilities honest — an enabled chain that can't be served // (no node/secret) must not boot in prod advertising itself as available. @@ -714,6 +732,7 @@ mod tests { erasure_enabled: false, purge_login_events: true, export_per_day: 3, + grace_period_hours: 72, }, } } diff --git a/src/infra/db/erasure.rs b/src/infra/db/erasure.rs new file mode 100644 index 0000000..af05575 --- /dev/null +++ b/src/infra/db/erasure.rs @@ -0,0 +1,208 @@ +//! Self-service erasure queries (operator §5). +//! +//! Two-phase (request -> confirm) + grace, then a sweeper executes. Execution is +//! ONE fail-closed transaction: per-table policy (purge login_events, scrub audit +//! PII, cascade-delete the user) + tombstone + a hash-chained audit row, all +//! committing together. The schema's ON DELETE clauses (CASCADE for owned rows, +//! SET NULL for counterparty/audit links) make `DELETE FROM users` safe, so the +//! cascade does the structural delete; this layer adds the policy nuances the +//! cascade can't (purging the login (origin,asset,time) quasi-identifier and +//! scrubbing audit PII) and the tamper-evident audit. +//! +//! Scope (v1): the live primary. Crypto-shred of view-key backup residue is a +//! documented non-v1 item (decisions ledger); LWS de-registration is a documented +//! residual (operators run their own LWS). No view keys are stored here anyway +//! (the wallet forwards them per-request), so there is no at-rest view key to shred. + +use chrono::{Duration, SubsecRound, Utc}; +use tracing::instrument; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::db::{ErasureRequest, NewAdminAudit}; + +use super::Database; + +const COLS: &str = "id, user_id, subject_hash, status, requested_at, scheduled_for, \ + confirmed_at, completed_at, cancelled_at"; + +impl Database { + /// The live (pending/confirmed) request for a user, if any. + #[instrument(skip(self))] + pub async fn active_erasure_request( + &self, + user_id: Uuid, + ) -> Result, AppError> { + let sql = format!( + "SELECT {COLS} FROM erasure_requests \ + WHERE user_id = $1 AND status IN ('pending', 'confirmed')" + ); + Ok(sqlx::query_as::<_, ErasureRequest>(&sql) + .bind(user_id) + .fetch_optional(self.pool()) + .await?) + } + + /// Get-or-create the live request for a user (idempotent: one active request + /// per user). `grace_hours` sets `scheduled_for`. + #[instrument(skip(self, subject_hash))] + pub async fn request_erasure( + &self, + user_id: Uuid, + subject_hash: &str, + grace_hours: i64, + ) -> Result { + if let Some(existing) = self.active_erasure_request(user_id).await? { + return Ok(existing); + } + let scheduled_for = (Utc::now() + Duration::hours(grace_hours)).trunc_subsecs(6); + let sql = format!( + "INSERT INTO erasure_requests (user_id, subject_hash, scheduled_for) \ + VALUES ($1, $2, $3) RETURNING {COLS}" + ); + match sqlx::query_as::<_, ErasureRequest>(&sql) + .bind(user_id) + .bind(subject_hash) + .bind(scheduled_for) + .fetch_one(self.pool()) + .await + { + Ok(row) => Ok(row), + // Lost the race against a concurrent request — return the winner. + Err(sqlx::Error::Database(e)) if e.is_unique_violation() => self + .active_erasure_request(user_id) + .await? + .ok_or_else(|| AppError::Internal("erasure request race".into())), + Err(e) => Err(e.into()), + } + } + + /// Confirm a pending request owned by `user_id` (a second fresh proof). Starts + /// the grace clock. Returns the updated row, or `None` if not pending/owned. + #[instrument(skip(self))] + pub async fn confirm_erasure_request( + &self, + id: Uuid, + user_id: Uuid, + grace_hours: i64, + ) -> Result, AppError> { + let scheduled_for = (Utc::now() + Duration::hours(grace_hours)).trunc_subsecs(6); + let sql = format!( + "UPDATE erasure_requests SET status = 'confirmed', confirmed_at = NOW(), \ + scheduled_for = $3 \ + WHERE id = $1 AND user_id = $2 AND status = 'pending' RETURNING {COLS}" + ); + Ok(sqlx::query_as::<_, ErasureRequest>(&sql) + .bind(id) + .bind(user_id) + .bind(scheduled_for) + .fetch_optional(self.pool()) + .await?) + } + + /// Cancel a live request owned by `user_id` (during grace). Returns the row, + /// or `None` if not live/owned. + #[instrument(skip(self))] + pub async fn cancel_erasure_request( + &self, + id: Uuid, + user_id: Uuid, + ) -> Result, AppError> { + let sql = format!( + "UPDATE erasure_requests SET status = 'cancelled', cancelled_at = NOW() \ + WHERE id = $1 AND user_id = $2 AND status IN ('pending', 'confirmed') RETURNING {COLS}" + ); + Ok(sqlx::query_as::<_, ErasureRequest>(&sql) + .bind(id) + .bind(user_id) + .fetch_optional(self.pool()) + .await?) + } + + /// Claim a batch of confirmed requests past their grace, for execution. + /// `FOR UPDATE SKIP LOCKED` so only one node processes a given row. + #[instrument(skip(self))] + pub async fn due_erasure_requests(&self, limit: i64) -> Result, AppError> { + let sql = format!( + "SELECT {COLS} FROM erasure_requests \ + WHERE status = 'confirmed' AND scheduled_for <= NOW() \ + ORDER BY scheduled_for ASC LIMIT $1 FOR UPDATE SKIP LOCKED" + ); + Ok(sqlx::query_as::<_, ErasureRequest>(&sql) + .bind(limit) + .fetch_all(self.pool()) + .await?) + } + + /// Execute a confirmed erasure in ONE fail-closed transaction: per-table + /// policy + cascade delete + tombstone + hash-chained audit. `purge_login` + /// chooses purge (default) vs anonymize for `login_events`. + #[instrument(skip(self, secret))] + pub async fn execute_erasure( + &self, + id: Uuid, + user_id: Uuid, + purge_login: bool, + secret: &str, + ) -> Result<(), AppError> { + let mut tx = self.pool().begin().await?; + + // login_events: the (origin, asset, time) quasi-identifier — the cascade + // only nulls user_id, so purge (default) or anonymize all three here. + if purge_login { + sqlx::query("DELETE FROM login_events WHERE user_id = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; + } else { + sqlx::query( + "UPDATE login_events SET user_id = NULL, origin = NULL, asset = 'redacted' \ + WHERE user_id = $1", + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + } + + // audit_logs: retained for the security trail, but scrub this user's PII + // (the cascade de-links user_id on the user delete below). + sqlx::query( + "UPDATE audit_logs SET ip_address = NULL, user_agent = NULL WHERE user_id = $1", + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + + // Structural delete: cascades wallets/sessions/user_keys/owned slatepacks; + // SET NULL on counterparty/audit/login links (incl. this tombstone row). + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; + + // Tombstone (user_id was just nulled by the cascade). + sqlx::query( + "UPDATE erasure_requests SET status = 'completed', completed_at = NOW() WHERE id = $1", + ) + .bind(id) + .execute(&mut *tx) + .await?; + + self.append_admin_audit( + &mut tx, + &NewAdminAudit { + action: "account_erasure_completed".into(), + actor_kind: "user".into(), + actor_pubkey_prefix: None, + target: Some(id.to_string()), + details: None, + ip_address: None, + }, + secret, + ) + .await?; + + tx.commit().await?; + Ok(()) + } +} diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index b429485..1c1a656 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -11,6 +11,7 @@ mod admin_keys; mod admin_sessions; mod audit; mod challenges; +mod erasure; mod grin_slatepacks; mod login_events; mod restore_attempts; diff --git a/src/lib.rs b/src/lib.rs index 10ac6ed..582100d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -106,6 +106,7 @@ pub fn build_router(state: Arc) -> Router { .merge(api::capabilities::routes()) .merge(api::prices::routes()) .merge(api::landing::routes()) + .merge(api::erasure::routes()) .layer(normal), ); diff --git a/src/main.rs b/src/main.rs index b6b220f..8cc5435 100644 --- a/src/main.rs +++ b/src/main.rs @@ -138,6 +138,23 @@ async fn main() -> Result<(), Box> { }); } + // Background erasure execution: confirmed requests past their grace window + // are deleted (per-table policy + cascade + hash-chained audit, one tx each). + if state.config.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"), + } + } + }); + } + // 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 diff --git a/src/models/db.rs b/src/models/db.rs index 0dea4fa..5d6c252 100644 --- a/src/models/db.rs +++ b/src/models/db.rs @@ -280,6 +280,21 @@ pub struct NewAdminKey { pub activation_deadline: Option>, } +/// A self-service erasure request (operator §5.3). `user_id` is `NULL` once the +/// request is a completed tombstone (the user it referenced is deleted). +#[derive(Debug, Clone, FromRow)] +pub struct ErasureRequest { + pub id: Uuid, + pub user_id: Option, + pub subject_hash: String, + pub status: String, + pub requested_at: DateTime, + pub scheduled_for: DateTime, + pub confirmed_at: Option>, + pub completed_at: Option>, + pub cancelled_at: Option>, +} + /// The singleton bootstrap-latch row (operator §3.2). MAC-protected against /// restore-to-pre-bootstrap tampering. #[derive(Debug, Clone, FromRow)] diff --git a/tests/erasure.rs b/tests/erasure.rs new file mode 100644 index 0000000..3221bd0 --- /dev/null +++ b/tests/erasure.rs @@ -0,0 +1,228 @@ +//! L1 integration: self-service erasure (operator §5) — the full action-bound +//! two-phase flow (export, request -> confirm -> execute) plus the absent-user +//! constant-shape path. One sequential test (it toggles process env to enable +//! erasure with a zero grace window so the sweeper executes immediately). + +mod common; + +use axum::http::StatusCode; +use base64::Engine; +use k256::schnorr::SigningKey; +use rand::rngs::OsRng; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use smirk_backend_core::core::crypto::nip98::{descriptor_sha256, request_descriptor}; +use smirk_backend_core::models::db::NewUser; + +const KIND: u32 = 27235; + +fn random_signer() -> SigningKey { + loop { + let mut b = [0u8; 32]; + OsRng.fill_bytes(&mut b); + if let Ok(sk) = SigningKey::from_bytes(&b) { + return sk; + } + } +} + +/// Build a `Nostr ` signed-action token for an erasure purpose. +fn sign( + sk: &SigningKey, + url: &str, + purpose: &str, + nonce: &str, + descriptor_path: &str, + target: Option<(&str, &str)>, +) -> String { + let pk = hex::encode(sk.verifying_key().to_bytes()); + let payload = descriptor_sha256(&request_descriptor("POST", descriptor_path, "", b"")); + let mut tags = vec![ + vec!["u".to_string(), url.to_string()], + vec!["method".to_string(), "POST".to_string()], + vec!["purpose".to_string(), purpose.to_string()], + vec!["challenge".to_string(), nonce.to_string()], + vec!["payload".to_string(), payload], + ]; + if let Some((k, v)) = target { + tags.push(vec![k.to_string(), v.to_string()]); + } + let created_at = chrono::Utc::now().timestamp(); + let serial = + serde_json::to_string(&serde_json::json!([0, pk, created_at, KIND, tags, ""])).unwrap(); + let id = hex::encode(Sha256::digest(serial.as_bytes())); + let sig = sk.sign_raw(&hex::decode(&id).unwrap(), &[0u8; 32]).unwrap(); + let ev = serde_json::json!({ + "id": id, "pubkey": pk, "created_at": created_at, + "kind": KIND, "tags": tags, "content": "", "sig": hex::encode(sig.to_bytes()) + }); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&ev).unwrap()) + ) +} + +#[tokio::test] +async fn erasure_request_confirm_execute_flow() { + std::env::set_var("ERASURE_ENABLED", "true"); + std::env::set_var( + "ADMIN_KEY_INTEGRITY_SECRET", + "erasure-integrity-secret-at-least-32-bytes!", + ); + std::env::set_var("ERASURE_GRACE_PERIOD_HOURS", "0"); // execute immediately + + let app = require_app!(); + let base = app + .state + .config + .identity + .public_api_url + .clone() + .expect("PUBLIC_API_URL"); + + // A user with a linked nostr identity that we control the key for. + let sk = random_signer(); + let pk = hex::encode(sk.verifying_key().to_bytes()); + app.state + .db + .create_user(NewUser { + username: None, + pubkey_hash: Some(format!("pk-{}", uuid::Uuid::new_v4())), + nostr_pubkey: Some(pk.clone()), + wallet_birthday: None, + seed_fingerprint: None, + xmr_start_height: None, + wow_start_height: None, + }) + .await + .unwrap(); + + let challenge = |purpose: &'static str| { + let app = &app; + async move { + let (st, body) = app + .request( + "POST", + "/api/v1/account/erasure/challenge", + None, + Some(serde_json::json!({ "purpose": purpose })), + ) + .await; + assert_eq!(st, StatusCode::OK, "challenge {purpose}"); + body["challenge"].as_str().unwrap().to_string() + } + }; + + // Export (see-before-delete): proof-gated, returns no view keys. + let nonce = challenge("erasure_export").await; + let token = sign( + &sk, + &format!("{base}/account/export"), + "erasure_export", + &nonce, + "/api/v1/account/export", + None, + ); + let (st, body) = app + .request( + "POST", + "/api/v1/account/export", + None, + Some(serde_json::json!({ "token": token, "nonce": nonce })), + ) + .await; + assert_eq!(st, StatusCode::OK, "export: {body:?}"); + assert_eq!(body["nostr_pubkey"].as_str().unwrap(), pk); + + // Phase 1: request. + let nonce = challenge("erasure_request").await; + let token = sign( + &sk, + &format!("{base}/account/erasure"), + "erasure_request", + &nonce, + "/api/v1/account/erasure", + None, + ); + let (st, body) = app + .request( + "POST", + "/api/v1/account/erasure", + None, + Some(serde_json::json!({ "token": token, "nonce": nonce })), + ) + .await; + assert_eq!(st, StatusCode::OK, "request: {body:?}"); + let erasure_id = body["erasure_id"].as_str().unwrap().to_string(); + assert_eq!(body["status"], "pending"); + + // Phase 2: confirm (bound to the erasure_id). + let nonce = challenge("erasure_confirm").await; + let confirm_path = format!("/account/erasure/{erasure_id}/confirm"); + let token = sign( + &sk, + &format!("{base}{confirm_path}"), + "erasure_confirm", + &nonce, + &format!("/api/v1{confirm_path}"), + Some(("erasure_id", &erasure_id)), + ); + let (st, body) = app + .request( + "POST", + &format!("/api/v1{confirm_path}"), + None, + Some(serde_json::json!({ "token": token, "nonce": nonce })), + ) + .await; + assert_eq!(st, StatusCode::OK, "confirm: {body:?}"); + assert_eq!(body["status"], "confirmed"); + + // Execute (grace = 0, so it is due now). + let n = smirk_backend_core::api::erasure::run_erasure_sweep(&app.state, 50) + .await + .unwrap(); + assert!(n >= 1, "at least our request executed"); + + // The user is gone. + assert!( + app.state + .db + .find_user_by_nostr_pubkey(&pk) + .await + .unwrap() + .is_none(), + "account erased" + ); + + // Absent user: a proof from an unlinked key returns the SAME shape (no oracle). + let stranger = random_signer(); + let nonce = challenge("erasure_request").await; + let token = sign( + &stranger, + &format!("{base}/account/erasure"), + "erasure_request", + &nonce, + "/api/v1/account/erasure", + None, + ); + let (st, body) = app + .request( + "POST", + "/api/v1/account/erasure", + None, + Some(serde_json::json!({ "token": token, "nonce": nonce })), + ) + .await; + assert_eq!(st, StatusCode::OK, "absent-user request is constant-shape"); + assert!(body["erasure_id"].as_str().is_some()); + assert_eq!(body["status"], "pending"); + + for k in [ + "ERASURE_ENABLED", + "ADMIN_KEY_INTEGRITY_SECRET", + "ERASURE_GRACE_PERIOD_HOURS", + ] { + std::env::remove_var(k); + } +}