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.
This commit is contained in:
jwinterm
2026-07-28 16:01:07 -04:00
parent 74884e368d
commit cf85dce89f
9 changed files with 39 additions and 93 deletions
@@ -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;
+6 -36
View File
@@ -44,7 +44,6 @@ use axum::{
Json, Router, Json, Router,
}; };
use chrono::Utc; use chrono::Utc;
use ipnetwork::IpNetwork;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{info, instrument, warn}; use tracing::{info, instrument, warn};
use uuid::Uuid; use uuid::Uuid;
@@ -210,7 +209,6 @@ async fn issue_session(
user_id: Uuid, user_id: Uuid,
platform: Platform, platform: Platform,
device_info: &str, device_info: &str,
ip: Option<IpNetwork>,
) -> Result<crate::core::session::TokenPair, AppError> { ) -> Result<crate::core::session::TokenPair, AppError> {
let session_id = Uuid::new_v4(); let session_id = Uuid::new_v4();
let pair = state let pair = state
@@ -230,7 +228,6 @@ async fn issue_session(
refresh_token_hash, refresh_token_hash,
platform: platform.to_string(), platform: platform.to_string(),
device_info: Some(device_info.to_string()), device_info: Some(device_info.to_string()),
ip_address: ip,
expires_at, expires_at,
}) })
.await?; .await?;
@@ -445,14 +442,9 @@ pub async fn extension_register(
state.db.update_pubkey_hash(target.id, &pubkey_hash).await?; state.db.update_pubkey_hash(target.id, &pubkey_hash).await?;
upsert_all_keys(&state, target.id, &req.keys).await?; upsert_all_keys(&state, target.id, &req.keys).await?;
let pair = issue_session( let pair =
&state, issue_session(&state, target.id, Platform::Extension, "Browser Extension")
target.id, .await?;
Platform::Extension,
"Browser Extension",
Some(IpNetwork::from(ip)),
)
.await?;
let _ = state let _ = state
.db .db
.record_login_event( .record_login_event(
@@ -569,14 +561,7 @@ pub async fn extension_register(
info!(user_id = %user.id, "existing extension user authenticated"); info!(user_id = %user.id, "existing extension user authenticated");
} }
let pair = issue_session( let pair = issue_session(&state, user.id, Platform::Extension, "Browser Extension").await?;
&state,
user.id,
Platform::Extension,
"Browser Extension",
Some(IpNetwork::from(ip)),
)
.await?;
let _ = state let _ = state
.db .db
.record_login_event( .record_login_event(
@@ -1227,7 +1212,6 @@ pub async fn refresh_token(
user.id, user.id,
platform, platform,
session.device_info.as_deref().unwrap_or("unknown"), session.device_info.as_deref().unwrap_or("unknown"),
session.ip_address,
) )
.await?; .await?;
@@ -1359,14 +1343,7 @@ pub async fn nostr_login(
) )
})?; })?;
let pair = issue_session( let pair = issue_session(&state, user.id, Platform::Nostr, "Nostr").await?;
&state,
user.id,
Platform::Nostr,
"Nostr",
Some(IpNetwork::from(ip)),
)
.await?;
let _ = state let _ = state
.db .db
.record_login_event( .record_login_event(
@@ -1745,14 +1722,7 @@ pub async fn nostr_register(
}; };
// 6. Session. // 6. Session.
let pair = issue_session( let pair = issue_session(&state, user.id, Platform::Nostr, "Nostr").await?;
&state,
user.id,
Platform::Nostr,
"Nostr",
Some(IpNetwork::from(ip)),
)
.await?;
let _ = state let _ = state
.db .db
.record_login_event( .record_login_event(
-2
View File
@@ -56,7 +56,6 @@ use axum::{
Json, Router, Json, Router,
}; };
use chrono::Utc; use chrono::Utc;
use ipnetwork::IpNetwork;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{info, instrument}; use tracing::{info, instrument};
@@ -307,7 +306,6 @@ pub async fn website_verify(
refresh_token_hash, refresh_token_hash,
platform: Platform::Web.to_string(), platform: Platform::Web.to_string(),
device_info: Some("Web Browser".to_string()), device_info: Some("Web Browser".to_string()),
ip_address: Some(IpNetwork::from(ip)),
expires_at, expires_at,
}) })
.await?; .await?;
+3 -7
View File
@@ -4,7 +4,6 @@
//! wallet registration, broadcasts). Explicit column lists (no `SELECT *`) keep //! wallet registration, broadcasts). Explicit column lists (no `SELECT *`) keep
//! the row shape pinned to the `AuditLog` struct. //! the row shape pinned to the `AuditLog` struct.
use ipnetwork::IpNetwork;
use tracing::instrument; use tracing::instrument;
use uuid::Uuid; use uuid::Uuid;
@@ -15,7 +14,7 @@ use super::Database;
/// Explicit `audit_logs` columns (matches `AuditLog`; FromRow maps by name). /// Explicit `audit_logs` columns (matches `AuditLog`; FromRow maps by name).
const AUDIT_LOG_COLS: &str = "id, user_id, action, resource_type, resource_id, \ 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 { impl Database {
/// Insert an audit log entry. /// Insert an audit log entry.
@@ -23,8 +22,8 @@ impl Database {
pub async fn create_audit_log(&self, input: NewAuditLog) -> Result<AuditLog, AppError> { pub async fn create_audit_log(&self, input: NewAuditLog) -> Result<AuditLog, AppError> {
let sql = format!( let sql = format!(
"INSERT INTO audit_logs \ "INSERT INTO audit_logs \
(user_id, action, resource_type, resource_id, details, ip_address, user_agent) \ (user_id, action, resource_type, resource_id, details, user_agent) \
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING {AUDIT_LOG_COLS}" VALUES ($1, $2, $3, $4, $5, $6) RETURNING {AUDIT_LOG_COLS}"
); );
let log = sqlx::query_as::<_, AuditLog>(&sql) let log = sqlx::query_as::<_, AuditLog>(&sql)
.bind(input.user_id) .bind(input.user_id)
@@ -32,7 +31,6 @@ impl Database {
.bind(&input.resource_type) .bind(&input.resource_type)
.bind(input.resource_id) .bind(input.resource_id)
.bind(&input.details) .bind(&input.details)
.bind(input.ip_address)
.bind(&input.user_agent) .bind(&input.user_agent)
.fetch_one(self.pool()) .fetch_one(self.pool())
.await?; .await?;
@@ -48,7 +46,6 @@ impl Database {
resource_type: Option<&str>, resource_type: Option<&str>,
resource_id: Option<Uuid>, resource_id: Option<Uuid>,
details: Option<serde_json::Value>, details: Option<serde_json::Value>,
ip_address: Option<IpNetwork>,
user_agent: Option<&str>, user_agent: Option<&str>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
self.create_audit_log(NewAuditLog { self.create_audit_log(NewAuditLog {
@@ -57,7 +54,6 @@ impl Database {
resource_type: resource_type.map(String::from), resource_type: resource_type.map(String::from),
resource_id, resource_id,
details, details,
ip_address,
user_agent: user_agent.map(String::from), user_agent: user_agent.map(String::from),
}) })
.await?; .await?;
+5 -7
View File
@@ -187,14 +187,12 @@ impl Database {
// audit_logs: retained for the security trail, but scrub this user's PII // audit_logs: retained for the security trail, but scrub this user's PII
// (the cascade de-links user_id on the user delete below). // (the cascade de-links user_id on the user delete below).
sqlx::query( sqlx::query("UPDATE audit_logs SET user_agent = NULL WHERE user_id = $1")
"UPDATE audit_logs SET ip_address = NULL, user_agent = NULL WHERE user_id = $1", .bind(user_id)
) .execute(&mut *tx)
.bind(user_id) .await?;
.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, // SET NULL on counterparty/audit/login links (incl. this row's user_id,
// leaving it a completed tombstone with the link scrubbed). // leaving it a completed tombstone with the link scrubbed).
sqlx::query("DELETE FROM users WHERE id = $1") sqlx::query("DELETE FROM users WHERE id = $1")
+3 -4
View File
@@ -15,7 +15,7 @@ use super::Database;
/// Explicit `sessions` columns (matches `Session` field names; FromRow maps by name). /// Explicit `sessions` columns (matches `Session` field names; FromRow maps by name).
const SESSION_COLS: &str = "id, user_id, refresh_token_hash, platform, device_info, \ 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 { impl Database {
/// Create a new session. `refresh_token_hash` is already peppered by the caller. /// 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<Session, AppError> { pub async fn create_session(&self, input: NewSession) -> Result<Session, AppError> {
let sql = format!( let sql = format!(
"INSERT INTO sessions \ "INSERT INTO sessions \
(user_id, refresh_token_hash, platform, device_info, ip_address, expires_at) \ (user_id, refresh_token_hash, platform, device_info, expires_at) \
VALUES ($1, $2, $3, $4, $5, $6) RETURNING {SESSION_COLS}" VALUES ($1, $2, $3, $4, $5) RETURNING {SESSION_COLS}"
); );
let session = sqlx::query_as::<_, Session>(&sql) let session = sqlx::query_as::<_, Session>(&sql)
.bind(input.user_id) .bind(input.user_id)
.bind(&input.refresh_token_hash) .bind(&input.refresh_token_hash)
.bind(&input.platform) .bind(&input.platform)
.bind(&input.device_info) .bind(&input.device_info)
.bind(input.ip_address)
.bind(input.expires_at) .bind(input.expires_at)
.fetch_one(self.pool()) .fetch_one(self.pool())
.await?; .await?;
-35
View File
@@ -91,27 +91,6 @@ pub struct User {
pub last_seen_at: Option<DateTime<Utc>>, pub last_seen_at: Option<DateTime<Utc>>,
} }
/// 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<String>,
/// HD derivation index (BTC/LTC).
pub derivation_index: Option<i32>,
pub registered_with_node: bool,
pub registration_error: Option<String>,
pub label: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// A session backing a JWT refresh token. /// A session backing a JWT refresh token.
#[derive(Debug, Clone, FromRow, Serialize)] #[derive(Debug, Clone, FromRow, Serialize)]
pub struct Session { pub struct Session {
@@ -122,7 +101,6 @@ pub struct Session {
/// Client kind: `extension`, `web`, or `nostr`. /// Client kind: `extension`, `web`, or `nostr`.
pub platform: String, pub platform: String,
pub device_info: Option<String>, pub device_info: Option<String>,
pub ip_address: Option<IpNetwork>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
pub revoked_at: Option<DateTime<Utc>>, pub revoked_at: Option<DateTime<Utc>>,
@@ -175,7 +153,6 @@ pub struct AuditLog {
pub resource_type: Option<String>, pub resource_type: Option<String>,
pub resource_id: Option<Uuid>, pub resource_id: Option<Uuid>,
pub details: Option<serde_json::Value>, pub details: Option<serde_json::Value>,
pub ip_address: Option<IpNetwork>,
pub user_agent: Option<String>, pub user_agent: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@@ -229,23 +206,12 @@ pub struct NewUser {
pub wow_start_height: Option<i64>, pub wow_start_height: Option<i64>,
} }
#[derive(Debug, Clone)]
pub struct NewWallet {
pub user_id: Uuid,
pub asset: AssetType,
pub address: String,
pub view_key: Option<String>,
pub derivation_index: Option<i32>,
pub label: Option<String>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NewSession { pub struct NewSession {
pub user_id: Uuid, pub user_id: Uuid,
pub refresh_token_hash: String, pub refresh_token_hash: String,
pub platform: String, pub platform: String,
pub device_info: Option<String>, pub device_info: Option<String>,
pub ip_address: Option<IpNetwork>,
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
} }
@@ -369,6 +335,5 @@ pub struct NewAuditLog {
pub resource_type: Option<String>, pub resource_type: Option<String>,
pub resource_id: Option<Uuid>, pub resource_id: Option<Uuid>,
pub details: Option<serde_json::Value>, pub details: Option<serde_json::Value>,
pub ip_address: Option<IpNetwork>,
pub user_agent: Option<String>, pub user_agent: Option<String>,
} }
-1
View File
@@ -270,7 +270,6 @@ impl TestApp {
refresh_token_hash: hash, refresh_token_hash: hash,
platform: "web".to_string(), platform: "web".to_string(),
device_info: None, device_info: None,
ip_address: None,
expires_at: chrono::Utc::now() + chrono::Duration::days(30), expires_at: chrono::Utc::now() + chrono::Duration::days(30),
}) })
.await .await
-1
View File
@@ -85,7 +85,6 @@ async fn session_create_lookup_revoke() {
refresh_token_hash: hash.clone(), refresh_token_hash: hash.clone(),
platform: "extension".to_string(), platform: "extension".to_string(),
device_info: None, device_info: None,
ip_address: None,
expires_at: Utc::now() + Duration::days(30), expires_at: Utc::now() + Duration::days(30),
}) })
.await .await