From cf85dce89fe6b86ba20bde273a5e150c29d683da Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 28 Jul 2026 16:01:07 -0400 Subject: [PATCH] privacy: drop raw-IP PII columns and the dead wallets table The published privacy policy states that IP addresses are retained only as a salted one-way hash for rate limiting. That was true of the login-event path but not of sessions.ip_address or audit_logs.ip_address, both of which stored a raw client IP tied to user_id on every login. Drop both columns and all writer/model code, so the schema enforces the policy rather than relying on convention. The hashed rate-limit path (login_events, restore_attempts) is unchanged and remains the only IP retention. Also drops the `wallets` table, which was created in the initial schema and never read or written: a latent user_id -> address / view_key store. The operator forensic trail (admin_audit_logs, admin_sessions) deliberately keeps its raw IP. Privileged-action forensics is a different threat model and is not covered by the user-facing claim. --- .../20260722000001_privacy_drop_pii.sql | 22 ++++++++++ src/api/auth.rs | 42 +++---------------- src/api/website.rs | 2 - src/infra/db/audit.rs | 10 ++--- src/infra/db/erasure.rs | 12 +++--- src/infra/db/sessions.rs | 7 ++-- src/models/db.rs | 35 ---------------- tests/common/mod.rs | 1 - tests/db_queries.rs | 1 - 9 files changed, 39 insertions(+), 93 deletions(-) create mode 100644 migrations/20260722000001_privacy_drop_pii.sql diff --git a/migrations/20260722000001_privacy_drop_pii.sql b/migrations/20260722000001_privacy_drop_pii.sql new file mode 100644 index 0000000..62ed64d --- /dev/null +++ b/migrations/20260722000001_privacy_drop_pii.sql @@ -0,0 +1,22 @@ +-- Privacy hardening: drop raw-IP PII columns and a dead table. +-- +-- Rationale: the published privacy policy (smirk.cash/privacy, PRIVACY.md) states +-- that IP addresses are retained ONLY as a salted one-way hash (for rate limiting, +-- via login_events / restore_attempts). These columns instead stored a RAW client +-- IP tied to user_id, contradicting that promise. Dropping them makes the schema +-- enforce the policy. The hashed rate-limit path (login_events.ip_hash) is untouched. +-- +-- The operator forensic trail (admin_audit_logs, admin_sessions) is intentionally +-- left as-is: a tamper-evident record of privileged actions is a different threat +-- model from end-user data and is covered separately. + +-- 1. sessions.ip_address — was written raw on every user login (extension + web). +ALTER TABLE sessions DROP COLUMN IF EXISTS ip_address; + +-- 2. audit_logs.ip_address — raw-IP column on the (currently unwired) user audit +-- table. If security logging is wired up later, hash the IP like login_events. +ALTER TABLE audit_logs DROP COLUMN IF EXISTS ip_address; + +-- 3. Dead `wallets` table — created in the initial schema but never read or written +-- by any code path (a latent user_id -> address / view_key PII store). Remove it. +DROP TABLE IF EXISTS wallets; diff --git a/src/api/auth.rs b/src/api/auth.rs index e441f33..7a92daa 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -44,7 +44,6 @@ use axum::{ Json, Router, }; use chrono::Utc; -use ipnetwork::IpNetwork; use serde::{Deserialize, Serialize}; use tracing::{info, instrument, warn}; use uuid::Uuid; @@ -210,7 +209,6 @@ async fn issue_session( user_id: Uuid, platform: Platform, device_info: &str, - ip: Option, ) -> Result { let session_id = Uuid::new_v4(); let pair = state @@ -230,7 +228,6 @@ async fn issue_session( refresh_token_hash, platform: platform.to_string(), device_info: Some(device_info.to_string()), - ip_address: ip, expires_at, }) .await?; @@ -445,14 +442,9 @@ pub async fn extension_register( state.db.update_pubkey_hash(target.id, &pubkey_hash).await?; upsert_all_keys(&state, target.id, &req.keys).await?; - let pair = issue_session( - &state, - target.id, - Platform::Extension, - "Browser Extension", - Some(IpNetwork::from(ip)), - ) - .await?; + let pair = + issue_session(&state, target.id, Platform::Extension, "Browser Extension") + .await?; let _ = state .db .record_login_event( @@ -569,14 +561,7 @@ pub async fn extension_register( info!(user_id = %user.id, "existing extension user authenticated"); } - let pair = issue_session( - &state, - user.id, - Platform::Extension, - "Browser Extension", - Some(IpNetwork::from(ip)), - ) - .await?; + let pair = issue_session(&state, user.id, Platform::Extension, "Browser Extension").await?; let _ = state .db .record_login_event( @@ -1227,7 +1212,6 @@ pub async fn refresh_token( user.id, platform, session.device_info.as_deref().unwrap_or("unknown"), - session.ip_address, ) .await?; @@ -1359,14 +1343,7 @@ pub async fn nostr_login( ) })?; - let pair = issue_session( - &state, - user.id, - Platform::Nostr, - "Nostr", - Some(IpNetwork::from(ip)), - ) - .await?; + let pair = issue_session(&state, user.id, Platform::Nostr, "Nostr").await?; let _ = state .db .record_login_event( @@ -1745,14 +1722,7 @@ pub async fn nostr_register( }; // 6. Session. - let pair = issue_session( - &state, - user.id, - Platform::Nostr, - "Nostr", - Some(IpNetwork::from(ip)), - ) - .await?; + let pair = issue_session(&state, user.id, Platform::Nostr, "Nostr").await?; let _ = state .db .record_login_event( diff --git a/src/api/website.rs b/src/api/website.rs index 91dde8d..ed0e92e 100644 --- a/src/api/website.rs +++ b/src/api/website.rs @@ -56,7 +56,6 @@ use axum::{ Json, Router, }; use chrono::Utc; -use ipnetwork::IpNetwork; use serde::{Deserialize, Serialize}; use tracing::{info, instrument}; @@ -307,7 +306,6 @@ pub async fn website_verify( refresh_token_hash, platform: Platform::Web.to_string(), device_info: Some("Web Browser".to_string()), - ip_address: Some(IpNetwork::from(ip)), expires_at, }) .await?; diff --git a/src/infra/db/audit.rs b/src/infra/db/audit.rs index 3ffae85..f049c42 100644 --- a/src/infra/db/audit.rs +++ b/src/infra/db/audit.rs @@ -4,7 +4,6 @@ //! wallet registration, broadcasts). Explicit column lists (no `SELECT *`) keep //! the row shape pinned to the `AuditLog` struct. -use ipnetwork::IpNetwork; use tracing::instrument; use uuid::Uuid; @@ -15,7 +14,7 @@ use super::Database; /// Explicit `audit_logs` columns (matches `AuditLog`; FromRow maps by name). const AUDIT_LOG_COLS: &str = "id, user_id, action, resource_type, resource_id, \ - details, ip_address, user_agent, created_at"; + details, user_agent, created_at"; impl Database { /// Insert an audit log entry. @@ -23,8 +22,8 @@ impl Database { pub async fn create_audit_log(&self, input: NewAuditLog) -> Result { let sql = format!( "INSERT INTO audit_logs \ - (user_id, action, resource_type, resource_id, details, ip_address, user_agent) \ - VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING {AUDIT_LOG_COLS}" + (user_id, action, resource_type, resource_id, details, user_agent) \ + VALUES ($1, $2, $3, $4, $5, $6) RETURNING {AUDIT_LOG_COLS}" ); let log = sqlx::query_as::<_, AuditLog>(&sql) .bind(input.user_id) @@ -32,7 +31,6 @@ impl Database { .bind(&input.resource_type) .bind(input.resource_id) .bind(&input.details) - .bind(input.ip_address) .bind(&input.user_agent) .fetch_one(self.pool()) .await?; @@ -48,7 +46,6 @@ impl Database { resource_type: Option<&str>, resource_id: Option, details: Option, - ip_address: Option, user_agent: Option<&str>, ) -> Result<(), AppError> { self.create_audit_log(NewAuditLog { @@ -57,7 +54,6 @@ impl Database { resource_type: resource_type.map(String::from), resource_id, details, - ip_address, user_agent: user_agent.map(String::from), }) .await?; diff --git a/src/infra/db/erasure.rs b/src/infra/db/erasure.rs index 30a5524..eaffa73 100644 --- a/src/infra/db/erasure.rs +++ b/src/infra/db/erasure.rs @@ -187,14 +187,12 @@ impl Database { // 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?; + sqlx::query("UPDATE audit_logs SET user_agent = NULL WHERE user_id = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; - // Structural delete: cascades wallets/sessions/user_keys/owned slatepacks; + // Structural delete: cascades sessions/user_keys/owned slatepacks; // SET NULL on counterparty/audit/login links (incl. this row's user_id, // leaving it a completed tombstone with the link scrubbed). sqlx::query("DELETE FROM users WHERE id = $1") diff --git a/src/infra/db/sessions.rs b/src/infra/db/sessions.rs index 0612cbb..0a26f6e 100644 --- a/src/infra/db/sessions.rs +++ b/src/infra/db/sessions.rs @@ -15,7 +15,7 @@ use super::Database; /// Explicit `sessions` columns (matches `Session` field names; FromRow maps by name). const SESSION_COLS: &str = "id, user_id, refresh_token_hash, platform, device_info, \ - ip_address, created_at, expires_at, revoked_at, last_used_at"; + created_at, expires_at, revoked_at, last_used_at"; impl Database { /// Create a new session. `refresh_token_hash` is already peppered by the caller. @@ -23,15 +23,14 @@ impl Database { pub async fn create_session(&self, input: NewSession) -> Result { let sql = format!( "INSERT INTO sessions \ - (user_id, refresh_token_hash, platform, device_info, ip_address, expires_at) \ - VALUES ($1, $2, $3, $4, $5, $6) RETURNING {SESSION_COLS}" + (user_id, refresh_token_hash, platform, device_info, expires_at) \ + VALUES ($1, $2, $3, $4, $5) RETURNING {SESSION_COLS}" ); let session = sqlx::query_as::<_, Session>(&sql) .bind(input.user_id) .bind(&input.refresh_token_hash) .bind(&input.platform) .bind(&input.device_info) - .bind(input.ip_address) .bind(input.expires_at) .fetch_one(self.pool()) .await?; diff --git a/src/models/db.rs b/src/models/db.rs index 0ce12f5..f5b9646 100644 --- a/src/models/db.rs +++ b/src/models/db.rs @@ -91,27 +91,6 @@ pub struct User { pub last_seen_at: Option>, } -/// A wallet associated with a user. -/// -/// Non-custodial: we store the public address and (for XMR/WOW) the view key -/// for balance scanning. We NEVER store a spend key or seed. -#[derive(Debug, Clone, FromRow, Serialize)] -pub struct Wallet { - pub id: Uuid, - pub user_id: Uuid, - pub asset: AssetType, - pub address: String, - /// View key (XMR/WOW only) for balance scanning. - pub view_key: Option, - /// HD derivation index (BTC/LTC). - pub derivation_index: Option, - pub registered_with_node: bool, - pub registration_error: Option, - pub label: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - /// A session backing a JWT refresh token. #[derive(Debug, Clone, FromRow, Serialize)] pub struct Session { @@ -122,7 +101,6 @@ pub struct Session { /// Client kind: `extension`, `web`, or `nostr`. pub platform: String, pub device_info: Option, - pub ip_address: Option, pub created_at: DateTime, pub expires_at: DateTime, pub revoked_at: Option>, @@ -175,7 +153,6 @@ pub struct AuditLog { pub resource_type: Option, pub resource_id: Option, pub details: Option, - pub ip_address: Option, pub user_agent: Option, pub created_at: DateTime, } @@ -229,23 +206,12 @@ pub struct NewUser { pub wow_start_height: Option, } -#[derive(Debug, Clone)] -pub struct NewWallet { - pub user_id: Uuid, - pub asset: AssetType, - pub address: String, - pub view_key: Option, - pub derivation_index: Option, - pub label: Option, -} - #[derive(Debug, Clone)] pub struct NewSession { pub user_id: Uuid, pub refresh_token_hash: String, pub platform: String, pub device_info: Option, - pub ip_address: Option, pub expires_at: DateTime, } @@ -369,6 +335,5 @@ pub struct NewAuditLog { pub resource_type: Option, pub resource_id: Option, pub details: Option, - pub ip_address: Option, pub user_agent: Option, } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index c32cf0e..b34ebea 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -270,7 +270,6 @@ impl TestApp { refresh_token_hash: hash, platform: "web".to_string(), device_info: None, - ip_address: None, expires_at: chrono::Utc::now() + chrono::Duration::days(30), }) .await diff --git a/tests/db_queries.rs b/tests/db_queries.rs index 45a4042..a1e2505 100644 --- a/tests/db_queries.rs +++ b/tests/db_queries.rs @@ -85,7 +85,6 @@ async fn session_create_lookup_revoke() { refresh_token_hash: hash.clone(), platform: "extension".to_string(), device_info: None, - ip_address: None, expires_at: Utc::now() + Duration::days(30), }) .await