diff --git a/openapi.json b/openapi.json index 546122e..f068881 100644 --- a/openapi.json +++ b/openapi.json @@ -13,6 +13,39 @@ "version": "0.3.0" }, "paths": { + "/.well-known/nostr.json": { + "get": { + "tags": [ + "nip05" + ], + "summary": "NIP-05 resolution for Smirk usernames.", + "description": "Looks up the user by lowercased `?name=` and, if they have a linked\n`nostr_pubkey`, returns it in `names` (with the default relay hints under\n`relays`). An unknown name, an unlinked user, or an absent `name` parameter\nall return an empty document — this endpoint never errors on the lookup path\nand never discloses which usernames exist.", + "operationId": "well_known_nostr", + "parameters": [ + { + "name": "name", + "in": "query", + "description": "NIP-05 local part to resolve", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "NIP-05 directory document (empty when unresolved)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WellKnownResponse" + } + } + } + } + } + } + }, "/auth/check-restore": { "post": { "tags": [ @@ -242,6 +275,79 @@ } } }, + "/auth/website/challenge": { + "post": { + "tags": [ + "auth" + ], + "summary": "Issue a website-auth challenge bound to the requesting origin.", + "description": "The challenge is stored in `state.web_challenges` keyed by its own nonce and\nexpires shortly (see [`WebChallenge`]). The returned `challenge` message is\nwhat the wallet signs; `challenge_id` is the nonce to present at verify.", + "operationId": "website_challenge", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteChallengeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Website authentication challenge created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteChallengeResponse" + } + } + } + }, + "400": { + "description": "Invalid origin" + } + } + } + }, + "/auth/website/verify": { + "post": { + "tags": [ + "auth" + ], + "summary": "Verify a website-auth signature and mint a `Platform::Web` session.", + "description": "The challenge is CONSUMED (removed from the map) before any signature work,\nso it is strictly single-use: a replayed `challenge_id` — or the loser of two\nconcurrent verifies — finds nothing and is rejected. After consume we check\nexpiry, verify the signature over the challenge message with the algorithm\nfor the submitted asset, resolve the ALREADY-registered user by the proven\nkey's `pubkey_hash` (never creating one), and issue a session. All failure\nmessages are literals (no oracle).", + "operationId": "website_verify", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebsiteVerifyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Website authentication verified; session issued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "400": { + "description": "Invalid asset or signature format" + }, + "401": { + "description": "Invalid/expired challenge, bad signature, or unknown wallet" + } + } + } + }, "/health": { "get": { "tags": [ @@ -263,6 +369,220 @@ } } } + }, + "/keys": { + "post": { + "tags": [ + "users" + ], + "summary": "Register or update one of the authenticated user's per-asset public keys.", + "description": "Idempotent: the DB upsert keys on `(user_id, asset, key_type)`, so re-posting\nthe same asset replaces the stored key material in place.", + "operationId": "register_key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Key registered or updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserKeyInfo" + } + } + } + }, + "400": { + "description": "Invalid asset" + }, + "401": { + "description": "Missing or invalid token" + } + } + } + }, + "/users/by-username/{username}": { + "get": { + "tags": [ + "users" + ], + "summary": "Resolve a username to its user and that user's receiving public keys.", + "description": "Public: this is how a sender discovers where to send. The lookup is\ncase-insensitive (the handle is canonicalized lowercase). An unknown handle\nreturns the same constant-shape response with `registered: false`.", + "operationId": "lookup_username", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "Username to resolve", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Username resolution result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LookupUsernameResponse" + } + } + } + } + } + } + }, + "/users/me/username": { + "get": { + "tags": [ + "users" + ], + "summary": "The authenticated user's own username (or `null`).", + "operationId": "get_my_username", + "responses": { + "200": { + "description": "Caller's username", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyUsernameResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid token" + } + } + }, + "post": { + "tags": [ + "users" + ], + "summary": "Claim or update the authenticated user's username.", + "description": "The submitted value is lowercased, validated, and checked against the\nreserved list before the DB write. The UNIQUE constraint is the atomic claim:\na collision (taken or reserved) is a 409, never a 500.", + "operationId": "set_username", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetUsernameRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Username claimed or updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetUsernameResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid token" + }, + "409": { + "description": "Username is taken or reserved" + } + } + } + }, + "/users/{user_id}/keys": { + "get": { + "tags": [ + "users" + ], + "summary": "List a user's per-asset receiving public keys.", + "description": "Public: a sender fetches these to construct a payment to `user_id`.", + "operationId": "get_user_keys", + "parameters": [ + { + "name": "user_id", + "in": "path", + "description": "User id (UUID)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The user's public keys", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserKeysResponse" + } + } + } + }, + "400": { + "description": "Malformed user id" + } + } + } + }, + "/users/{user_id}/keys/{asset}": { + "get": { + "tags": [ + "users" + ], + "summary": "Fetch a user's receiving public key for one asset.", + "operationId": "get_user_key_for_asset", + "parameters": [ + { + "name": "user_id", + "in": "path", + "description": "User id (UUID)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "asset", + "in": "path", + "description": "Asset (btc, ltc, xmr, wow, grin)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The user's key for this asset", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserKeyInfo" + } + } + } + }, + "400": { + "description": "Invalid asset" + }, + "404": { + "description": "User has no key for this asset" + } + } + } } }, "components": { @@ -290,6 +610,29 @@ } } }, + "AssetSignature": { + "type": "object", + "description": "One asset's signature over the challenge message.", + "required": [ + "asset", + "signature", + "public_key" + ], + "properties": { + "asset": { + "type": "string", + "description": "Asset whose key produced the signature (`btc`, `ltc`, `xmr`, `wow`, `grin`)." + }, + "public_key": { + "type": "string", + "description": "The public key that produced the signature (format depends on asset)." + }, + "signature": { + "type": "string", + "description": "Signature over the challenge message. BIP-137 base64 for BTC/LTC; 64-byte\nhex Ed25519 for XMR/WOW/Grin." + } + } + }, "AuthResponse": { "type": "object", "description": "Successful session response: a JWT pair plus minimal user info.", @@ -487,6 +830,56 @@ } } }, + "LookupUsernameResponse": { + "type": "object", + "description": "Result of resolving a username. Constant-shape: the same fields are always\npresent, so the response is not a structure oracle.", + "required": [ + "registered" + ], + "properties": { + "public_keys": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PublicKeysInfo", + "description": "The user's per-asset receiving public keys, if registered." + } + ] + }, + "registered": { + "type": "boolean", + "description": "Whether a user owns this username." + }, + "user_id": { + "type": [ + "string", + "null" + ], + "description": "The resolved user id (UUID string), if registered." + }, + "username": { + "type": [ + "string", + "null" + ], + "description": "The canonical (lowercased) username, if registered." + } + } + }, + "MyUsernameResponse": { + "type": "object", + "properties": { + "username": { + "type": [ + "string", + "null" + ], + "description": "The caller's username, or `null` if they have not claimed one." + } + } + }, "NostrLinkRequest": { "type": "object", "description": "Link a Nostr identity to the authenticated user. This is a STATE CHANGE, so\nit carries a signed-action proof, not a login-grade token.", @@ -517,6 +910,42 @@ } } }, + "PublicKeysInfo": { + "type": "object", + "description": "A user's per-asset receiving public keys, by asset.", + "properties": { + "btc": { + "type": [ + "string", + "null" + ] + }, + "grin": { + "type": [ + "string", + "null" + ] + }, + "ltc": { + "type": [ + "string", + "null" + ] + }, + "wow": { + "type": [ + "string", + "null" + ] + }, + "xmr": { + "type": [ + "string", + "null" + ] + } + } + }, "RefreshTokenRequest": { "type": "object", "required": [ @@ -528,6 +957,55 @@ } } }, + "RegisterKeyRequest": { + "type": "object", + "description": "Register or update one of the caller's per-asset public keys.", + "required": [ + "asset", + "public_key" + ], + "properties": { + "asset": { + "type": "string", + "description": "Asset the key is for (`btc`, `ltc`, `xmr`, `wow`, `grin`)." + }, + "public_key": { + "type": "string", + "description": "The public key (format depends on the asset)." + }, + "public_spend_key": { + "type": [ + "string", + "null" + ], + "description": "XMR/WOW only: the public spend key." + } + } + }, + "SetUsernameRequest": { + "type": "object", + "required": [ + "username" + ], + "properties": { + "username": { + "type": "string", + "description": "Desired username (3-32 chars, lowercase `[a-z0-9_]`, no leading/trailing\nunderscore). Submitted values are lowercased before validation." + } + } + }, + "SetUsernameResponse": { + "type": "object", + "required": [ + "username" + ], + "properties": { + "username": { + "type": "string", + "description": "The username as stored (lowercased)." + } + } + }, "UserInfo": { "type": "object", "description": "Minimal, non-enumerable user info returned to a signed-in client.", @@ -552,6 +1030,132 @@ ] } } + }, + "UserKeyInfo": { + "type": "object", + "description": "A single per-asset public key.", + "required": [ + "asset", + "public_key" + ], + "properties": { + "asset": { + "type": "string", + "description": "Asset the key is for (lowercase: `btc`, `ltc`, `xmr`, `wow`, `grin`)." + }, + "public_key": { + "type": "string" + }, + "public_spend_key": { + "type": [ + "string", + "null" + ], + "description": "XMR/WOW only: the public spend key." + } + } + }, + "UserKeysResponse": { + "type": "object", + "description": "A user's full set of per-asset public keys.", + "required": [ + "keys" + ], + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserKeyInfo" + } + } + } + }, + "WebsiteChallengeRequest": { + "type": "object", + "description": "Request a website-auth challenge for a calling origin.", + "required": [ + "origin" + ], + "properties": { + "origin": { + "type": "string", + "description": "Origin of the requesting website (e.g. `https://smirk.cash`). Bound into\nthe challenge message so a signature for one origin is not reusable at\nanother." + } + } + }, + "WebsiteChallengeResponse": { + "type": "object", + "description": "The challenge to sign, with the handle and expiry the wallet echoes back.", + "required": [ + "challenge", + "challenge_id", + "expires_at" + ], + "properties": { + "challenge": { + "type": "string", + "description": "The exact message string to sign with the wallet's key." + }, + "challenge_id": { + "type": "string", + "description": "Opaque handle to present at verify time (this is the challenge nonce)." + }, + "expires_at": { + "type": "string", + "description": "When the challenge expires (RFC 3339)." + } + } + }, + "WebsiteVerifyRequest": { + "type": "object", + "description": "Verify a website-auth challenge with a single asset signature.", + "required": [ + "challenge_id", + "signature" + ], + "properties": { + "challenge_id": { + "type": "string", + "description": "The `challenge_id` returned by [`website_challenge`] (the challenge nonce)." + }, + "signature": { + "$ref": "#/components/schemas/AssetSignature", + "description": "The wallet's signature over the challenge message." + } + } + }, + "WellKnownResponse": { + "type": "object", + "description": "NIP-05 well-known document.\n\n* `names` maps each resolved local part to its x-only Nostr pubkey (hex).\n* `relays` maps each returned pubkey to a list of recommended relay URLs.\n\nBoth maps are empty when the name is unknown, unlinked, or omitted.", + "required": [ + "names", + "relays" + ], + "properties": { + "names": { + "type": "object", + "description": "`{ local_part: nostr_pubkey_hex }`. Empty when nothing resolved.", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "relays": { + "type": "object", + "description": "`{ nostr_pubkey_hex: [relay_url, ...] }`. Empty when nothing resolved.", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "propertyNames": { + "type": "string" + } + } + } } } }, diff --git a/src/api/mod.rs b/src/api/mod.rs index 03cb9b7..a625862 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -3,4 +3,7 @@ pub mod auth; pub mod health; pub mod middleware; +pub mod nip05; pub mod openapi; +pub mod users; +pub mod website; diff --git a/src/api/nip05.rs b/src/api/nip05.rs new file mode 100644 index 0000000..9c1ff72 --- /dev/null +++ b/src/api/nip05.rs @@ -0,0 +1,177 @@ +//! NIP-05 directory hosting: serves `/.well-known/nostr.json`. +//! +//! This is the directory half of NIP-05. It resolves a Smirk `username` to the +//! Nostr pubkey the user has linked (the `nostr_pubkey` column, set via +//! `POST /api/v1/auth/nostr/link`), and pairs with the wallet-side resolver +//! (`@smirk/core` `resolveNip05`) so Smirk users become findable by any Nostr +//! client. +//! +//! Behavior is intentionally enumeration-safe and non-failing for the public +//! lookup surface: +//! +//! * Usernames are stored lowercased (see `users::set_username`), so the `?name=` +//! query is lowercased before lookup. +//! * An unknown name, an unlinked user, or a missing `name` parameter all return +//! the SAME empty document (`{"names":{},"relays":{}}`) — never a 404 and never +//! an error — so the endpoint does not disclose which usernames exist. +//! * Foreign error detail (sqlx) is the only thing routed to `AppError`; the +//! client-facing failure messages are literals. +//! +//! This module mounts at the SERVER ROOT, not under `/api/v1`: NIP-05 mandates +//! the well-known path `/.well-known/nostr.json`. The application is expected to +//! `merge` [`routes`] into the root router (alongside the `/api/v1`-nested +//! routers). +//! +//! Spec: + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::{ + extract::{Query, State}, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; + +use crate::error::AppError; +use crate::AppState; + +// ── constants ───────────────────────────────────────────────────────────────── + +/// Default relay hints advertised in the well-known `relays` map. +/// +/// Public relays shared with other wallets so cross-wallet delivery works before +/// any per-user relay list is configured. NIP-05 lets a server advertise relay +/// hints per pubkey in the `relays` object; we serve the same default set for +/// every resolved name. +/// +/// TODO(phase-2): make configurable and serve per-user lists (NIP-65 kind 10002 +/// / NIP-17 inbox relays) when private-message delivery lands. +const DEFAULT_RELAYS: &[&str] = &["wss://relay.damus.io", "wss://nos.lol"]; + +// ── DTOs ────────────────────────────────────────────────────────────────────── + +/// Query for `GET /.well-known/nostr.json`. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct WellKnownQuery { + /// The local part to resolve (NIP-05 `?name=`). Absent or unknown resolves to + /// an empty document rather than an error. + pub name: Option, +} + +/// NIP-05 well-known document. +/// +/// * `names` maps each resolved local part to its x-only Nostr pubkey (hex). +/// * `relays` maps each returned pubkey to a list of recommended relay URLs. +/// +/// Both maps are empty when the name is unknown, unlinked, or omitted. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct WellKnownResponse { + /// `{ local_part: nostr_pubkey_hex }`. Empty when nothing resolved. + pub names: HashMap, + /// `{ nostr_pubkey_hex: [relay_url, ...] }`. Empty when nothing resolved. + pub relays: HashMap>, +} + +// ── GET /.well-known/nostr.json ──────────────────────────────────────────────── + +/// NIP-05 resolution for Smirk usernames. +/// +/// Looks up the user by lowercased `?name=` and, if they have a linked +/// `nostr_pubkey`, returns it in `names` (with the default relay hints under +/// `relays`). An unknown name, an unlinked user, or an absent `name` parameter +/// all return an empty document — this endpoint never errors on the lookup path +/// and never discloses which usernames exist. +#[utoipa::path( + get, + path = "/.well-known/nostr.json", + params( + ("name" = Option, Query, description = "NIP-05 local part to resolve") + ), + responses( + (status = 200, description = "NIP-05 directory document (empty when unresolved)", body = WellKnownResponse) + ), + tag = "nip05" +)] +#[instrument(skip(state))] +pub async fn well_known_nostr( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + let mut names = HashMap::new(); + let mut relays = HashMap::new(); + + if let Some(raw) = q.name { + // Usernames are stored lowercased (see users::set_username). + let name = raw.to_lowercase(); + if let Some(user) = state.db.get_user_by_username(&name).await? { + if let Some(pubkey) = user.nostr_pubkey { + relays.insert( + pubkey.clone(), + DEFAULT_RELAYS.iter().map(|r| r.to_string()).collect(), + ); + names.insert(name, pubkey); + } + } + } + + Ok(Json(WellKnownResponse { names, relays })) +} + +// ── router ───────────────────────────────────────────────────────────────────── + +/// NIP-05 route, mounted at the SERVER ROOT (NOT under `/api/v1`). NIP-05 +/// mandates the well-known path verbatim, so the application is expected to +/// `merge` this into the root router rather than nest it under `/api/v1`. +pub fn routes() -> Router> { + Router::new().route("/.well-known/nostr.json", get(well_known_nostr)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The query field is optional: `name` round-trips when present and defaults + /// to `None` when absent. + #[test] + fn well_known_query_wire_shape() { + let with_name: WellKnownQuery = + serde_json::from_str(r#"{"name":"alice"}"#).expect("name parses"); + assert_eq!(with_name.name.as_deref(), Some("alice")); + + let empty: WellKnownQuery = serde_json::from_str("{}").expect("empty parses"); + assert!(empty.name.is_none()); + } + + /// Default relay hints are well-formed `wss://` URLs. + #[test] + fn default_relays_are_wss() { + assert!(!DEFAULT_RELAYS.is_empty()); + assert!(DEFAULT_RELAYS.iter().all(|r| r.starts_with("wss://"))); + } + + /// The response serializes to the exact NIP-05 wire shape, and an empty + /// document is `{"names":{},"relays":{}}` (never `null`). + #[test] + fn response_serializes_to_nip05_shape() { + let empty = WellKnownResponse { + names: HashMap::new(), + relays: HashMap::new(), + }; + let json = serde_json::to_value(&empty).unwrap(); + assert_eq!(json, serde_json::json!({ "names": {}, "relays": {} })); + + let mut names = HashMap::new(); + names.insert("alice".to_string(), "abc123".to_string()); + let mut relays = HashMap::new(); + relays.insert( + "abc123".to_string(), + vec!["wss://relay.damus.io".to_string()], + ); + let json = serde_json::to_value(&WellKnownResponse { names, relays }).unwrap(); + assert_eq!(json["names"]["alice"], "abc123"); + assert_eq!(json["relays"]["abc123"][0], "wss://relay.damus.io"); + } +} diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 2ee7307..1770e95 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -24,6 +24,15 @@ use utoipa::OpenApi; crate::api::auth::get_me, crate::api::auth::nostr_login, crate::api::auth::nostr_link, + crate::api::website::website_challenge, + crate::api::website::website_verify, + crate::api::users::set_username, + crate::api::users::get_my_username, + crate::api::users::lookup_username, + crate::api::users::register_key, + crate::api::users::get_user_keys, + crate::api::users::get_user_key_for_asset, + crate::api::nip05::well_known_nostr, ), components(schemas( crate::api::health::HealthResponse, @@ -38,6 +47,19 @@ use utoipa::OpenApi; crate::api::auth::LogoutResponse, crate::api::auth::NostrLinkRequest, crate::api::auth::NostrLinkResponse, + crate::api::website::WebsiteChallengeRequest, + crate::api::website::WebsiteChallengeResponse, + crate::api::website::AssetSignature, + crate::api::website::WebsiteVerifyRequest, + crate::api::users::SetUsernameRequest, + crate::api::users::SetUsernameResponse, + crate::api::users::MyUsernameResponse, + crate::api::users::PublicKeysInfo, + crate::api::users::LookupUsernameResponse, + crate::api::users::RegisterKeyRequest, + crate::api::users::UserKeyInfo, + crate::api::users::UserKeysResponse, + crate::api::nip05::WellKnownResponse, )), tags( (name = "system", description = "Service health and metadata."), diff --git a/src/api/users.rs b/src/api/users.rs new file mode 100644 index 0000000..d8d86fe --- /dev/null +++ b/src/api/users.rs @@ -0,0 +1,485 @@ +//! User identity handlers (core). +//! +//! Two concerns live here, both keyed off the wallet's own key material — never a +//! third-party platform login: +//! +//! * **Username** — a reserved `@handle` that backs NIP-05. [`set_username`] +//! (JWT) claims or updates it (atomically, via the DB UNIQUE constraint, so a +//! collision is a 409), [`get_my_username`] (JWT) reads the caller's own, and +//! [`lookup_username`] (public) resolves a handle to its user plus that user's +//! per-asset receiving public keys. +//! * **Per-asset public keys** — generic public keys a user publishes so others +//! can send to them. [`register_key`] (JWT) upserts the caller's key for one +//! asset, [`get_user_keys`] (public) lists a user's keys, and +//! [`get_user_key_for_asset`] (public) fetches one. +//! +//! Conventions enforced here (matching [`crate::api::auth`]): +//! * JWT-gated handlers resolve the caller via +//! [`crate::api::middleware::extract_user_id_from_token`]; all failure modes +//! collapse to a literal `AuthError`, never an oracle. +//! * Username/UNIQUE collisions surface as `AppError::Conflict` (409) — the DB +//! methods already map the constraint violation; reserved handles are refused +//! the same way before the DB is touched. +//! * Foreign error detail is routed to tracing by `AppError`; the client gets a +//! generic literal — SAFE `AppError` messages are literals at the call site. +//! * All request/response fields are snake_case (the wallet client expects it), +//! and every DTO derives `utoipa::ToSchema`. Assets cross the wire as lowercase +//! strings (`"btc"`, `"xmr"`, …), parsed/rendered here. +//! +//! Routes are registered RELATIVE to the `/api/v1` mount point; see [`routes`]. + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::HeaderMap, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use tracing::{info, instrument}; +use uuid::Uuid; + +use crate::api::middleware::extract_user_id_from_token; +use crate::error::AppError; +use crate::models::db::{AssetType, NewUserKey, UserKey}; +use crate::AppState; + +// ── helpers ───────────────────────────────────────────────────────────────── + +/// Usernames reserved to prevent impersonation of the project, staff, or system +/// roles. The candidate is already lowercased before this check. +const RESERVED_USERNAMES: &[&str] = &[ + "admin", + "administrator", + "root", + "support", + "help", + "smirk", + "smirkcash", + "official", + "mod", + "moderator", + "system", + "security", + "team", + "staff", + "billing", + "payment", + "payments", + "wallet", + "info", + "contact", + "abuse", + "noreply", + "api", + "www", + "satoshi", +]; + +/// Validate a reserved username (3-32 chars, `[a-z0-9_]`, no leading/trailing +/// `_`, not on the reserved list). The candidate must already be lowercased. +/// +/// Mirrors [`crate::api::auth`]'s username rules and adds the anti-impersonation +/// reserved-name guard (a 409, the same status a UNIQUE collision yields, so the +/// "taken" and "reserved" outcomes are indistinguishable to a probing client). +fn validate_username(username: &str) -> Result<(), AppError> { + if username.len() < 3 || username.len() > 32 { + return Err(AppError::ValidationError( + "Username must be 3-32 characters".into(), + )); + } + if !username + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') + { + return Err(AppError::ValidationError( + "Username must contain only lowercase letters, numbers, and underscores".into(), + )); + } + if username.starts_with('_') || username.ends_with('_') { + return Err(AppError::ValidationError( + "Username cannot start or end with underscore".into(), + )); + } + if RESERVED_USERNAMES.contains(&username) { + return Err(AppError::Conflict("That username is not available".into())); + } + Ok(()) +} + +/// Parse an asset string into [`AssetType`], or a 400. Accepts any case. +fn parse_asset(asset: &str) -> Result { + 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}"))), + } +} + +/// Render a stored [`UserKey`] into its wire DTO. +fn key_info(key: UserKey) -> UserKeyInfo { + UserKeyInfo { + asset: key.asset.to_string(), + public_key: key.public_key, + public_spend_key: key.public_spend_key, + } +} + +// ── DTOs ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct SetUsernameRequest { + /// Desired username (3-32 chars, lowercase `[a-z0-9_]`, no leading/trailing + /// underscore). Submitted values are lowercased before validation. + pub username: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct SetUsernameResponse { + /// The username as stored (lowercased). + pub username: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct MyUsernameResponse { + /// The caller's username, or `null` if they have not claimed one. + pub username: Option, +} + +/// A user's per-asset receiving public keys, by asset. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct PublicKeysInfo { + pub btc: Option, + pub ltc: Option, + pub xmr: Option, + pub wow: Option, + pub grin: Option, +} + +/// Result of resolving a username. Constant-shape: the same fields are always +/// present, so the response is not a structure oracle. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct LookupUsernameResponse { + /// Whether a user owns this username. + pub registered: bool, + /// The resolved user id (UUID string), if registered. + pub user_id: Option, + /// The canonical (lowercased) username, if registered. + pub username: Option, + /// The user's per-asset receiving public keys, if registered. + pub public_keys: Option, +} + +/// Register or update one of the caller's per-asset public keys. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct RegisterKeyRequest { + /// Asset the key is for (`btc`, `ltc`, `xmr`, `wow`, `grin`). + pub asset: String, + /// The public key (format depends on the asset). + pub public_key: String, + /// XMR/WOW only: the public spend key. + pub public_spend_key: Option, +} + +/// A single per-asset public key. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct UserKeyInfo { + /// Asset the key is for (lowercase: `btc`, `ltc`, `xmr`, `wow`, `grin`). + pub asset: String, + pub public_key: String, + /// XMR/WOW only: the public spend key. + pub public_spend_key: Option, +} + +/// A user's full set of per-asset public keys. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct UserKeysResponse { + pub keys: Vec, +} + +// ── POST /users/me/username ─────────────────────────────────────────────────── + +/// Claim or update the authenticated user's username. +/// +/// The submitted value is lowercased, validated, and checked against the +/// reserved list before the DB write. The UNIQUE constraint is the atomic claim: +/// a collision (taken or reserved) is a 409, never a 500. +#[utoipa::path( + post, + path = "/users/me/username", + request_body = SetUsernameRequest, + responses( + (status = 200, description = "Username claimed or updated", body = SetUsernameResponse), + (status = 401, description = "Missing or invalid token"), + (status = 409, description = "Username is taken or reserved") + ), + tag = "users" +)] +#[instrument(skip(state, headers, req))] +pub async fn set_username( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + + let username = req.username.to_lowercase(); + validate_username(&username)?; + + // The UNIQUE constraint is the atomic claim; a collision surfaces as 409 + // CONFLICT (not 500) via `set_username`/`update_username`. + state.db.set_username(user_id, &username).await?; + + // `user_id` is an opaque UUID (safe to log); the username itself is omitted. + info!(user_id = %user_id, username_len = username.len(), "username set"); + + Ok(Json(SetUsernameResponse { username })) +} + +// ── GET /users/me/username ──────────────────────────────────────────────────── + +/// The authenticated user's own username (or `null`). +#[utoipa::path( + get, + path = "/users/me/username", + responses( + (status = 200, description = "Caller's username", body = MyUsernameResponse), + (status = 401, description = "Missing or invalid token") + ), + tag = "users" +)] +#[instrument(skip(state, headers))] +pub async fn get_my_username( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + let user = state + .db + .get_user_by_id(user_id) + .await? + .ok_or_else(|| AppError::AuthError("Invalid or expired token".into()))?; + Ok(Json(MyUsernameResponse { + username: user.username, + })) +} + +// ── GET /users/by-username/:username ────────────────────────────────────────── + +/// Resolve a username to its user and that user's receiving public keys. +/// +/// Public: this is how a sender discovers where to send. The lookup is +/// case-insensitive (the handle is canonicalized lowercase). An unknown handle +/// returns the same constant-shape response with `registered: false`. +#[utoipa::path( + get, + path = "/users/by-username/{username}", + params(("username" = String, Path, description = "Username to resolve")), + responses((status = 200, description = "Username resolution result", body = LookupUsernameResponse)), + tag = "users" +)] +#[instrument(skip(state))] +pub async fn lookup_username( + State(state): State>, + Path(username): Path, +) -> Result, AppError> { + let username = username.to_lowercase(); + + let Some(user) = state.db.get_user_by_username(&username).await? else { + return Ok(Json(LookupUsernameResponse { + registered: false, + user_id: None, + username: None, + public_keys: None, + })); + }; + + let keys = state.db.get_user_keys(user.id).await?; + let key_for = |asset: AssetType| { + keys.iter() + .find(|k| k.asset == asset) + .map(|k| k.public_key.clone()) + }; + let public_keys = PublicKeysInfo { + btc: key_for(AssetType::Btc), + ltc: key_for(AssetType::Ltc), + xmr: key_for(AssetType::Xmr), + wow: key_for(AssetType::Wow), + grin: key_for(AssetType::Grin), + }; + + Ok(Json(LookupUsernameResponse { + registered: true, + user_id: Some(user.id.to_string()), + username: user.username, + public_keys: Some(public_keys), + })) +} + +// ── POST /keys ──────────────────────────────────────────────────────────────── + +/// Register or update one of the authenticated user's per-asset public keys. +/// +/// Idempotent: the DB upsert keys on `(user_id, asset, key_type)`, so re-posting +/// the same asset replaces the stored key material in place. +#[utoipa::path( + post, + path = "/keys", + request_body = RegisterKeyRequest, + responses( + (status = 200, description = "Key registered or updated", body = UserKeyInfo), + (status = 400, description = "Invalid asset"), + (status = 401, description = "Missing or invalid token") + ), + tag = "users" +)] +#[instrument(skip(state, headers, req))] +pub async fn register_key( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let user_id = extract_user_id_from_token(&state, &headers).await?; + let asset = parse_asset(&req.asset)?; + + let key = state + .db + .upsert_user_key(NewUserKey { + user_id, + asset, + public_key: req.public_key.clone(), + public_spend_key: req.public_spend_key.clone(), + key_type: "primary".to_string(), + }) + .await?; + + info!(user_id = %user_id, asset = %asset, "registered public key"); + Ok(Json(key_info(key))) +} + +// ── GET /users/:user_id/keys ────────────────────────────────────────────────── + +/// List a user's per-asset receiving public keys. +/// +/// Public: a sender fetches these to construct a payment to `user_id`. +#[utoipa::path( + get, + path = "/users/{user_id}/keys", + params(("user_id" = String, Path, description = "User id (UUID)")), + responses( + (status = 200, description = "The user's public keys", body = UserKeysResponse), + (status = 400, description = "Malformed user id") + ), + tag = "users" +)] +#[instrument(skip(state))] +pub async fn get_user_keys( + State(state): State>, + Path(user_id): Path, +) -> Result, AppError> { + let keys = state.db.get_user_keys(user_id).await?; + Ok(Json(UserKeysResponse { + keys: keys.into_iter().map(key_info).collect(), + })) +} + +// ── GET /users/:user_id/keys/:asset ─────────────────────────────────────────── + +/// Fetch a user's receiving public key for one asset. +#[utoipa::path( + get, + path = "/users/{user_id}/keys/{asset}", + params( + ("user_id" = String, Path, description = "User id (UUID)"), + ("asset" = String, Path, description = "Asset (btc, ltc, xmr, wow, grin)") + ), + responses( + (status = 200, description = "The user's key for this asset", body = UserKeyInfo), + (status = 400, description = "Invalid asset"), + (status = 404, description = "User has no key for this asset") + ), + tag = "users" +)] +#[instrument(skip(state))] +pub async fn get_user_key_for_asset( + State(state): State>, + Path((user_id, asset)): Path<(Uuid, String)>, +) -> Result, AppError> { + let asset = parse_asset(&asset)?; + let key = state + .db + .get_user_key(user_id, asset) + .await? + .ok_or_else(|| AppError::NotFound("User has no key for this asset".into()))?; + Ok(Json(key_info(key))) +} + +// ── router ──────────────────────────────────────────────────────────────────── + +/// User routes, RELATIVE to the `/api/v1` mount point. The application is +/// expected to `Router::new().nest("/api/v1", users::routes())`. +pub fn routes() -> Router> { + Router::new() + .route( + "/users/me/username", + post(set_username).get(get_my_username), + ) + .route("/users/by-username/:username", get(lookup_username)) + .route("/keys", post(register_key)) + .route("/users/:user_id/keys", get(get_user_keys)) + .route("/users/:user_id/keys/:asset", get(get_user_key_for_asset)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn username_rules() { + assert!(validate_username("alice").is_ok()); + assert!(validate_username("a_b_2").is_ok()); + assert!(validate_username("ab").is_err()); // too short + assert!(validate_username("_alice").is_err()); // leading underscore + assert!(validate_username("alice_").is_err()); // trailing underscore + assert!(validate_username("al ice").is_err()); // space + } + + #[test] + fn reserved_usernames_are_conflicts() { + match validate_username("admin") { + Err(AppError::Conflict(_)) => {} + other => panic!("expected Conflict for reserved name, got {other:?}"), + } + match validate_username("smirk") { + Err(AppError::Conflict(_)) => {} + other => panic!("expected Conflict for reserved name, got {other:?}"), + } + // A non-reserved, well-formed name passes. + assert!(validate_username("alice123").is_ok()); + } + + #[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()); + } + + /// Wire-shape: the key registration request accepts a snake_case asset + /// string and an optional spend key. + #[test] + fn register_key_request_wire_shape() { + let json = r#"{"asset":"xmr","public_key":"abcd","public_spend_key":"ef01"}"#; + let req: RegisterKeyRequest = serde_json::from_str(json).expect("deserializes"); + assert_eq!(req.asset, "xmr"); + assert_eq!(req.public_spend_key.as_deref(), Some("ef01")); + + let minimal = r#"{"asset":"btc","public_key":"deadbeef"}"#; + let req: RegisterKeyRequest = serde_json::from_str(minimal).expect("deserializes"); + assert!(req.public_spend_key.is_none()); + } +} diff --git a/src/api/website.rs b/src/api/website.rs new file mode 100644 index 0000000..f469d2d --- /dev/null +++ b/src/api/website.rs @@ -0,0 +1,400 @@ +//! 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 (by the proven identity key's `pubkey_hash`); an unknown key is a +//! 401 directing the user to register with the extension first. It never +//! mints an identity from a bare signature. +//! +//! Identity in this backend is the wallet's BTC key (`pubkey_hash`); the signed +//! asset's public key is hashed and resolved via +//! [`Database::get_user_by_pubkey_hash`]. The verifier still branches by asset +//! so the cross-asset signing contract holds, but only a key that hashes to a +//! stored identity resolves a user. +//! +//! 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 ipnetwork::IpNetwork; +use serde::{Deserialize, Serialize}; +use tracing::{info, instrument}; + +use crate::api::auth::{AuthResponse, UserInfo}; +use crate::api::middleware::client_ip; +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::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 { + 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>, + Json(req): Json, +) -> Result, 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>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result, 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 by the proven key's pubkey hash. We never create + // a user here — website sign-in is for wallets already registered via the + // extension. An unknown key is a 401 with a literal, registration-pointing + // message (the same outcome as a non-matching signature: no enumeration). + let pubkey_hash = hash_public_key(&req.signature.public_key); + let user = state + .db + .get_user_by_pubkey_hash(&pubkey_hash) + .await? + .ok_or_else(|| { + info!("website_verify: no user for proven key"); + AppError::AuthError( + "No wallet is registered for this key. Register with the Smirk extension first." + .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.config.secrets.refresh_token_pepper, + ); + let expires_at = Utc::now() + state.sessions.refresh_token_expiry(); + state + .db + .create_session(NewSession { + user_id: user.id, + refresh_token_hash, + platform: Platform::Web.to_string(), + device_info: Some("Web Browser".to_string()), + ip_address: Some(IpNetwork::from(ip)), + 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, + })) +} + +// ── 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::()` so [`client_ip`] +/// sees the real TCP peer. +pub fn routes() -> Router> { + 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"); + } +} diff --git a/src/main.rs b/src/main.rs index 5d41d8f..b13b49c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,9 +43,28 @@ async fn main() -> Result<(), Box> { web_challenges: Arc::default(), }); + // Periodic GC of expired website-auth challenges. Bounds the single-node + // in-memory store so the unauthenticated challenge endpoint cannot grow it + // without limit. (The fleet path moves this state to a shared store.) + { + let challenges = state.web_challenges.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(120)); + loop { + tick.tick().await; + challenges.write().await.retain(|_, c| !c.is_expired()); + } + }); + } + + let api_v1 = smirk_backend_core::api::auth::routes() + .merge(smirk_backend_core::api::website::routes()) + .merge(smirk_backend_core::api::users::routes()); + let app = Router::new() .route("/health", get(health::health)) - .nest("/api/v1", smirk_backend_core::api::auth::routes()) + .merge(smirk_backend_core::api::nip05::routes()) + .nest("/api/v1", api_v1) .with_state(state); let listener = tokio::net::TcpListener::bind(&addr).await?;