From 468d3f0add5bae677404262deb4d7587ab0abcbe Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 15 Sep 2026 10:33:15 -0400 Subject: [PATCH] feat(migration): read-only legacy directory, off unless configured The two deployments never shared a database. The legacy instance holds 134 identities whose keys v3 has never seen, so pointing the website at v3 would refuse all of them: nothing in smirk_v3_db can match a key that exists only in smirk_db. Neither obvious join key survives contact with the data. users.pubkey_hash is a plain sha256 on legacy and an HMAC under identity_pepper on v3, so the columns are not comparable at all. Usernames are actively dangerous: 14 of the 16 handles exist in both databases while only 6 users share a raw key, so merging by name would hand one person's keys to someone else's account. What is reliable is the public key a signature just proved control of. This looks up that key and nothing else, so it can only answer about a key the caller could already sign for. The capability is closed by default. With no LEGACY_DATABASE_URL nothing connects, every lookup answers "not found", and sign-in behaves exactly as it does now. A URL that points nowhere degrades to disabled rather than failing the boot, because a migration convenience must never be able to stop the service from serving. Nothing calls it yet. --- src/config.rs | 10 ++ src/infra/legacy_directory.rs | 207 ++++++++++++++++++++++++++++++++++ src/infra/mod.rs | 1 + src/lib.rs | 3 + src/main.rs | 8 ++ 5 files changed, 229 insertions(+) create mode 100644 src/infra/legacy_directory.rs diff --git a/src/config.rs b/src/config.rs index f8c9342..68f9e48 100644 --- a/src/config.rs +++ b/src/config.rs @@ -147,6 +147,12 @@ pub struct Config { pub database_url: String, + /// Legacy (v0.2.x) database, read-only, for sign-in migration. `None` (the + /// default) leaves the capability OFF: nothing connects and every legacy + /// lookup answers "not found". Set it only for as long as the tail of legacy + /// users is still migrating, then remove it again. + pub legacy_database_url: Option, + /// Base URL for public-tip share links: a tip's share URL is /// `{tip_share_base}/{tip_id}`. Required when `FEATURE_TIPS` is on (checked /// in `validate`); never hardcode a host (federation). @@ -797,6 +803,9 @@ impl Config { database_url: env_opt("DATABASE_URL") .ok_or_else(|| cfg_err("DATABASE_URL is required"))?, + // Absent means off. No default, and no inference from DATABASE_URL: + // a second database is a capability an operator opts into. + legacy_database_url: env_opt("LEGACY_DATABASE_URL"), tip_share_base: env_opt("TIP_SHARE_BASE_URL"), @@ -1507,6 +1516,7 @@ mod tests { deployment_mode: DeploymentMode::Single, environment: "development".into(), database_url: "postgres://localhost/smirk".into(), + legacy_database_url: None, tip_share_base: None, auth: AuthConfig { jwt_secret: "a".repeat(32), diff --git a/src/infra/legacy_directory.rs b/src/infra/legacy_directory.rs new file mode 100644 index 0000000..5f3b713 --- /dev/null +++ b/src/infra/legacy_directory.rs @@ -0,0 +1,207 @@ +//! Read-only lookup into the legacy (v0.2.x) database, for sign-in migration. +//! +//! The two deployments do not share a database, and they never shared one. The +//! legacy instance holds 134 identities whose keys the v3 instance has never +//! seen, so pointing the website at v3 would refuse every one of them: nothing +//! in `smirk_v3_db` can match a key that only exists in `smirk_db`. +//! +//! Neither of the obvious join keys works. `users.pubkey_hash` is a plain +//! `sha256(public_key)` on legacy and an HMAC under `identity_pepper` on v3, so +//! the columns are not comparable. Usernames are worse than useless: 14 of the +//! 16 handles exist in BOTH databases while only 6 users share a raw key, so +//! merging by name would hand one person's keys to a different account. +//! +//! What IS reliable is the public key a signature just proved control of. This +//! module looks up exactly that, and only that. A caller arrives holding a +//! verified signature; we answer whether the legacy instance knows that key. +//! +//! **This capability is OFF unless a legacy URL is configured.** With no URL the +//! directory is constructed disabled, every lookup answers `None`, and sign-in +//! behaves exactly as it does today. It is meant to be switched off again once +//! the tail of legacy users has migrated, which happens on its own as each signs +//! in: the path is self-draining rather than a one-shot copy. + +use sqlx::postgres::PgPoolOptions; +use sqlx::{PgPool, Row}; +use tracing::{info, instrument, warn}; + +use crate::error::AppError; + +/// One asset key as the legacy instance stored it. +#[derive(Debug, Clone)] +pub struct LegacyKey { + pub asset: String, + pub public_key: String, + pub public_spend_key: Option, +} + +/// A legacy identity, resolved by a key its owner just proved control of. +#[derive(Debug, Clone)] +pub struct LegacyIdentity { + /// The handle held on the legacy instance. The caller decides whether it can + /// be carried over; it is NOT automatically free on v3. + pub username: Option, + /// Every asset key the legacy instance holds for this user. + pub keys: Vec, +} + +impl LegacyIdentity { + /// This identity's key for `asset`, if the legacy instance had one. + pub fn key_for(&self, asset: &str) -> Option<&LegacyKey> { + self.keys.iter().find(|k| k.asset == asset) + } +} + +/// Read-only handle to the legacy database. Disabled unless configured. +#[derive(Clone)] +pub struct LegacyDirectory { + pool: Option, +} + +impl LegacyDirectory { + /// An explicitly-off directory. Every lookup answers `None`. + pub fn disabled() -> Self { + Self { pool: None } + } + + /// Connect when a URL is configured, otherwise stay off. + /// + /// A bad URL is reported and then treated as off. Sign-in migration is a + /// transitional convenience, so it must never be able to stop the service + /// from starting: the failure mode is "legacy users wait", not "nobody + /// serves". + pub async fn connect(database_url: Option<&str>) -> Self { + let Some(url) = database_url.filter(|u| !u.trim().is_empty()) else { + info!("legacy sign-in migration: disabled (no legacy database configured)"); + return Self::disabled(); + }; + + // A small pool on purpose: this path runs once per legacy user, ever. The + // short timeout matters because this runs during boot, and the default + // would stall startup for 30s on a URL that points nowhere. + match PgPoolOptions::new() + .max_connections(2) + .acquire_timeout(std::time::Duration::from_secs(5)) + .connect(url) + .await + { + Ok(pool) => { + info!("legacy sign-in migration: enabled"); + Self { pool: Some(pool) } + } + Err(e) => { + warn!(error = %e, "legacy sign-in migration: could not connect, staying disabled"); + Self::disabled() + } + } + } + + /// Whether lookups can resolve anything. + pub fn is_enabled(&self) -> bool { + self.pool.is_some() + } + + /// Resolve the legacy identity owning `public_key` for `asset`. + /// + /// `public_key` must be a value whose control the caller has ALREADY proven. + /// The match is verbatim against the stored key, so nothing is re-encoded or + /// re-hashed on either side, and a caller cannot use this to enumerate: it + /// answers only about a key they could already sign for. + #[instrument(skip(self, public_key), fields(asset = %asset))] + pub async fn find_by_proven_key( + &self, + asset: &str, + public_key: &str, + ) -> Result, AppError> { + let Some(pool) = self.pool.as_ref() else { + return Ok(None); + }; + + let owner = sqlx::query( + "SELECT u.id, u.username FROM users u \ + JOIN user_keys k ON k.user_id = u.id \ + WHERE k.asset::text = $1 AND k.public_key = $2", + ) + .bind(asset) + .bind(public_key) + .fetch_optional(pool) + .await?; + + let Some(owner) = owner else { + return Ok(None); + }; + let user_id: uuid::Uuid = owner.try_get("id")?; + let username: Option = owner.try_get("username")?; + + let rows = sqlx::query( + "SELECT asset::text AS asset, public_key, public_spend_key \ + FROM user_keys WHERE user_id = $1", + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + let mut keys = Vec::with_capacity(rows.len()); + for r in rows { + keys.push(LegacyKey { + asset: r.try_get("asset")?, + public_key: r.try_get("public_key")?, + public_spend_key: r.try_get("public_spend_key")?, + }); + } + + Ok(Some(LegacyIdentity { username, keys })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The capability is closed unless it is configured open. + #[tokio::test] + async fn absent_configuration_leaves_the_directory_disabled() { + for url in [None, Some(""), Some(" ")] { + let dir = LegacyDirectory::connect(url).await; + assert!(!dir.is_enabled(), "url {url:?} must not enable the lookup"); + } + } + + /// A disabled directory resolves nothing, rather than erroring. Sign-in has + /// to behave exactly as it did before the capability existed. + #[tokio::test] + async fn a_disabled_directory_resolves_nothing() { + let dir = LegacyDirectory::disabled(); + let found = dir.find_by_proven_key("btc", &"a".repeat(66)).await; + assert!(matches!(found, Ok(None))); + } + + /// An unreachable legacy database degrades to "off", never to a failed boot. + #[tokio::test] + async fn an_unreachable_database_degrades_to_disabled() { + // Port 1 is reserved and nothing listens there. + let dir = LegacyDirectory::connect(Some("postgres://u:p@127.0.0.1:1/nope")).await; + assert!(!dir.is_enabled()); + } + + #[test] + fn key_for_selects_by_asset() { + let id = LegacyIdentity { + username: None, + keys: vec![ + LegacyKey { + asset: "btc".into(), + public_key: "bb".into(), + public_spend_key: None, + }, + LegacyKey { + asset: "xmr".into(), + public_key: "xx".into(), + public_spend_key: Some("ss".into()), + }, + ], + }; + assert_eq!(id.key_for("xmr").map(|k| k.public_key.as_str()), Some("xx")); + assert!(id.key_for("ltc").is_none()); + } +} diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 2cc3187..6364cc4 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -6,6 +6,7 @@ pub mod db; pub mod electrum; pub mod grin; pub mod grin_lws; +pub mod legacy_directory; pub mod lws; pub mod payment; pub mod prices; diff --git a/src/lib.rs b/src/lib.rs index 95fc86a..c1412e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,6 +64,9 @@ pub struct AppState { /// config on a settings change and to report a field's source (default/env/db). pub config_base: Arc, pub db: Database, + /// Read-only lookup into the legacy database, for sign-in migration. + /// Disabled unless `LEGACY_DATABASE_URL` is configured. + pub legacy: crate::infra::legacy_directory::LegacyDirectory, pub sessions: SessionManager, /// Per-chain data-source clients (present only for enabled chains). pub chains: ChainClients, diff --git a/src/main.rs b/src/main.rs index 703a4f1..66bd5d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,10 +107,18 @@ async fn main() -> Result<(), Box> { .admin .enabled .then(|| AdminSessionManager::new(&config.admin.jwt_secret)); + // Off unless configured. Connecting here (not lazily per request) means an + // operator sees "enabled" or "staying disabled" once, at boot, in the log. + let legacy = smirk_backend_core::infra::legacy_directory::LegacyDirectory::connect( + config_base.legacy_database_url.as_deref(), + ) + .await; + let state = Arc::new(AppState { config: Arc::new(arc_swap::ArcSwap::from_pointee(config)), config_base: Arc::new(config_base), db, + legacy, sessions, chains, payment,