From 309da0073b6d1547106cd51cec82d6438a8277e4 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 15 Sep 2026 10:35:53 -0400 Subject: [PATCH] feat(migration): adopt a legacy identity on proven sign-in Sign-in resolves the proven key against this instance first, then the BTC pubkey_hash fallback. When both miss and the legacy directory is configured, it now asks whether the legacy instance knows that key, and adopts the identity if so. With the directory off, which is the default, the lookup resolves nothing and the refusal is exactly what it was. The import creates a NEW row rather than attaching to an existing one, and that is the point. Fourteen of the sixteen legacy handles are also held here by rows we cannot prove belong to the same person, so adopting by name could hand this person's keys to someone else's account. The keys just proven are the identity; a handle is carried over only when it is free here, and a key another account already holds is skipped so one key can never resolve to two users. pubkey_hash is not carried either: legacy computes a plain sha256 where this instance stores an HMAC, so the value would be meaningless here, and a collision would be with a row we cannot identify. Migrating on sign-in rather than in a batch means every import is gated on a signature the user just produced, and the population drains on its own as people arrive. Nothing is copied for a user who never returns. Not yet exercised against a real legacy database: that needs the read-only credential in place, so this stays closed until it is. --- src/api/users.rs | 4 +- src/api/website.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/api/users.rs b/src/api/users.rs index db50238..15fce5c 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -354,7 +354,7 @@ pub const KEY_TYPE_SLATEPACK: &str = "slatepack"; /// cannot begin with those characters because `g`, `r`, `i` and `n` are not hex /// digits. So this is a total discriminator, not a heuristic that a well-formed /// public key could trip. -fn is_grin_slatepack_address(value: &str) -> bool { +pub(crate) fn is_grin_slatepack_address(value: &str) -> bool { let v = value.trim(); v.starts_with("grin1") || v.starts_with("tgrin1") } @@ -367,7 +367,7 @@ fn is_grin_slatepack_address(value: &str) -> bool { /// the second from overwriting the first on `(user_id, asset, key_type)`, and it /// does so for wallets that are ALREADY SHIPPED, which cannot be taught to send a /// `key_type` of their own. -fn key_type_for(asset: AssetType, public_key: &str) -> &'static str { +pub(crate) fn key_type_for(asset: AssetType, public_key: &str) -> &'static str { match asset { AssetType::Grin if is_grin_slatepack_address(public_key) => KEY_TYPE_SLATEPACK, _ => KEY_TYPE_PRIMARY, diff --git a/src/api/website.rs b/src/api/website.rs index cbe8e41..d28dfd6 100644 --- a/src/api/website.rs +++ b/src/api/website.rs @@ -72,10 +72,12 @@ use tracing::{info, instrument}; use crate::api::auth::{AuthResponse, UserInfo}; use crate::api::middleware::client_ip; +use crate::api::users::key_type_for; use crate::core::crypto::signatures::{verify_bitcoin_signature, verify_ed25519_signature}; use crate::core::session::{hash_refresh_token, Platform, WebChallenge}; use crate::error::AppError; -use crate::models::db::{AssetType, NewSession}; +use crate::infra::legacy_directory::LegacyIdentity; +use crate::models::db::{AssetType, NewSession, NewUser, NewUserKey, User}; use crate::AppState; // ── DTOs ────────────────────────────────────────────────────────────────────── @@ -307,6 +309,22 @@ pub async fn website_verify( } None => None, }; + + // Still nothing: the legacy instance may hold this identity. Disabled unless + // an operator configured it, in which case this resolves nothing and the + // refusal below is unchanged. Migrating here rather than in a batch means + // every import is gated on a signature the user just produced. + let resolved = match resolved { + Some(user) => Some(user), + None => match state + .legacy + .find_by_proven_key(asset.as_str(), &req.signature.public_key) + .await? + { + Some(identity) => Some(import_legacy_identity(&state, identity).await?), + None => None, + }, + }; let user = resolved.ok_or_else(|| { info!(asset = %asset, "website_verify: no user for proven key"); AppError::AuthError( @@ -365,6 +383,78 @@ pub async fn website_verify( })) } +/// Adopt a legacy identity into this instance, having just proven one of its keys. +/// +/// Creates a NEW row rather than attaching to an existing one. That is the whole +/// safety property: 14 of the 16 legacy handles are also held here by rows we +/// cannot prove belong to the same person, so adopting by name could hand this +/// person's keys to someone else's account. The keys we just proved are the +/// identity; everything else is left behind. +async fn import_legacy_identity( + state: &AppState, + identity: LegacyIdentity, +) -> Result { + // Carry the handle only when it is free here. Taking a held one is the + // takeover we are avoiding, so a collision silently imports without it and + // the user re-claims a handle as normal. + let username = match identity.username.as_deref().map(str::to_lowercase) { + Some(name) => match state.db.get_user_by_username(&name).await? { + None => Some(name), + Some(_) => { + info!("legacy import: handle already held here, importing without it"); + None + } + }, + None => None, + }; + + // pubkey_hash is deliberately NOT carried. Legacy computes a plain sha256 + // where this instance stores an HMAC, so the value would be meaningless, and + // importing one risks colliding with a row we cannot prove is the same + // person. The imported keys are the credential. + let user = state + .db + .create_user(NewUser { + username, + pubkey_hash: None, + nostr_pubkey: None, + wallet_birthday: None, + seed_fingerprint: None, + xmr_start_height: None, + wow_start_height: None, + }) + .await?; + + for key in &identity.keys { + let Ok(key_asset) = parse_asset(&key.asset) else { + continue; + }; + // Never claim a key some other account here already holds: that would + // make the same key resolve to two users. + if state + .db + .get_user_by_asset_pubkey(key_asset, &key.public_key) + .await? + .is_some() + { + continue; + } + state + .db + .upsert_user_key(NewUserKey { + user_id: user.id, + asset: key_asset, + public_key: key.public_key.clone(), + public_spend_key: key.public_spend_key.clone(), + key_type: key_type_for(key_asset, &key.public_key).to_string(), + }) + .await?; + } + + info!(user_id = %user.id, "migrated a legacy identity on proven sign-in"); + Ok(user) +} + // ── router ─────────────────────────────────────────────────────────────────── /// Website-auth routes, RELATIVE to the `/api/v1` mount point. The application