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.
This commit is contained in:
jwinterm
2026-06-22 14:09:21 -04:00
parent e182972599
commit 5d70b80aa9
6 changed files with 385 additions and 0 deletions
+1
View File
@@ -1,4 +1,5 @@
//! Cryptographic primitives: NIP-98 auth and wallet-signature verification.
pub mod nip98;
pub mod pepper;
pub mod signatures;
+61
View File
@@ -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::<Sha256>::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")
);
}
}
+56
View File
@@ -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<bool, AppError> {
sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&self.pool)
.await?;
Ok(true)
}
}
+262
View File
@@ -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<User, AppError> {
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<Option<User>, 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<Option<User>, 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<Option<User>, 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<Option<User>, 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<Option<User>, 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<Option<User>, 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<String>,
wallet_birthday: Option<chrono::DateTime<chrono::Utc>>,
seed_fingerprint: Option<String>,
xmr_start_height: Option<i64>,
wow_start_height: Option<i64>,
) -> Result<User, AppError> {
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<String>,
) -> Result<User, AppError> {
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<User, AppError> {
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<i64, AppError> {
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),
}
}
+4
View File
@@ -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;
+1
View File
@@ -12,4 +12,5 @@ pub mod api;
pub mod config;
pub mod core;
pub mod error;
pub mod infra;
pub mod models;