mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user