feat: website auth, username + key registration, NIP-05 directory

- api/website.rs: sign-in-with-wallet (dapp connect). challenge bound to origin
  + short TTL; verify is single-use (remove-before-verify, race-safe), expiry-
  checked, signature via Result<(),AppError> verifiers (no oracle), resolves an
  EXISTING user by the proven key's pubkey_hash (never mints identity), mints a
  Platform::Web session.
- api/users.rs: username set/get + by-username lookup (JWT-gated, 409 on taken,
  constant-shape lookup); per-asset key registration bound to the JWT user
  (no spoofable user_id) + public key-directory reads.
- api/nip05.rs: /.well-known/nostr.json directory (username -> nostr_pubkey).
- Wired into api/mod.rs; website+users nested at /api/v1, nip05 at root; all
  paths/schemas registered (openapi.json now 17 endpoints).
- main.rs: periodic GC of expired web challenges (bounds the in-memory store).

Adversarially reviewed (register_key authz + website identity-binding both
hold; no critical/high). 69 tests green. Deferred LOW: per-IP rate-limit on the
website routes lands with the router-assembly unit.
This commit is contained in:
jwinterm
2026-06-22 18:13:47 -04:00
parent be23042c0c
commit fb3c547d93
7 changed files with 1711 additions and 1 deletions
+604
View File
@@ -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"
}
}
}
}
}
},
+3
View File
@@ -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;
+177
View File
@@ -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: <https://github.com/nostr-protocol/nips/blob/master/05.md>
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<String>,
}
/// 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<String, String>,
/// `{ nostr_pubkey_hex: [relay_url, ...] }`. Empty when nothing resolved.
pub relays: HashMap<String, Vec<String>>,
}
// ── 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<String>, 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<Arc<AppState>>,
Query(q): Query<WellKnownQuery>,
) -> Result<Json<WellKnownResponse>, 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<Arc<AppState>> {
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");
}
}
+22
View File
@@ -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."),
+485
View File
@@ -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<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}"))),
}
}
/// 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<String>,
}
/// A user's per-asset receiving public keys, by asset.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct PublicKeysInfo {
pub btc: Option<String>,
pub ltc: Option<String>,
pub xmr: Option<String>,
pub wow: Option<String>,
pub grin: Option<String>,
}
/// 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<String>,
/// The canonical (lowercased) username, if registered.
pub username: Option<String>,
/// The user's per-asset receiving public keys, if registered.
pub public_keys: Option<PublicKeysInfo>,
}
/// 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<String>,
}
/// 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<String>,
}
/// A user's full set of per-asset public keys.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UserKeysResponse {
pub keys: Vec<UserKeyInfo>,
}
// ── 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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<SetUsernameRequest>,
) -> Result<Json<SetUsernameResponse>, 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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<MyUsernameResponse>, 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<Arc<AppState>>,
Path(username): Path<String>,
) -> Result<Json<LookupUsernameResponse>, 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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<RegisterKeyRequest>,
) -> Result<Json<UserKeyInfo>, 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<Arc<AppState>>,
Path(user_id): Path<Uuid>,
) -> Result<Json<UserKeysResponse>, 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<Arc<AppState>>,
Path((user_id, asset)): Path<(Uuid, String)>,
) -> Result<Json<UserKeyInfo>, 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<Arc<AppState>> {
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());
}
}
+400
View File
@@ -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<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 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::<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");
}
}
+20 -1
View File
@@ -43,9 +43,28 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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?;