From 5d70b80aa9dc3b920d605e5f16ed50f905496fb8 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 22 Jun 2026 14:09:21 -0400 Subject: [PATCH] feat: db layer foundation + Nostr-native user queries - core/crypto/pepper.rs: domain-separated HMAC peppering of identity values at rest (kills the seed-existence oracle: stored pubkey_hash/seed_fingerprint are non-reproducible from a candidate seed without the server pepper) - infra/db/mod.rs: Database wrapper holding the pool + peppers (no Debug derive); peppering applied at the data boundary so it cannot drift between call sites - infra/db/users.rs: Nostr-native query set (create/get/update, by pubkey_hash /seed_fingerprint [peppered], by username/nostr_pubkey, get-or-create with NULL-only backfill, username + nostr_pubkey UNIQUE -> 409). Explicit columns (no SELECT *). Dropped all telegram/discord methods. --- src/core/crypto/mod.rs | 1 + src/core/crypto/pepper.rs | 61 +++++++++ src/infra/db/mod.rs | 56 ++++++++ src/infra/db/users.rs | 262 ++++++++++++++++++++++++++++++++++++++ src/infra/mod.rs | 4 + src/lib.rs | 1 + 6 files changed, 385 insertions(+) create mode 100644 src/core/crypto/pepper.rs create mode 100644 src/infra/db/mod.rs create mode 100644 src/infra/db/users.rs create mode 100644 src/infra/mod.rs diff --git a/src/core/crypto/mod.rs b/src/core/crypto/mod.rs index 89a75f0..e073e1c 100644 --- a/src/core/crypto/mod.rs +++ b/src/core/crypto/mod.rs @@ -1,4 +1,5 @@ //! Cryptographic primitives: NIP-98 auth and wallet-signature verification. pub mod nip98; +pub mod pepper; pub mod signatures; diff --git a/src/core/crypto/pepper.rs b/src/core/crypto/pepper.rs new file mode 100644 index 0000000..68d32df --- /dev/null +++ b/src/core/crypto/pepper.rs @@ -0,0 +1,61 @@ +//! Domain-separated HMAC peppering for identity values stored at rest. +//! +//! `pubkey_hash` and `seed_fingerprint` are deterministic, client-reproducible +//! values (anyone with a candidate seed/pubkey can recompute them). Storing them +//! raw turns the database — and `/auth/check-restore` — into a seed-existence +//! oracle and a cross-instance linker. Peppering with a server-held HMAC key +//! makes the stored value non-reproducible without the pepper, while staying +//! deterministic for the server's own lookups. The domain string separates +//! namespaces so the same input under two domains yields unrelated outputs. + +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +/// `hex(HMAC-SHA256(pepper, domain || 0x1f || value))`. +pub fn peppered_hex(pepper: &str, domain: &str, value: &str) -> String { + let mut mac = + Hmac::::new_from_slice(pepper.as_bytes()).expect("HMAC accepts any key length"); + mac.update(domain.as_bytes()); + mac.update(&[0x1f]); // unit separator: unambiguous domain/value boundary + mac.update(value.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic() { + assert_eq!( + peppered_hex("pep", "pubkey_hash", "abc"), + peppered_hex("pep", "pubkey_hash", "abc") + ); + } + + #[test] + fn domain_separated() { + assert_ne!( + peppered_hex("pep", "pubkey_hash", "abc"), + peppered_hex("pep", "seed_fingerprint", "abc") + ); + } + + #[test] + fn keyed_by_pepper() { + assert_ne!( + peppered_hex("pep-A", "d", "abc"), + peppered_hex("pep-B", "d", "abc") + ); + } + + #[test] + fn no_delimiter_collision() { + // The 0x1f separator prevents (domain="a", value="bc") colliding with + // (domain="ab", value="c"). + assert_ne!( + peppered_hex("pep", "a", "bc"), + peppered_hex("pep", "ab", "c") + ); + } +} diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs new file mode 100644 index 0000000..328acce --- /dev/null +++ b/src/infra/db/mod.rs @@ -0,0 +1,56 @@ +//! Database access layer. +//! +//! `Database` wraps the connection pool and the secrets needed to pepper +//! identity values at rest. Per-entity query methods are implemented on it in +//! sibling modules (`users`, ...). Identity values (`pubkey_hash`, +//! `seed_fingerprint`) are peppered inside these methods so the plaintext never +//! reaches a column and the peppering cannot drift between call sites. + +mod users; + +use sqlx::PgPool; + +use crate::core::crypto::pepper::peppered_hex; +use crate::error::AppError; + +/// Connection pool + at-rest peppering secrets. Does not derive `Debug`. +#[derive(Clone)] +pub struct Database { + pool: PgPool, + /// HMAC pepper for identity columns (pubkey_hash, seed_fingerprint). + identity_pepper: String, + /// Salt for hashing client IPs in analytics/abuse tables. + ip_salt: String, +} + +impl Database { + pub fn new(pool: PgPool, identity_pepper: String, ip_salt: String) -> Self { + Self { + pool, + identity_pepper, + ip_salt, + } + } + + pub fn pool(&self) -> &PgPool { + &self.pool + } + + /// Salt for IP hashing (used by login_events / restore_attempts). + pub fn ip_salt(&self) -> &str { + &self.ip_salt + } + + /// Pepper an identity value for storage/lookup (see [`peppered_hex`]). + fn pepper(&self, domain: &str, value: &str) -> String { + peppered_hex(&self.identity_pepper, domain, value) + } + + /// Liveness check against the database. + pub async fn health_check(&self) -> Result { + sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(&self.pool) + .await?; + Ok(true) + } +} diff --git a/src/infra/db/users.rs b/src/infra/db/users.rs new file mode 100644 index 0000000..5cb61d5 --- /dev/null +++ b/src/infra/db/users.rs @@ -0,0 +1,262 @@ +//! User queries. +//! +//! Identity is Nostr-native: `pubkey_hash` and `seed_fingerprint` are peppered +//! inside these methods before they touch a column, so callers pass plaintext +//! and the at-rest values are non-reproducible without the server pepper. +//! Explicit column lists (no `SELECT *`) keep the hot auth lookups lean. + +use tracing::instrument; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::db::{NewUser, User}; + +use super::Database; + +/// Explicit `users` columns (matches `User` field names; FromRow maps by name). +const USER_COLS: &str = "id, username, pubkey_hash, nostr_pubkey, wallet_birthday, \ + seed_fingerprint, xmr_start_height, wow_start_height, created_at, updated_at, last_seen_at"; + +impl Database { + /// Create a new user. `pubkey_hash` / `seed_fingerprint` are peppered here. + #[instrument(skip(self, input))] + pub async fn create_user(&self, input: NewUser) -> Result { + let pubkey_hash = input + .pubkey_hash + .as_deref() + .map(|v| self.pepper("pubkey_hash", v)); + let seed_fingerprint = input + .seed_fingerprint + .as_deref() + .map(|v| self.pepper("seed_fingerprint", v)); + + let sql = format!( + "INSERT INTO users \ + (username, pubkey_hash, nostr_pubkey, wallet_birthday, seed_fingerprint, \ + xmr_start_height, wow_start_height) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING {USER_COLS}" + ); + let user = sqlx::query_as::<_, User>(&sql) + .bind(&input.username) + .bind(&pubkey_hash) + .bind(&input.nostr_pubkey) + .bind(input.wallet_birthday) + .bind(&seed_fingerprint) + .bind(input.xmr_start_height) + .bind(input.wow_start_height) + .fetch_one(self.pool()) + .await?; + Ok(user) + } + + #[instrument(skip(self))] + pub async fn get_user_by_id(&self, id: Uuid) -> Result, AppError> { + let sql = format!("SELECT {USER_COLS} FROM users WHERE id = $1"); + Ok(sqlx::query_as::<_, User>(&sql) + .bind(id) + .fetch_optional(self.pool()) + .await?) + } + + /// Alias for [`Database::get_user_by_id`]. + pub async fn get_user(&self, user_id: Uuid) -> Result, AppError> { + self.get_user_by_id(user_id).await + } + + /// Look up by the wallet identity pubkey hash (peppered). + #[instrument(skip(self, pubkey_hash))] + pub async fn get_user_by_pubkey_hash( + &self, + pubkey_hash: &str, + ) -> Result, AppError> { + let peppered = self.pepper("pubkey_hash", pubkey_hash); + let sql = format!("SELECT {USER_COLS} FROM users WHERE pubkey_hash = $1"); + Ok(sqlx::query_as::<_, User>(&sql) + .bind(peppered) + .fetch_optional(self.pool()) + .await?) + } + + /// Look up by seed fingerprint (peppered) for restore validation. + #[instrument(skip(self, fingerprint))] + pub async fn get_user_by_seed_fingerprint( + &self, + fingerprint: &str, + ) -> Result, AppError> { + let peppered = self.pepper("seed_fingerprint", fingerprint); + let sql = format!("SELECT {USER_COLS} FROM users WHERE seed_fingerprint = $1"); + Ok(sqlx::query_as::<_, User>(&sql) + .bind(peppered) + .fetch_optional(self.pool()) + .await?) + } + + #[instrument(skip(self))] + pub async fn get_user_by_username(&self, username: &str) -> Result, AppError> { + let sql = format!("SELECT {USER_COLS} FROM users WHERE username = $1"); + Ok(sqlx::query_as::<_, User>(&sql) + .bind(username) + .fetch_optional(self.pool()) + .await?) + } + + /// Find a user by their linked Nostr pubkey (x-only hex; not peppered — it is + /// public and discoverable via NIP-05). `None` if unlinked. + #[instrument(skip(self))] + pub async fn find_user_by_nostr_pubkey( + &self, + nostr_pubkey: &str, + ) -> Result, AppError> { + let sql = format!("SELECT {USER_COLS} FROM users WHERE nostr_pubkey = $1"); + Ok(sqlx::query_as::<_, User>(&sql) + .bind(nostr_pubkey) + .fetch_optional(self.pool()) + .await?) + } + + /// Replace a user's `pubkey_hash` (derivation-scheme rotation, keyed by the + /// unchanged `seed_fingerprint`). Peppered. + #[instrument(skip(self, new_pubkey_hash))] + pub async fn update_pubkey_hash( + &self, + user_id: Uuid, + new_pubkey_hash: &str, + ) -> Result<(), AppError> { + let peppered = self.pepper("pubkey_hash", new_pubkey_hash); + sqlx::query("UPDATE users SET pubkey_hash = $1, updated_at = NOW() WHERE id = $2") + .bind(peppered) + .bind(user_id) + .execute(self.pool()) + .await?; + Ok(()) + } + + /// Link a Nostr pubkey to a user (NIP-98 sign-in). The UNIQUE constraint is + /// the atomic claim; a collision surfaces as 409 CONFLICT. + #[instrument(skip(self))] + pub async fn set_nostr_pubkey( + &self, + user_id: Uuid, + nostr_pubkey: &str, + ) -> Result<(), AppError> { + sqlx::query("UPDATE users SET nostr_pubkey = $2, updated_at = NOW() WHERE id = $1") + .bind(user_id) + .bind(nostr_pubkey) + .execute(self.pool()) + .await + .map_err(unique_violation_as( + "That Nostr identity is already linked to another account", + ))?; + Ok(()) + } + + /// Get or create a user by pubkey hash (extension registration). For an + /// existing user, backfills only NULL `wallet_birthday` / `seed_fingerprint` + /// / chain start-heights; never overwrites an existing value. + #[instrument(skip(self, pubkey_hash, seed_fingerprint))] + pub async fn get_or_create_user_by_pubkey_hash( + &self, + pubkey_hash: &str, + username: Option, + wallet_birthday: Option>, + seed_fingerprint: Option, + xmr_start_height: Option, + wow_start_height: Option, + ) -> Result { + if let Some(existing) = self.get_user_by_pubkey_hash(pubkey_hash).await? { + let needs = existing.wallet_birthday.is_none() && wallet_birthday.is_some() + || existing.seed_fingerprint.is_none() && seed_fingerprint.is_some() + || existing.xmr_start_height.is_none() && xmr_start_height.is_some() + || existing.wow_start_height.is_none() && wow_start_height.is_some(); + if !needs { + return Ok(existing); + } + + let peppered_fp = seed_fingerprint + .as_deref() + .map(|v| self.pepper("seed_fingerprint", v)); + let sql = format!( + "UPDATE users SET \ + wallet_birthday = COALESCE(wallet_birthday, $2), \ + seed_fingerprint = COALESCE(seed_fingerprint, $3), \ + xmr_start_height = COALESCE(xmr_start_height, $4), \ + wow_start_height = COALESCE(wow_start_height, $5), \ + updated_at = NOW() \ + WHERE id = $1 RETURNING {USER_COLS}" + ); + let updated = sqlx::query_as::<_, User>(&sql) + .bind(existing.id) + .bind(wallet_birthday) + .bind(peppered_fp) + .bind(xmr_start_height) + .bind(wow_start_height) + .fetch_one(self.pool()) + .await?; + return Ok(updated); + } + + self.create_user(NewUser { + username, + pubkey_hash: Some(pubkey_hash.to_string()), + nostr_pubkey: None, + wallet_birthday, + seed_fingerprint, + xmr_start_height, + wow_start_height, + }) + .await + } + + /// Update a user's username. UNIQUE collision -> 409 CONFLICT. + #[instrument(skip(self))] + pub async fn update_username( + &self, + user_id: Uuid, + username: Option, + ) -> Result { + let sql = format!("UPDATE users SET username = $2, updated_at = NOW() WHERE id = $1 RETURNING {USER_COLS}"); + let user = sqlx::query_as::<_, User>(&sql) + .bind(user_id) + .bind(&username) + .fetch_one(self.pool()) + .await + .map_err(unique_violation_as("That username is not available"))?; + Ok(user) + } + + /// Set a user's username (non-null convenience wrapper). + pub async fn set_username(&self, user_id: Uuid, username: &str) -> Result { + self.update_username(user_id, Some(username.to_string())) + .await + } + + #[instrument(skip(self))] + pub async fn update_user_last_seen(&self, user_id: Uuid) -> Result<(), AppError> { + sqlx::query("UPDATE users SET last_seen_at = NOW() WHERE id = $1") + .bind(user_id) + .execute(self.pool()) + .await?; + Ok(()) + } + + /// Count registered wallets (users with a pubkey_hash). + #[instrument(skip(self))] + pub async fn get_user_count(&self) -> Result { + Ok( + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE pubkey_hash IS NOT NULL") + .fetch_one(self.pool()) + .await?, + ) + } +} + +/// Map a unique-constraint violation to a 409 CONFLICT with `msg`; pass other +/// errors through. +fn unique_violation_as(msg: &'static str) -> impl Fn(sqlx::Error) -> AppError { + move |e| match &e { + sqlx::Error::Database(db_err) if db_err.is_unique_violation() => { + AppError::Conflict(msg.to_string()) + } + _ => AppError::from(e), + } +} diff --git a/src/infra/mod.rs b/src/infra/mod.rs new file mode 100644 index 0000000..3381c9a --- /dev/null +++ b/src/infra/mod.rs @@ -0,0 +1,4 @@ +//! Infrastructure layer: adapters for external services (database, chain nodes, +//! light-wallet servers). Chain clients are wired in as they land. + +pub mod db; diff --git a/src/lib.rs b/src/lib.rs index 2199f97..c09bfc8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,4 +12,5 @@ pub mod api; pub mod config; pub mod core; pub mod error; +pub mod infra; pub mod models;