mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(erasure): self-service account erasure + export [operator §5]
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).
This commit is contained in:
@@ -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';
|
||||
@@ -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<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,
|
||||
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<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?;
|
||||
|
||||
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<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 }))
|
||||
}
|
||||
|
||||
/// 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 }))
|
||||
}
|
||||
|
||||
#[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 {
|
||||
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<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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Option<ErasureRequest>, 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<ErasureRequest, AppError> {
|
||||
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<Option<ErasureRequest>, 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<Option<ErasureRequest>, 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<Vec<ErasureRequest>, 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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -106,6 +106,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.merge(api::capabilities::routes())
|
||||
.merge(api::prices::routes())
|
||||
.merge(api::landing::routes())
|
||||
.merge(api::erasure::routes())
|
||||
.layer(normal),
|
||||
);
|
||||
|
||||
|
||||
+17
@@ -138,6 +138,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -280,6 +280,21 @@ pub struct NewAdminKey {
|
||||
pub activation_deadline: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 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<Uuid>,
|
||||
pub subject_hash: String,
|
||||
pub status: String,
|
||||
pub requested_at: DateTime<Utc>,
|
||||
pub scheduled_for: DateTime<Utc>,
|
||||
pub confirmed_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub cancelled_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// The singleton bootstrap-latch row (operator §3.2). MAC-protected against
|
||||
/// restore-to-pre-bootstrap tampering.
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
|
||||
@@ -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 <base64(event)>` 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user