Files
smirk-backend-core/src/api/website.rs
T
such-gitea 309da0073b 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.
2026-09-15 10:35:53 -04:00

520 lines
21 KiB
Rust

//! Website ("Sign in with your wallet") authentication — the dapp-connect flow.
//!
//! A first-party dapp (smirk.cash, play.wowne.ro, …) lets a user prove control
//! of their Smirk wallet WITHOUT a platform login: the wallet signs a
//! server-issued challenge with its own key material and trades the proof for a
//! standard `(access, refresh)` JWT pair backed by a revocable DB session row —
//! the same session shape as every other login path.
//!
//! Two endpoints, a challenge-response handshake:
//!
//! * [`website_challenge`] — issue a [`WebChallenge`] (a random nonce bound to
//! the requesting `origin`, with a short TTL) and stash it in
//! `state.web_challenges`, keyed by its own nonce. The challenge MESSAGE
//! ([`WebChallenge::message`]) is what the wallet signs.
//! * [`website_verify`] — look the challenge up by nonce, reject it if expired,
//! verify the wallet's signature over the challenge message with the algorithm
//! for the submitted asset (BTC/LTC → Bitcoin/BIP-137 ECDSA, XMR/WOW/Grin →
//! Ed25519), resolve the user from the PROVEN key, mint a `Platform::Web`
//! session, and CONSUME the challenge so it can never be replayed.
//!
//! ## Security properties
//!
//! * **Single-use.** The challenge is removed from the map at the START of
//! verify (before any signature work), so a replayed `challenge_id` finds
//! nothing — even two concurrent verifies race on the `remove` and only one
//! wins. A failed verify still consumes the challenge (no retry on a stolen
//! nonce); the wallet re-requests a fresh one.
//! * **Bounded lifetime.** [`WebChallenge`] carries its own expiry; an expired
//! challenge is rejected even if it is still present in the map.
//! * **No signature-format oracle.** Both verifiers return `Ok(())` ONLY on a
//! valid signature; a malformed input is a `ValidationError` (400) and a
//! well-formed-but-wrong signature an `AuthError` (401), both with LITERAL
//! messages. "User not found" and "signature invalid" deliberately do not
//! leak which one occurred beyond their distinct, fixed literals.
//! * **No user creation.** Website sign-in resolves an ALREADY-registered
//! wallet from the proven key; an unknown key is a 401 with a fixed literal.
//! It never mints an identity from a bare signature, and it never names a
//! client the user is supposedly missing: the wallet that can sign here is by
//! definition the right client.
//!
//! ## Resolving the signer
//!
//! The proven key is looked up as itself, against the `primary` `user_keys` row
//! for the asset that signed ([`Database::get_user_by_asset_pubkey`]): the very
//! value `/auth/extension` stored at registration.
//!
//! This used to resolve only through `users.pubkey_hash`, which holds the BTC
//! key's hash and nothing else, so LTC/XMR/WOW/Grin sign-in could not succeed
//! for anyone: the lookup had no row that a non-BTC key could ever match, and an
//! unregistered key is indistinguishable from an unknown user, so the failure
//! surfaced as "register first" to users who were already registered.
//!
//! `pubkey_hash` remains the BTC-only fallback. Identities imported by
//! `migrate-legacy` carry `pubkey_hash` but no `user_keys` rows until their
//! owner unlocks a v0.3 wallet, and they must keep signing in meanwhile.
//!
//! Routes are registered RELATIVE to `/api/v1` (e.g. `/auth/website/challenge`);
//! see [`routes`]. The app nests this router under `/api/v1`.
use std::net::SocketAddr;
use std::sync::Arc;
use axum::{
extract::{ConnectInfo, State},
http::HeaderMap,
routing::post,
Json, Router,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
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::infra::legacy_directory::LegacyIdentity;
use crate::models::db::{AssetType, NewSession, NewUser, NewUserKey, User};
use crate::AppState;
// ── DTOs ──────────────────────────────────────────────────────────────────────
/// Request a website-auth challenge for a calling origin.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct WebsiteChallengeRequest {
/// Origin of the requesting website (e.g. `https://smirk.cash`). Bound into
/// the challenge message so a signature for one origin is not reusable at
/// another.
pub origin: String,
}
/// The challenge to sign, with the handle and expiry the wallet echoes back.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct WebsiteChallengeResponse {
/// The exact message string to sign with the wallet's key.
pub challenge: String,
/// Opaque handle to present at verify time (this is the challenge nonce).
pub challenge_id: String,
/// When the challenge expires (RFC 3339).
pub expires_at: String,
}
/// One asset's signature over the challenge message.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct AssetSignature {
/// Asset whose key produced the signature (`btc`, `ltc`, `xmr`, `wow`, `grin`).
pub asset: String,
/// Signature over the challenge message. BIP-137 base64 for BTC/LTC; 64-byte
/// hex Ed25519 for XMR/WOW/Grin.
pub signature: String,
/// The public key that produced the signature (format depends on asset).
pub public_key: String,
}
/// Verify a website-auth challenge with a single asset signature.
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct WebsiteVerifyRequest {
/// The `challenge_id` returned by [`website_challenge`] (the challenge nonce).
pub challenge_id: String,
/// The wallet's signature over the challenge message.
pub signature: AssetSignature,
}
// ── helpers ─────────────────────────────────────────────────────────────────
/// Parse an asset string into [`AssetType`], or a literal 400.
fn parse_asset(asset: &str) -> Result<AssetType, AppError> {
match asset.to_lowercase().as_str() {
"btc" => Ok(AssetType::Btc),
"ltc" => Ok(AssetType::Ltc),
"xmr" => Ok(AssetType::Xmr),
"wow" => Ok(AssetType::Wow),
"grin" => Ok(AssetType::Grin),
other => Err(AppError::ValidationError(format!("Invalid asset: {other}"))),
}
}
/// SHA-256 hex of a public key string — the wallet's stable identity handle,
/// computed the same way as the extension-registration path so the two flows
/// resolve to the same user row.
fn hash_public_key(public_key: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(public_key.as_bytes()))
}
/// Verify the challenge signature with the algorithm for `asset`. Returns
/// `Ok(())` ONLY on a cryptographically valid signature; the inner verifiers
/// map malformed input to a literal `ValidationError` (400) and a wrong-but-
/// well-formed signature to a literal `AuthError` (401), so this is not an
/// oracle.
fn verify_asset_signature(
asset: AssetType,
message: &str,
signature: &str,
public_key: &str,
) -> Result<(), AppError> {
match asset {
AssetType::Btc | AssetType::Ltc => verify_bitcoin_signature(message, signature, public_key),
AssetType::Xmr | AssetType::Wow | AssetType::Grin => {
verify_ed25519_signature(message, signature, public_key)
}
}
}
/// Build [`UserInfo`] from a DB user.
fn user_info(user: &crate::models::db::User) -> UserInfo {
UserInfo {
id: user.id.to_string(),
username: user.username.clone(),
nostr_pubkey: user.nostr_pubkey.clone(),
}
}
// ── POST /auth/website/challenge ──────────────────────────────────────────────
/// Issue a website-auth challenge bound to the requesting origin.
///
/// The challenge is stored in `state.web_challenges` keyed by its own nonce and
/// expires shortly (see [`WebChallenge`]). The returned `challenge` message is
/// what the wallet signs; `challenge_id` is the nonce to present at verify.
#[utoipa::path(
post,
path = "/auth/website/challenge",
request_body = WebsiteChallengeRequest,
responses(
(status = 200, description = "Website authentication challenge created", body = WebsiteChallengeResponse),
(status = 400, description = "Invalid origin")
),
tag = "auth"
)]
#[instrument(skip(state, req))]
pub async fn website_challenge(
State(state): State<Arc<AppState>>,
Json(req): Json<WebsiteChallengeRequest>,
) -> Result<Json<WebsiteChallengeResponse>, AppError> {
// Minimal origin sanity check: a non-empty http(s) origin. The value is only
// ever echoed into the signed message and the login-event record; it is not
// trusted for any authorization decision.
if req.origin.is_empty() || !req.origin.starts_with("http") {
return Err(AppError::ValidationError("Invalid origin".into()));
}
let challenge = WebChallenge::new(req.origin);
// The nonce IS the lookup key, so a verify cannot reference a challenge by a
// handle the server did not issue.
let challenge_id = challenge.nonce.clone();
let challenge_message = challenge.message();
let expires_at = challenge.expires_at.to_rfc3339();
{
let mut challenges = state.web_challenges.write().await;
challenges.insert(challenge_id.clone(), challenge);
}
info!(challenge_id = %challenge_id, "issued website auth challenge");
Ok(Json(WebsiteChallengeResponse {
challenge: challenge_message,
challenge_id,
expires_at,
}))
}
// ── POST /auth/website/verify ─────────────────────────────────────────────────
/// Verify a website-auth signature and mint a `Platform::Web` session.
///
/// The challenge is CONSUMED (removed from the map) before any signature work,
/// so it is strictly single-use: a replayed `challenge_id` — or the loser of two
/// concurrent verifies — finds nothing and is rejected. After consume we check
/// expiry, verify the signature over the challenge message with the algorithm
/// for the submitted asset, resolve the ALREADY-registered user by the proven
/// key's `pubkey_hash` (never creating one), and issue a session. All failure
/// messages are literals (no oracle).
#[utoipa::path(
post,
path = "/auth/website/verify",
request_body = WebsiteVerifyRequest,
responses(
(status = 200, description = "Website authentication verified; session issued", body = AuthResponse),
(status = 400, description = "Invalid asset or signature format"),
(status = 401, description = "Invalid/expired challenge, bad signature, or unknown wallet")
),
tag = "auth"
)]
#[instrument(skip(state, headers, req, peer))]
pub async fn website_verify(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<WebsiteVerifyRequest>,
) -> Result<Json<AuthResponse>, AppError> {
let ip = client_ip(&state, &headers, peer);
// Consume the challenge FIRST (single-use): remove it before any signature
// work so a replay — or the loser of a concurrent verify — finds nothing.
let challenge = {
let mut challenges = state.web_challenges.write().await;
challenges.remove(&req.challenge_id).ok_or_else(|| {
info!("website_verify: challenge not found / already consumed");
AppError::AuthError("Invalid or expired challenge".into())
})?
};
// Reject an expired challenge even though it was present (and is now gone).
if challenge.is_expired() {
info!("website_verify: challenge expired");
return Err(AppError::AuthError("Invalid or expired challenge".into()));
}
// Parse the asset (literal 400 on an unknown asset) and verify the signature
// over the challenge message with that asset's algorithm. `?` propagates the
// literal Validation/Auth error from the verifier — never a bool.
let asset = parse_asset(&req.signature.asset)?;
let message = challenge.message();
verify_asset_signature(
asset,
&message,
&req.signature.signature,
&req.signature.public_key,
)?;
// The signature is valid: the caller controls this key. Resolve the
// ALREADY-registered wallet from THAT key. We never create a user here.
//
// Resolve by the proven asset's own registered key first. Resolving only by
// `users.pubkey_hash` used to be the whole lookup, and `pubkey_hash` only
// ever holds the BTC key's hash (see `auth::extension_register`), so a
// signature from any other coin could not match anything no matter how
// correct it was. Users reported "User not found" while their wallet,
// handle and desktop session all worked; picking a different coin on the
// sign-in page was the only thing that changed the outcome.
//
// The `pubkey_hash` lookup stays as the BTC fallback: rows imported by
// `migrate-legacy` carry an identity but no `user_keys` (keys are
// re-registered on the first v0.3 unlock), and dropping the fallback would
// lock every one of them out.
let resolved = match state
.db
.get_user_by_asset_pubkey(asset, &req.signature.public_key)
.await?
{
Some(user) => Some(user),
None if asset == AssetType::Btc => {
let pubkey_hash = hash_public_key(&req.signature.public_key);
state.db.get_user_by_pubkey_hash(&pubkey_hash).await?
}
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(
"We could not match this coin's key to a Smirk account on this server. \
Unlock your Smirk wallet once so it publishes its keys here, then try \
again with this or another coin."
.into(),
)
})?;
// Mint a Web session: token pair + revocable DB session row (peppered
// refresh hash). Mirrors `issue_session` in the auth module so the session
// shape cannot drift.
let session_id = uuid::Uuid::new_v4();
let pair = state
.sessions
.create_token_pair(user.id, Platform::Web, session_id)?;
let refresh_token_hash = hash_refresh_token(
&pair.refresh_token,
&state.cfg().secrets.refresh_token_pepper,
);
let expires_at = Utc::now() + state.sessions.refresh_token_expiry();
state
.db
.create_session(NewSession {
id: session_id,
user_id: user.id,
refresh_token_hash,
platform: Platform::Web.to_string(),
device_info: Some("Web Browser".to_string()),
expires_at,
})
.await?;
// Best-effort analytics (which asset signed, from which origin).
let _ = state
.db
.record_login_event(
Some(user.id),
asset.as_str(),
Platform::Web.as_str(),
Some(&challenge.origin),
Some(&ip.to_string()),
)
.await;
let _ = state.db.update_user_last_seen(user.id).await;
info!(user_id = %user.id, asset = %asset, "authenticated via website");
Ok(Json(AuthResponse {
access_token: pair.access_token,
refresh_token: pair.refresh_token,
expires_in: pair.expires_in,
user: user_info(&user),
is_new: false,
}))
}
/// 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<User, AppError> {
// 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
/// is expected to `Router::new().nest("/api/v1", website::routes())` and serve
/// with `into_make_service_with_connect_info::<SocketAddr>()` so [`client_ip`]
/// sees the real TCP peer.
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/auth/website/challenge", post(website_challenge))
.route("/auth/website/verify", post(website_verify))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asset_parse_roundtrips_and_rejects() {
assert_eq!(parse_asset("BTC").unwrap(), AssetType::Btc);
assert_eq!(parse_asset("grin").unwrap(), AssetType::Grin);
assert!(parse_asset("doge").is_err());
}
#[test]
fn hash_public_key_is_stable_hex() {
let a = hash_public_key("deadbeef");
assert_eq!(a, hash_public_key("deadbeef"));
assert_eq!(a.len(), 64);
assert_ne!(a, hash_public_key("deadbee0"));
}
/// BTC/LTC route through the Bitcoin verifier, XMR/WOW/Grin through Ed25519.
/// A malformed input must be a `ValidationError` (400), never a silent pass.
#[test]
fn verify_asset_signature_routes_by_asset_and_never_passes_garbage() {
for asset in [AssetType::Btc, AssetType::Ltc] {
let err = verify_asset_signature(asset, "msg", "not-base64-…", "zz").unwrap_err();
assert!(matches!(err, AppError::ValidationError(_)));
}
for asset in [AssetType::Xmr, AssetType::Wow, AssetType::Grin] {
let err = verify_asset_signature(asset, "msg", &"00".repeat(64), "zz").unwrap_err();
assert!(matches!(err, AppError::ValidationError(_)));
}
}
/// Wire-shape regression: the verify request nests a single `AssetSignature`.
#[test]
fn verify_request_wire_shape() {
let body = r#"{
"challenge_id": "abc123",
"signature": {
"asset": "btc",
"signature": "sig",
"public_key": "deadbeef"
}
}"#;
let req: WebsiteVerifyRequest =
serde_json::from_str(body).expect("verify request deserializes");
assert_eq!(req.challenge_id, "abc123");
assert_eq!(req.signature.asset, "btc");
}
}