mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
Adversarial review of the auth/registration surface (findings tracked privately). Backend half: - F2: npub-native register no longer binds an npub onto an existing wallet row on a seed_fingerprint match alone (unauthenticated account takeover). get_or_create_user_by_nostr_pubkey fails closed (409); linking an npub goes through the authenticated /auth/nostr/link flow. Integration test rewritten to assert the refuse-and-409 contract. - F3: `all`-mode gate verifies payment is settled (non-consuming) BEFORE consuming the single-use invite, so a still-confirming payment can't permanently burn the invite. verify_payment_settled split out. - F4: username availability is pre-checked before any gate token is consumed; collisions return 409, not 500 — a taken username no longer burns a paid invoice / invite. - F6: nostr_register now persists the submitted chain keys, so receive-by-username and restore self-verification work for npub wallets. - F10: the pay-to-register not-yet-settled response carries a stable PAYMENT_PENDING code so polling clients don't parse a free-text string. Validated against Postgres: full suite 283 passed / 0 failed.
1937 lines
78 KiB
Rust
1937 lines
78 KiB
Rust
//! Authentication handlers (core).
|
|
//!
|
|
//! Identity is the wallet's own key material — never a third-party platform
|
|
//! login. Three entry points mint a session, all converging on the same
|
|
//! `(access, refresh)` JWT pair backed by a revocable DB session row:
|
|
//!
|
|
//! * [`extension_register`] — proves control of the wallet's BTC key (a signed
|
|
//! timestamp), optionally gated by proof-of-work, and get-or-creates the user
|
|
//! keyed by `pubkey_hash`. A derivation-scheme rotation (a known
|
|
//! `seed_fingerprint` at a new `pubkey_hash`) re-points an EXISTING user row
|
|
//! ONLY when the request additionally proves control of the BTC key already on
|
|
//! file for that user — never on a bare (unauthenticated) fingerprint.
|
|
//! * [`nostr_login`] — NIP-98 (login grade) over the `Authorization` header.
|
|
//! Resolves an already-linked npub to its user; it NEVER creates a user.
|
|
//! * [`refresh_token`] — rotates a valid, still-active refresh token.
|
|
//!
|
|
//! Plus [`check_restore`] (rate-limited per fingerprint AND per IP,
|
|
//! enumeration-safe), [`pow_challenge`], [`logout`], [`get_me`], and
|
|
//! [`nostr_link`] (a state-change requiring both a JWT and a signed-action
|
|
//! proof).
|
|
//!
|
|
//! Conventions enforced here:
|
|
//! * The shared [`crate::core::session::SessionManager`] is read from
|
|
//! `state.sessions`; refresh tokens are stored peppered with
|
|
//! `config.secrets.refresh_token_pepper`.
|
|
//! * NIP-98 binds the canonical `config.identity.public_api_url`, never the
|
|
//! request `Host`. If that is unset, the Nostr endpoints fail closed.
|
|
//! * Foreign error detail (sqlx, k256, jsonwebtoken) is routed to tracing; the
|
|
//! client gets a generic literal — `AppError` SAFE variants are literals.
|
|
//! * All request/response fields are snake_case (the wallet client expects it).
|
|
//!
|
|
//! Routes are registered RELATIVE to `/api/v1` (e.g. `/auth/extension`); see
|
|
//! [`routes`]. The app is expected to nest this router under `/api/v1` and serve
|
|
//! it with `into_make_service_with_connect_info::<SocketAddr>()` so the
|
|
//! [`client_ip`] extractor has the real TCP peer.
|
|
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
extract::{ConnectInfo, State},
|
|
http::HeaderMap,
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use chrono::Utc;
|
|
use ipnetwork::IpNetwork;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::{info, instrument, warn};
|
|
use uuid::Uuid;
|
|
|
|
use crate::api::middleware::{client_ip, extract_user_id_from_token};
|
|
use crate::core::crypto::nip98::{
|
|
descriptor_sha256, request_descriptor, verify_nip98, verify_signed_action,
|
|
};
|
|
use crate::core::crypto::signatures::verify_bitcoin_signature;
|
|
use crate::core::session::{hash_refresh_token, Platform};
|
|
use crate::error::AppError;
|
|
use crate::models::db::{AssetType, NewSession, NewUserKey};
|
|
use crate::AppState;
|
|
|
|
// ── shared constants ────────────────────────────────────────────────────────
|
|
|
|
/// Replay window for a NIP-98 LOGIN token (seconds). A captured login token is a
|
|
/// bearer credential within this window, so keep it tight — matched to the
|
|
/// state-change grade below. Still ample for real client clock skew.
|
|
const NIP98_LOGIN_MAX_AGE_SECS: i64 = 30;
|
|
|
|
/// Replay window for a NIP-98 STATE-CHANGE (signed action) token (seconds).
|
|
/// Deliberately tighter than login.
|
|
const NIP98_ACTION_MAX_AGE_SECS: i64 = 30;
|
|
|
|
/// TTL for a Nostr-link nonce (seconds): long enough for the wallet to sign and
|
|
/// POST, short enough to bound an unused nonce's lifetime.
|
|
const NOSTR_LINK_NONCE_TTL_SECS: i64 = 300;
|
|
|
|
/// Max accepted drift for the extension's signed-timestamp proof (seconds).
|
|
const SIGNED_TS_MAX_DRIFT_SECS: i64 = 300;
|
|
|
|
/// Failed restore attempts (per fingerprint, last hour) that trip the gate.
|
|
const RESTORE_FAIL_THRESHOLD: i64 = 3;
|
|
|
|
/// All restore attempts (per IP, last hour) that trip the per-IP governor. This
|
|
/// bounds distinct-fingerprint scanning — the per-fingerprint counter alone
|
|
/// never trips when each candidate fingerprint is probed only once.
|
|
const RESTORE_IP_THRESHOLD: i64 = 30;
|
|
|
|
// ── shared DTOs ─────────────────────────────────────────────────────────────
|
|
|
|
/// Successful session response: a JWT pair plus minimal user info.
|
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
pub struct AuthResponse {
|
|
pub access_token: String,
|
|
pub refresh_token: String,
|
|
/// Access-token lifetime in seconds.
|
|
pub expires_in: i64,
|
|
pub user: UserInfo,
|
|
/// `true` when this request created the user (extension registration only).
|
|
pub is_new: bool,
|
|
}
|
|
|
|
/// Minimal, non-enumerable user info returned to a signed-in client.
|
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
pub struct UserInfo {
|
|
pub id: String,
|
|
pub username: Option<String>,
|
|
/// Linked Nostr pubkey (x-only hex), if any.
|
|
pub nostr_pubkey: Option<String>,
|
|
}
|
|
|
|
/// A per-asset public key submitted by the wallet.
|
|
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
|
|
pub struct AssetPublicKey {
|
|
pub asset: String,
|
|
pub public_key: String,
|
|
/// XMR/WOW only: public spend key.
|
|
pub public_spend_key: Option<String>,
|
|
}
|
|
|
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/// Parse an asset string into [`AssetType`], or a 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}"))),
|
|
}
|
|
}
|
|
|
|
/// Validate a reserved username (3-32 chars, `[a-z0-9_]`, no leading/trailing `_`).
|
|
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(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether the PoW gate applies to this registration. Consistent with the
|
|
/// feature flag: a disabled feature (`config.pow.enabled == false`) NEVER gates,
|
|
/// regardless of `required` / `required_for_pubkeys`. This is the handler-side
|
|
/// guard against the `POW_REQUIRED=true, FEATURE_POW=false` misconfiguration in
|
|
/// which `verify_payload` would otherwise run against an empty HMAC key (config
|
|
/// validation only requires `ALTCHA_HMAC_KEY` when the feature is enabled). When
|
|
/// disabled we never call `verify_payload`, so the empty key is never used.
|
|
fn pow_applies(state: &AppState, pubkey_hash_lc: &str) -> bool {
|
|
state.config.pow.enabled && crate::core::pow::required_for(&state.config.pow, pubkey_hash_lc)
|
|
}
|
|
|
|
/// The canonical absolute URL a NIP-98 token must bind for `path` (the value of
|
|
/// the event's `u` tag). Built from `config.identity.public_api_url`, never the
|
|
/// request Host. Fail closed when unset (Nostr identity is disabled).
|
|
fn nip98_url(state: &AppState, path: &str) -> Result<String, AppError> {
|
|
let base = state
|
|
.config
|
|
.identity
|
|
.public_api_url
|
|
.as_deref()
|
|
.ok_or_else(|| {
|
|
warn!("Nostr endpoint reached but PUBLIC_API_URL is unset; refusing");
|
|
AppError::AuthError("Nostr authentication is not enabled".into())
|
|
})?;
|
|
Ok(format!("{}{}", base.trim_end_matches('/'), path))
|
|
}
|
|
|
|
/// 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(),
|
|
}
|
|
}
|
|
|
|
/// Mint a token pair, persist a session row (peppered refresh hash), and return
|
|
/// the pair. Centralizes the refresh-token peppering + session-row shape so it
|
|
/// cannot drift between the three login paths and the refresh rotation.
|
|
async fn issue_session(
|
|
state: &AppState,
|
|
user_id: Uuid,
|
|
platform: Platform,
|
|
device_info: &str,
|
|
ip: Option<IpNetwork>,
|
|
) -> Result<crate::core::session::TokenPair, AppError> {
|
|
let session_id = Uuid::new_v4();
|
|
let pair = state
|
|
.sessions
|
|
.create_token_pair(user_id, platform, 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,
|
|
refresh_token_hash,
|
|
platform: platform.to_string(),
|
|
device_info: Some(device_info.to_string()),
|
|
ip_address: ip,
|
|
expires_at,
|
|
})
|
|
.await?;
|
|
|
|
Ok(pair)
|
|
}
|
|
|
|
/// Upsert every submitted asset key for `user_id`. Idempotent (the DB upsert
|
|
/// keys on `(user_id, asset, key_type)`), so it is safe to call on first
|
|
/// creation, on re-registration of the same pubkey, and on a proven rotation.
|
|
async fn upsert_all_keys(
|
|
state: &AppState,
|
|
user_id: Uuid,
|
|
keys: &[AssetPublicKey],
|
|
) -> Result<(), AppError> {
|
|
for key in keys {
|
|
let asset = parse_asset(&key.asset)?;
|
|
state
|
|
.db
|
|
.upsert_user_key(NewUserKey {
|
|
user_id,
|
|
asset,
|
|
public_key: key.public_key.clone(),
|
|
public_spend_key: key.public_spend_key.clone(),
|
|
key_type: "primary".to_string(),
|
|
})
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ── POST /auth/pow-challenge ─────────────────────────────────────────────────
|
|
|
|
/// Issue a fresh proof-of-work challenge for the wallet to solve before calling
|
|
/// `/auth/extension`. The solved payload is sent back as the request's
|
|
/// `altcha_solution`.
|
|
///
|
|
/// Stateless: the challenge embeds an HMAC signature over its own fields plus an
|
|
/// expiry, so no issued-challenge store is needed. See [`crate::core::pow`].
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/pow-challenge",
|
|
responses((status = 200, description = "Proof-of-work challenge for wallet registration")),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state))]
|
|
pub async fn pow_challenge(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Result<Json<altcha::Challenge>, AppError> {
|
|
let challenge = crate::core::pow::issue_challenge(&state.config.pow)?;
|
|
Ok(Json(challenge))
|
|
}
|
|
|
|
// ── POST /auth/extension ─────────────────────────────────────────────────────
|
|
|
|
/// Register a new extension wallet or re-authenticate an existing one.
|
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
pub struct ExtensionRegisterRequest {
|
|
/// Public keys for each supported asset. A `btc` key is required (it is the
|
|
/// identity).
|
|
pub keys: Vec<AssetPublicKey>,
|
|
/// Optional reserved username.
|
|
pub username: Option<String>,
|
|
/// Wallet creation time (unix seconds), to bound chain scans.
|
|
pub wallet_birthday: Option<i64>,
|
|
/// Seed fingerprint `hex(SHA256(SHA256(seed))[..])`. Used for restore and to
|
|
/// LOCATE a candidate user row for the derivation-rotation path. By itself it
|
|
/// is NOT authority: a rotation also requires `rotation_signature` below.
|
|
pub seed_fingerprint: Option<String>,
|
|
pub xmr_start_height: Option<i64>,
|
|
pub wow_start_height: Option<i64>,
|
|
/// Unix seconds that were signed to prove BTC key ownership.
|
|
pub signed_timestamp: i64,
|
|
/// BIP-137 base64 signature of `smirk-auth-{signed_timestamp}` under the
|
|
/// SUBMITTED (new) BTC key. Proves control of the key in `keys`.
|
|
pub signature: String,
|
|
/// Derivation-rotation proof: BIP-137 base64 signature of the SAME
|
|
/// `smirk-auth-{signed_timestamp}` message under the BTC key ALREADY ON FILE
|
|
/// for the user identified by `seed_fingerprint`. Required to re-point an
|
|
/// existing user row; without it (or if it does not verify against the stored
|
|
/// key) a fingerprint match is treated as a brand-new identity and the
|
|
/// existing row is never touched. See [`extension_register`].
|
|
#[serde(default)]
|
|
pub rotation_signature: Option<String>,
|
|
/// Optional proof-of-work solution. Required when the PoW gate applies to
|
|
/// this pubkey (see [`pow_applies`]); otherwise ignored.
|
|
#[serde(default)]
|
|
#[schema(value_type = Object)]
|
|
pub altcha_solution: Option<altcha::Payload>,
|
|
/// Operator-minted invite code. Required when the instance enables the invite
|
|
/// registration gate (see `/capabilities` → `registration.invite_required`);
|
|
/// ignored otherwise and for returning wallets.
|
|
#[serde(default)]
|
|
pub invite_code: Option<String>,
|
|
/// Settled payment-invoice id from `/auth/payment-invoice`. Required when the
|
|
/// instance enables the pay-to-register gate (see `/capabilities` →
|
|
/// `registration.payment_required`); ignored otherwise and for returning
|
|
/// wallets. Its settlement is verified against the processor and the invoice
|
|
/// atomically consumed (single-use) before the wallet is granted.
|
|
#[serde(default)]
|
|
pub payment_invoice_id: Option<String>,
|
|
}
|
|
|
|
/// Register a new extension wallet or authenticate an existing one.
|
|
///
|
|
/// The BTC pubkey hash is the unique identity. A signed timestamp proves control
|
|
/// of the BTC private key (defeats registration with a stolen public key). When
|
|
/// the PoW gate applies and the wallet is new, a valid `altcha_solution` is
|
|
/// required; a returning user (a known `pubkey_hash`) bypasses PoW.
|
|
///
|
|
/// ## Derivation rotation is authenticated (account-takeover defense)
|
|
///
|
|
/// A known `seed_fingerprint` at a NEW `pubkey_hash` (the wallet changed its
|
|
/// derivation scheme) may re-point the existing user row — but ONLY when the
|
|
/// request also carries a `rotation_signature` that verifies against the BTC key
|
|
/// already on file for that user (proving control of the seed-derived key, not
|
|
/// merely knowledge of the fingerprint, which `check_restore` discloses and is
|
|
/// not secret). A bare fingerprint match WITHOUT a valid rotation proof is
|
|
/// treated as a brand-new identity: a fresh user row is created on the new
|
|
/// `pubkey_hash` and the matched victim row is never modified. The rotation path
|
|
/// is gated by PoW exactly like any other new-pubkey registration.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/extension",
|
|
request_body = ExtensionRegisterRequest,
|
|
responses((status = 200, description = "Wallet registered or authenticated", body = AuthResponse)),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state, headers, req, peer))]
|
|
pub async fn extension_register(
|
|
State(state): State<Arc<AppState>>,
|
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ExtensionRegisterRequest>,
|
|
) -> Result<Json<AuthResponse>, AppError> {
|
|
let ip = client_ip(&state, &headers, peer);
|
|
|
|
// The BTC key is the identity. Resolve it (and its hash) first: needed for
|
|
// the PoW gate, the returning-user check, and the signature verification.
|
|
let btc_key = req
|
|
.keys
|
|
.iter()
|
|
.find(|k| k.asset.eq_ignore_ascii_case("btc"))
|
|
.ok_or_else(|| AppError::ValidationError("BTC public key is required".into()))?;
|
|
let pubkey_hash = hash_public_key(&btc_key.public_key);
|
|
let pubkey_hash_lc = pubkey_hash.to_lowercase();
|
|
|
|
if let Some(ref username) = req.username {
|
|
validate_username(username)?;
|
|
}
|
|
|
|
// Prove control of the SUBMITTED BTC private key: a fresh, signed timestamp.
|
|
// This is required on every path; it proves the caller controls the key in
|
|
// `keys`, but NOT (on its own) any key already on file for another user.
|
|
let now = Utc::now().timestamp();
|
|
if (now - req.signed_timestamp).abs() > SIGNED_TS_MAX_DRIFT_SECS {
|
|
return Err(AppError::ValidationError(
|
|
"Signed timestamp expired or too far in the future".into(),
|
|
));
|
|
}
|
|
let message = format!("smirk-auth-{}", req.signed_timestamp);
|
|
// `verify_bitcoin_signature` returns Ok(()) ONLY on a valid signature; a
|
|
// bad signature is an AuthError, a malformed one a ValidationError — both
|
|
// literal-messaged, so this is not an oracle.
|
|
verify_bitcoin_signature(&message, &req.signature, &btc_key.public_key)?;
|
|
|
|
// Is the exact pubkey_hash already known? (Plain returning user.)
|
|
let returning_by_pubkey = state
|
|
.db
|
|
.get_user_by_pubkey_hash(&pubkey_hash)
|
|
.await?
|
|
.is_some();
|
|
|
|
let wallet_birthday = req
|
|
.wallet_birthday
|
|
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0));
|
|
|
|
// ── Authenticated derivation-rotation path ──────────────────────────────
|
|
//
|
|
// A known fingerprint at a NEW pubkey_hash. We re-point the existing user row
|
|
// ONLY if the request proves control of the BTC key ALREADY ON FILE for that
|
|
// user (the seed can re-derive it). Otherwise we DO NOT touch that row.
|
|
if !returning_by_pubkey {
|
|
if let Some(ref fp) = req.seed_fingerprint {
|
|
if let Some(target) = state.db.get_user_by_seed_fingerprint(fp).await? {
|
|
// Fetch the BTC key on file for the matched user. Absence means
|
|
// we cannot authenticate a rotation -> fall through to new-identity.
|
|
let stored_btc = state.db.get_user_key(target.id, AssetType::Btc).await?;
|
|
|
|
let rotation_proven = match (&req.rotation_signature, &stored_btc) {
|
|
(Some(sig), Some(stored)) => {
|
|
// Control of the on-file (seed-derived) key proves seed
|
|
// ownership. A bad signature is rejected (not an oracle:
|
|
// we simply decline to rotate and create a new identity).
|
|
verify_bitcoin_signature(&message, sig, &stored.public_key).is_ok()
|
|
}
|
|
_ => false,
|
|
};
|
|
|
|
if rotation_proven {
|
|
// PoW still applies to the new pubkey, exactly like any other
|
|
// new-pubkey registration. The invite and pay-to-register gates
|
|
// are INTENTIONALLY skipped: this branch only re-points an
|
|
// already-registered (already-gated) user's row to a new key —
|
|
// a move, not a mint (`is_new=false`, no new identity) — and is
|
|
// reachable only by proving control of that user's on-file key.
|
|
// Do NOT add a create / get-or-create here without also running
|
|
// enforce_invite + enforce_payment, or it becomes a gate bypass.
|
|
enforce_pow(&state, &pubkey_hash_lc, false, req.altcha_solution.as_ref())?;
|
|
|
|
info!(user_id = %target.id, "auto key-rotation: fingerprint match + rotation proof verified");
|
|
state.db.update_pubkey_hash(target.id, &pubkey_hash).await?;
|
|
upsert_all_keys(&state, target.id, &req.keys).await?;
|
|
|
|
let pair = issue_session(
|
|
&state,
|
|
target.id,
|
|
Platform::Extension,
|
|
"Browser Extension",
|
|
Some(IpNetwork::from(ip)),
|
|
)
|
|
.await?;
|
|
let _ = state
|
|
.db
|
|
.record_login_event(
|
|
Some(target.id),
|
|
"btc",
|
|
Platform::Extension.as_str(),
|
|
None,
|
|
Some(&ip.to_string()),
|
|
)
|
|
.await;
|
|
return Ok(Json(AuthResponse {
|
|
access_token: pair.access_token,
|
|
refresh_token: pair.refresh_token,
|
|
expires_in: pair.expires_in,
|
|
user: user_info(&target),
|
|
is_new: false,
|
|
}));
|
|
}
|
|
|
|
// Fingerprint matched but rotation was NOT proven. Do not touch
|
|
// the existing row; fall through and create a brand-new identity
|
|
// on the submitted pubkey_hash. We must NOT carry the matched
|
|
// user's seed_fingerprint onto the new row (the UNIQUE constraint
|
|
// would collide and could leak existence), so drop it below.
|
|
warn!(
|
|
"extension_register: fingerprint match WITHOUT valid rotation proof; \
|
|
treating as a new identity (existing row untouched)"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Plain get-or-create on the submitted pubkey_hash ────────────────────
|
|
//
|
|
// Either a known pubkey (returning) or a genuinely new identity. PoW applies
|
|
// to new pubkeys only.
|
|
enforce_pow(
|
|
&state,
|
|
&pubkey_hash_lc,
|
|
returning_by_pubkey,
|
|
req.altcha_solution.as_ref(),
|
|
)?;
|
|
// Reject a taken username BEFORE consuming any single-use gate token.
|
|
ensure_username_available(&state, returning_by_pubkey, req.username.as_deref()).await?;
|
|
// Invite + payment gates, composed per the operator's gate mode (`all` =
|
|
// every enabled gate; `any` = one-of). PoW above is orthogonal to both.
|
|
enforce_method_gates(
|
|
&state,
|
|
returning_by_pubkey,
|
|
&pubkey_hash,
|
|
req.invite_code.as_deref(),
|
|
req.payment_invoice_id.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
// If we reached here after an unproven fingerprint match, do NOT attach that
|
|
// fingerprint to the new row — it belongs to another user, and the UNIQUE
|
|
// constraint would either collide or hijack the lookup. Only attach the
|
|
// fingerprint when it is genuinely unknown, or when this is the same pubkey
|
|
// (a plain returning user, where get_or_create only backfills NULLs anyway).
|
|
let fingerprint_for_row = match &req.seed_fingerprint {
|
|
Some(fp) => {
|
|
let belongs_to_other =
|
|
!returning_by_pubkey && state.db.get_user_by_seed_fingerprint(fp).await?.is_some();
|
|
if belongs_to_other {
|
|
None
|
|
} else {
|
|
Some(fp.clone())
|
|
}
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
let user = state
|
|
.db
|
|
.get_or_create_user_by_pubkey_hash(
|
|
&pubkey_hash,
|
|
req.username.clone(),
|
|
wallet_birthday,
|
|
fingerprint_for_row,
|
|
req.xmr_start_height,
|
|
req.wow_start_height,
|
|
)
|
|
.await?;
|
|
|
|
// Derive is_new from the resolved row, not the pre-read: a concurrent
|
|
// first-registration race is settled in the DB. `created_at == updated_at`
|
|
// on a freshly-inserted row and diverges on the COALESCE backfill update or
|
|
// any later write, so it is a reliable "created just now" marker.
|
|
let is_new = user.created_at == user.updated_at && !returning_by_pubkey;
|
|
|
|
// Upsert keys unconditionally: idempotent, and this honors asset-key rotation
|
|
// when a returning user re-registers with an updated key.
|
|
upsert_all_keys(&state, user.id, &req.keys).await?;
|
|
|
|
if is_new {
|
|
info!(user_id = %user.id, num_keys = req.keys.len(), "registered new extension user");
|
|
} else {
|
|
info!(user_id = %user.id, "existing extension user authenticated");
|
|
}
|
|
|
|
let pair = issue_session(
|
|
&state,
|
|
user.id,
|
|
Platform::Extension,
|
|
"Browser Extension",
|
|
Some(IpNetwork::from(ip)),
|
|
)
|
|
.await?;
|
|
let _ = state
|
|
.db
|
|
.record_login_event(
|
|
Some(user.id),
|
|
"btc",
|
|
Platform::Extension.as_str(),
|
|
None,
|
|
Some(&ip.to_string()),
|
|
)
|
|
.await;
|
|
|
|
Ok(Json(AuthResponse {
|
|
access_token: pair.access_token,
|
|
refresh_token: pair.refresh_token,
|
|
expires_in: pair.expires_in,
|
|
user: user_info(&user),
|
|
is_new,
|
|
}))
|
|
}
|
|
|
|
/// Enforce the PoW gate for a registration.
|
|
///
|
|
/// * `returning` users bypass PoW.
|
|
/// * For a new pubkey, when [`pow_applies`] a valid solution is REQUIRED.
|
|
/// * When PoW does not apply but a solution is supplied, it is still verified so
|
|
/// a malformed solution surfaces a clear error (but absence is not rejected).
|
|
///
|
|
/// `pow_applies` already fail-closes the `FEATURE_POW=false` case, so
|
|
/// `verify_payload` (and thus the HMAC key) is never exercised while the feature
|
|
/// is disabled.
|
|
fn enforce_pow(
|
|
state: &AppState,
|
|
pubkey_hash_lc: &str,
|
|
returning: bool,
|
|
solution: Option<&altcha::Payload>,
|
|
) -> Result<(), AppError> {
|
|
if returning {
|
|
info!(pow = "bypass_returning", "returning user, PoW not required");
|
|
return Ok(());
|
|
}
|
|
if pow_applies(state, pubkey_hash_lc) {
|
|
let solution = solution.ok_or_else(|| {
|
|
warn!(
|
|
pow = "missing",
|
|
"PoW required for new wallet but no solution"
|
|
);
|
|
AppError::ValidationError(
|
|
"Proof-of-work solution is required to create a new wallet. \
|
|
Please upgrade to a newer Smirk client."
|
|
.into(),
|
|
)
|
|
})?;
|
|
crate::core::pow::verify_payload(&state.config.pow, solution)?;
|
|
info!(pow = "ok", "PoW solution accepted (new user)");
|
|
} else if let Some(solution) = solution {
|
|
// Supplied but not required: verify anyway (clear error on malformed),
|
|
// but only if the feature is enabled so we never touch an empty key.
|
|
if state.config.pow.enabled {
|
|
crate::core::pow::verify_payload(&state.config.pow, solution)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Compose the invite + payment gates per the operator's gate mode. PoW is
|
|
/// enforced separately (orthogonal) and always applies on top. Returning wallets
|
|
/// and self-hosting (both gates off) pass trivially.
|
|
///
|
|
/// - `All` (default): every ENABLED gate must pass, each consuming its own token
|
|
/// — the historical conjunctive behavior (invite AND payment when both on).
|
|
/// - `Any`: the enabled gates are ALTERNATIVES. With a single gate on it is just
|
|
/// that gate. With BOTH on, the client presents exactly ONE method's credential
|
|
/// and only that gate runs (and only it consumes its single-use token).
|
|
/// Presenting BOTH is rejected outright — a one-of choice must never burn two
|
|
/// tokens (an invite AND a paid invoice) in a single request.
|
|
///
|
|
/// Dispatching to a single gate's own `enforce_*` preserves its exact errors,
|
|
/// including the payment "not yet confirmed" literal the client polls on.
|
|
async fn enforce_method_gates(
|
|
state: &AppState,
|
|
returning: bool,
|
|
pubkey_hash: &str,
|
|
invite_code: Option<&str>,
|
|
payment_invoice_id: Option<&str>,
|
|
) -> Result<(), AppError> {
|
|
if returning {
|
|
return Ok(());
|
|
}
|
|
let has_invite = invite_code.map(str::trim).is_some_and(|c| !c.is_empty());
|
|
let has_payment = payment_invoice_id
|
|
.map(str::trim)
|
|
.is_some_and(|s| !s.is_empty());
|
|
|
|
match plan_gates(
|
|
state.config.registration.gate_mode,
|
|
state.config.registration.require_invite,
|
|
state.config.registration.payment.require_payment,
|
|
has_invite,
|
|
has_payment,
|
|
) {
|
|
GatePlan::Open => Ok(()),
|
|
// Conjunction: both enabled gates must pass (each self-disables when its
|
|
// own flag is off). Verify the payment gate is redeemable (settled,
|
|
// NON-consuming) BEFORE consuming the single-use invite, so a payment that
|
|
// is still confirming — the common poll state — can never burn the invite.
|
|
// Residual: a concurrent double-spend of the invoice between this check and
|
|
// the consume below could still burn the invite; that window is far
|
|
// narrower than the "still confirming" case. Full atomicity would require
|
|
// consuming both tokens + the user INSERT in one shared DB transaction.
|
|
GatePlan::All => {
|
|
verify_payment_settled(state, returning, pubkey_hash, payment_invoice_id).await?;
|
|
enforce_invite(state, returning, invite_code).await?;
|
|
enforce_payment(state, returning, pubkey_hash, payment_invoice_id).await?;
|
|
Ok(())
|
|
}
|
|
// Exactly ONE gate runs (and only it consumes its single-use token),
|
|
// preserving that gate's own error literals.
|
|
GatePlan::Invite => enforce_invite(state, returning, invite_code).await,
|
|
GatePlan::Payment => {
|
|
enforce_payment(state, returning, pubkey_hash, payment_invoice_id).await
|
|
}
|
|
GatePlan::RejectBothPresented => Err(AppError::ValidationError(
|
|
"Present exactly one registration method, not both.".into(),
|
|
)),
|
|
GatePlan::RequireOne => Err(AppError::ValidationError(
|
|
"Registration on this instance requires an invite code or a settled payment. \
|
|
Choose one and retry."
|
|
.into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// What to do about the invite + payment gates for a NEW wallet. The pure
|
|
/// decision (no db / async), extracted so the fund-critical one-of dispatch is
|
|
/// unit-testable. PoW is handled separately (orthogonal).
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum GatePlan {
|
|
/// No enabled gates — accept (PoW aside).
|
|
Open,
|
|
/// Conjunction: every enabled gate must pass (`all` mode).
|
|
All,
|
|
/// Run only the invite gate.
|
|
Invite,
|
|
/// Run only the payment gate.
|
|
Payment,
|
|
/// `any` mode, both gates enabled, and the client presented BOTH credentials
|
|
/// — reject rather than risk burning two single-use tokens.
|
|
RejectBothPresented,
|
|
/// `any` mode, both gates enabled, but no usable credential presented.
|
|
RequireOne,
|
|
}
|
|
|
|
fn plan_gates(
|
|
mode: crate::config::GateMode,
|
|
require_invite: bool,
|
|
require_payment: bool,
|
|
has_invite: bool,
|
|
has_payment: bool,
|
|
) -> GatePlan {
|
|
if !require_invite && !require_payment {
|
|
return GatePlan::Open;
|
|
}
|
|
match mode {
|
|
crate::config::GateMode::All => GatePlan::All,
|
|
crate::config::GateMode::Any => {
|
|
// A single enabled gate under `any` is just that gate.
|
|
if require_invite && !require_payment {
|
|
return GatePlan::Invite;
|
|
}
|
|
if require_payment && !require_invite {
|
|
return GatePlan::Payment;
|
|
}
|
|
// Both enabled => one-of, dispatched by the presented credential.
|
|
match (has_invite, has_payment) {
|
|
(true, true) => GatePlan::RejectBothPresented,
|
|
(true, false) => GatePlan::Invite,
|
|
(false, true) => GatePlan::Payment,
|
|
(false, false) => GatePlan::RequireOne,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Invite-code registration gate. A genuinely new wallet must present a valid,
|
|
/// unused operator-minted invite code when the instance requires one; a
|
|
/// returning wallet (known pubkey) bypasses it, exactly like PoW, and an
|
|
/// instance with the gate off accepts everyone. The claim is atomic and
|
|
/// single-use (db layer), so a code is redeemable by at most one registration
|
|
/// even under concurrent submission. Self-hosting bypasses this entirely
|
|
/// (operators simply leave the gate off).
|
|
async fn enforce_invite(
|
|
state: &AppState,
|
|
returning: bool,
|
|
invite_code: Option<&str>,
|
|
) -> Result<(), AppError> {
|
|
if returning || !state.config.registration.require_invite {
|
|
return Ok(());
|
|
}
|
|
let code = invite_code
|
|
.map(str::trim)
|
|
.filter(|c| !c.is_empty())
|
|
.ok_or_else(|| {
|
|
warn!(
|
|
gate = "invite",
|
|
"invite required for new wallet but none supplied"
|
|
);
|
|
AppError::ValidationError(
|
|
"An invite code is required to register on this instance.".into(),
|
|
)
|
|
})?;
|
|
if !state
|
|
.db
|
|
.claim_invite_code(&crate::core::invite::hash_invite_code(code))
|
|
.await?
|
|
{
|
|
// One literal for "no such code", "already used", and "expired" — never
|
|
// an oracle that distinguishes them.
|
|
return Err(AppError::ValidationError(
|
|
"Invalid or already-used invite code.".into(),
|
|
));
|
|
}
|
|
info!(gate = "invite", "invite code accepted (new user)");
|
|
Ok(())
|
|
}
|
|
|
|
/// Pay-to-register gate (PULL model). A genuinely new wallet must present the id
|
|
/// of a payment invoice — minted at [`payment_invoice`] and paid to the
|
|
/// operator's own wallet via the configured processor — that THIS backend reads
|
|
/// back as `Settled`. Returning wallets bypass it, an instance with the gate off
|
|
/// accepts everyone, and self-hosting bypasses it (leave the gate off).
|
|
///
|
|
/// The grant decision reads the processor's authenticated API as the source of
|
|
/// truth — never a client claim, never an inbound webhook. The invoice must
|
|
/// exist HERE bound to THIS `pubkey_hash`, be unspent, and read `Settled`; it is
|
|
/// then atomically consumed (single-use), so one invoice grants at most one
|
|
/// registration even under concurrent completion (the race loser is rejected).
|
|
async fn enforce_payment(
|
|
state: &AppState,
|
|
returning: bool,
|
|
pubkey_hash: &str,
|
|
payment_invoice_id: Option<&str>,
|
|
) -> Result<(), AppError> {
|
|
// Verify (non-consuming) then consume. Callers that must confirm the gate is
|
|
// redeemable BEFORE committing ANOTHER single-use token (the `All` gate plan
|
|
// pairs this with the invite) reuse `verify_payment_settled` first so a
|
|
// still-confirming payment can never cost the invite.
|
|
let Some(id) =
|
|
verify_payment_settled(state, returning, pubkey_hash, payment_invoice_id).await?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
|
|
// Atomic single-use consume: exactly one registration per invoice, even under
|
|
// a concurrent race (the loser gets the already-used literal).
|
|
if !state.db.consume_payment_invoice(&id, pubkey_hash).await? {
|
|
return Err(AppError::ValidationError(
|
|
"This payment invoice has already been used.".into(),
|
|
));
|
|
}
|
|
info!(
|
|
gate = "payment",
|
|
"payment invoice settled + consumed (new user)"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Non-consuming half of the pay-to-register gate: confirm the invoice exists,
|
|
/// is bound to THIS `pubkey_hash`, is unspent, and reads `Settled` from the
|
|
/// processor. Returns `Ok(Some(id))` when ready to consume, `Ok(None)` when the
|
|
/// gate does not apply (returning user / gate off). Splitting the check from the
|
|
/// consume lets the `all`-mode plan verify payment is settled BEFORE it burns the
|
|
/// single-use invite — a pending payment must never cost the invite.
|
|
async fn verify_payment_settled(
|
|
state: &AppState,
|
|
returning: bool,
|
|
pubkey_hash: &str,
|
|
payment_invoice_id: Option<&str>,
|
|
) -> Result<Option<String>, AppError> {
|
|
if returning || !state.config.registration.payment.require_payment {
|
|
return Ok(None);
|
|
}
|
|
// Gate on but no provider built — config validation prevents this, so it is
|
|
// an internal misconfiguration (generic 500), not a client-facing error.
|
|
let provider = state.payment.as_ref().ok_or_else(|| {
|
|
warn!(
|
|
gate = "payment",
|
|
"payment gate on but no provider configured"
|
|
);
|
|
AppError::Internal("payment provider not configured".into())
|
|
})?;
|
|
|
|
let id = payment_invoice_id
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| {
|
|
warn!(
|
|
gate = "payment",
|
|
"payment required for new wallet but no invoice id"
|
|
);
|
|
AppError::ValidationError(
|
|
"Registration on this instance requires payment. Request an invoice from \
|
|
/auth/payment-invoice, pay it, then retry."
|
|
.into(),
|
|
)
|
|
})?;
|
|
|
|
// Binding precheck against our OWN record. A missing row and a row bound to a
|
|
// DIFFERENT identity collapse to ONE literal — never an oracle distinguishing
|
|
// "unknown" from "someone else's".
|
|
let row = match state.db.get_payment_invoice(id).await? {
|
|
Some(r) if r.pubkey_hash == pubkey_hash => r,
|
|
_ => {
|
|
warn!(
|
|
gate = "payment",
|
|
"unknown or mis-bound payment invoice presented"
|
|
);
|
|
return Err(AppError::ValidationError(
|
|
"Unknown or invalid payment invoice.".into(),
|
|
));
|
|
}
|
|
};
|
|
if row.consumed_at.is_some() {
|
|
return Err(AppError::ValidationError(
|
|
"This payment invoice has already been used.".into(),
|
|
));
|
|
}
|
|
|
|
// Source of truth: read the processor. Only `Settled` grants — and by the
|
|
// `InvoiceStatus::Settled` adapter contract that means paid IN FULL, so the
|
|
// amount is not re-checked here (it is server-set at mint and the row is the
|
|
// binding anchor; a foreign or underpaid invoice never reaches this point).
|
|
let invoice = provider.get_invoice(id).await?;
|
|
if invoice.status != crate::infra::payment::InvoiceStatus::Settled {
|
|
// Distinct machine-readable code (`PAYMENT_PENDING`) so a polling client
|
|
// can tell "keep waiting" from a terminal failure without matching on the
|
|
// human string.
|
|
return Err(AppError::PaymentPending(
|
|
"Payment not yet confirmed. Complete the payment and retry.".into(),
|
|
));
|
|
}
|
|
// Defense-in-depth: the processor-side metadata bind, when present, must
|
|
// agree with the row (the row is the authority; this catches a swapped id).
|
|
if let Some(ref bound) = invoice.bind {
|
|
if bound.as_str() != pubkey_hash {
|
|
warn!(gate = "payment", "settled invoice metadata bind mismatch");
|
|
return Err(AppError::ValidationError(
|
|
"Unknown or invalid payment invoice.".into(),
|
|
));
|
|
}
|
|
}
|
|
Ok(Some(id.to_string()))
|
|
}
|
|
|
|
/// Reject a NEW registration whose requested username is already taken BEFORE any
|
|
/// single-use gate token is consumed, so a collision can't burn a paid invoice /
|
|
/// invite with no account created. Returning users are exempt (get-or-create
|
|
/// only backfills NULL optionals and never re-points an existing username).
|
|
///
|
|
/// Not fully atomic: a concurrent claim can still race the final `create_user`
|
|
/// INSERT between this read and the write. That narrow race is the price of not
|
|
/// threading a shared transaction through the gate consume + user INSERT; it is
|
|
/// far rarer than a user simply picking an already-taken name, which this closes.
|
|
async fn ensure_username_available(
|
|
state: &AppState,
|
|
returning: bool,
|
|
username: Option<&str>,
|
|
) -> Result<(), AppError> {
|
|
if returning {
|
|
return Ok(());
|
|
}
|
|
if let Some(name) = username.map(str::trim).filter(|n| !n.is_empty()) {
|
|
if state.db.get_user_by_username(name).await?.is_some() {
|
|
return Err(AppError::Conflict(
|
|
"That username is already taken. Choose another.".into(),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// SHA-256 hex of a public key string — the wallet's stable identity handle.
|
|
fn hash_public_key(public_key: &str) -> String {
|
|
use sha2::{Digest, Sha256};
|
|
hex::encode(Sha256::digest(public_key.as_bytes()))
|
|
}
|
|
|
|
// ── POST /auth/payment-invoice ───────────────────────────────────────────────
|
|
|
|
/// Request a registration-payment invoice for a wallet about to register.
|
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
pub struct PaymentInvoiceRequest {
|
|
/// The wallet's BTC public key. Its hash is the identity the invoice binds
|
|
/// to; only a later `/auth/extension` proving control of this key can redeem
|
|
/// the settled invoice, so this endpoint needs no separate proof.
|
|
pub btc_public_key: String,
|
|
}
|
|
|
|
/// A created payment invoice: what to pay, and the id to present at registration.
|
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
pub struct PaymentInvoiceResponse {
|
|
/// Opaque invoice id — send it back as `/auth/extension`'s `payment_invoice_id`.
|
|
pub invoice_id: String,
|
|
/// Where to pay (a hosted checkout URL, or an address).
|
|
pub pay_to: String,
|
|
/// Price to pay.
|
|
pub amount: String,
|
|
/// Price currency.
|
|
pub currency: String,
|
|
}
|
|
|
|
/// Create a registration-payment invoice on the operator's processor.
|
|
///
|
|
/// Public (pre-registration) and deliberately cheap: it only binds an invoice to
|
|
/// the wallet's `pubkey_hash`, which is worthless to anyone who cannot later
|
|
/// prove control of that BTC key at `/auth/extension`. The unauthenticated-surface
|
|
/// rate limiter bounds invoice spam and unpaid invoices expire on the processor,
|
|
/// so no signature proof is required here. Returns 400 when the pay gate is off
|
|
/// or the wallet is already registered (it would bypass payment anyway).
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/payment-invoice",
|
|
request_body = PaymentInvoiceRequest,
|
|
responses(
|
|
(status = 200, description = "Payment invoice created", body = PaymentInvoiceResponse),
|
|
(status = 400, description = "Pay gate off, already registered, or invalid key"),
|
|
(status = 503, description = "Payment processor unavailable")
|
|
),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state, req))]
|
|
pub async fn payment_invoice(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<PaymentInvoiceRequest>,
|
|
) -> Result<Json<PaymentInvoiceResponse>, AppError> {
|
|
let cfg = &state.config.registration.payment;
|
|
if !cfg.require_payment {
|
|
return Err(AppError::ValidationError(
|
|
"This instance does not require registration payment.".into(),
|
|
));
|
|
}
|
|
let provider = state.payment.as_ref().ok_or_else(|| {
|
|
warn!(
|
|
gate = "payment",
|
|
"payment gate on but no provider configured"
|
|
);
|
|
AppError::Internal("payment provider not configured".into())
|
|
})?;
|
|
|
|
// Light hygiene only (the authoritative key-format check is at
|
|
// /auth/extension): non-empty, bounded, printable — so we don't mint invoices
|
|
// for obvious garbage. A bind to a junk hash is harmless (never redeemable).
|
|
let key = req.btc_public_key.trim();
|
|
if key.is_empty() || key.len() > 200 || !key.bytes().all(|b| b.is_ascii_graphic()) {
|
|
return Err(AppError::ValidationError("Invalid BTC public key.".into()));
|
|
}
|
|
let pubkey_hash = hash_public_key(key);
|
|
|
|
// A known wallet bypasses payment; minting for it would waste the payer's money.
|
|
if state
|
|
.db
|
|
.get_user_by_pubkey_hash(&pubkey_hash)
|
|
.await?
|
|
.is_some()
|
|
{
|
|
return Err(AppError::ValidationError(
|
|
"This wallet is already registered; no payment is required.".into(),
|
|
));
|
|
}
|
|
|
|
let invoice = provider
|
|
.create_invoice(&crate::infra::payment::InvoiceRequest {
|
|
amount: cfg.amount.clone(),
|
|
currency: cfg.currency.clone(),
|
|
confirmations: cfg.confirmations,
|
|
bind: pubkey_hash.clone(),
|
|
expires_minutes: cfg.expires_minutes,
|
|
})
|
|
.await?;
|
|
|
|
state
|
|
.db
|
|
.insert_payment_invoice(
|
|
&invoice.id,
|
|
&pubkey_hash,
|
|
provider.kind(),
|
|
&cfg.amount,
|
|
&cfg.currency,
|
|
)
|
|
.await?;
|
|
|
|
info!(gate = "payment", "created registration payment invoice");
|
|
Ok(Json(PaymentInvoiceResponse {
|
|
invoice_id: invoice.id,
|
|
pay_to: invoice.pay_to,
|
|
amount: cfg.amount.clone(),
|
|
currency: cfg.currency.clone(),
|
|
}))
|
|
}
|
|
|
|
// ── POST /auth/check-restore ─────────────────────────────────────────────────
|
|
|
|
/// Ask whether a wallet (by seed fingerprint) was created on this backend, and
|
|
/// whether the submitted keys match.
|
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
pub struct CheckRestoreRequest {
|
|
/// Seed fingerprint (16 or 64 hex chars).
|
|
pub fingerprint: String,
|
|
/// Submitted per-asset public keys, for verification.
|
|
pub keys: Vec<AssetPublicKey>,
|
|
}
|
|
|
|
/// Restore-check result. Constant-shape: the same fields are always present so
|
|
/// the response is not a structure oracle, and `user_id` is never returned (no
|
|
/// enumeration).
|
|
///
|
|
/// NOTE: `exists` is an INTENTIONAL disclosure (the wallet uses it to decide
|
|
/// whether to offer a restore). It is throttled per fingerprint AND per IP, and
|
|
/// nothing downstream treats fingerprint-existence as authority — the
|
|
/// derivation-rotation path in [`extension_register`] requires a key-control
|
|
/// proof, not a bare fingerprint.
|
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
pub struct CheckRestoreResponse {
|
|
/// Whether the fingerprint exists on this backend.
|
|
pub exists: bool,
|
|
/// Whether every submitted key matches the stored key. `None` when the
|
|
/// fingerprint does not exist.
|
|
pub keys_valid: Option<bool>,
|
|
/// XMR scan-start height, if the wallet was created here.
|
|
pub xmr_start_height: Option<i64>,
|
|
/// WOW scan-start height, if the wallet was created here.
|
|
pub wow_start_height: Option<i64>,
|
|
}
|
|
|
|
/// Check whether a wallet restore is valid (created here + keys match).
|
|
///
|
|
/// Two governors run BEFORE any user lookup: a per-IP limit
|
|
/// ([`RESTORE_IP_THRESHOLD`]) that bounds distinct-fingerprint scanning, and a
|
|
/// per-fingerprint failure limit ([`RESTORE_FAIL_THRESHOLD`]); either trips a
|
|
/// 429. The per-IP limit uses [`client_ip`] so an untrusted `X-Forwarded-For`
|
|
/// cannot evade it. Every attempt is recorded (peppered fingerprint, salted IP).
|
|
///
|
|
/// The known/unknown branches are equalized: the unknown branch runs the same
|
|
/// key-comparison loop against an empty stored set, so the two paths do the same
|
|
/// work and the response does not become a timing oracle for existence beyond the
|
|
/// already-intentional `exists` field.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/check-restore",
|
|
request_body = CheckRestoreRequest,
|
|
responses((status = 200, description = "Restore validation result", body = CheckRestoreResponse)),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state, headers, req, peer))]
|
|
pub async fn check_restore(
|
|
State(state): State<Arc<AppState>>,
|
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<CheckRestoreRequest>,
|
|
) -> Result<Json<CheckRestoreResponse>, AppError> {
|
|
let ip = client_ip(&state, &headers, peer).to_string();
|
|
|
|
let valid_hex = req.fingerprint.chars().all(|c| c.is_ascii_hexdigit());
|
|
let valid_len = req.fingerprint.len() == 16 || req.fingerprint.len() == 64;
|
|
if !valid_hex || !valid_len {
|
|
return Err(AppError::ValidationError(
|
|
"Invalid fingerprint format (expected 16 or 64 hex characters)".into(),
|
|
));
|
|
}
|
|
|
|
// Per-IP governor FIRST: bounds distinct-fingerprint enumeration that the
|
|
// per-fingerprint counter cannot see (each candidate is probed once).
|
|
let ip_attempts = state.db.count_ip_restore_attempts(&ip).await?;
|
|
if ip_attempts >= RESTORE_IP_THRESHOLD {
|
|
warn!(
|
|
ip_attempts,
|
|
"restore blocked: too many attempts from this IP"
|
|
);
|
|
return Err(AppError::RateLimited);
|
|
}
|
|
|
|
// Per-fingerprint failure governor.
|
|
let failed = state
|
|
.db
|
|
.count_failed_restore_attempts(&req.fingerprint)
|
|
.await?;
|
|
if failed >= RESTORE_FAIL_THRESHOLD {
|
|
warn!(failed, "restore blocked: too many failed attempts");
|
|
return Err(AppError::RateLimited);
|
|
}
|
|
|
|
let user = state
|
|
.db
|
|
.get_user_by_seed_fingerprint(&req.fingerprint)
|
|
.await?;
|
|
|
|
// Equalize work across branches: always fetch the stored keys (an empty set
|
|
// for an unknown fingerprint) and always run the comparison loop, so the
|
|
// unknown and known paths do the same DB + CPU work.
|
|
let stored = match &user {
|
|
Some(u) => state.db.get_user_keys(u.id).await?,
|
|
None => Vec::new(),
|
|
};
|
|
|
|
let mut all_match = !req.keys.is_empty();
|
|
for submitted in &req.keys {
|
|
let Ok(asset) = parse_asset(&submitted.asset) else {
|
|
all_match = false;
|
|
continue;
|
|
};
|
|
match stored.iter().find(|k| k.asset == asset) {
|
|
Some(k) if k.public_key == submitted.public_key => {}
|
|
_ => all_match = false,
|
|
}
|
|
}
|
|
|
|
match user {
|
|
None => {
|
|
// Unknown fingerprint -> a failed attempt; same response shape as a
|
|
// found-but-mismatch (sans heights).
|
|
let _ = state
|
|
.db
|
|
.record_restore_attempt(&req.fingerprint, Some(&ip), false)
|
|
.await;
|
|
Ok(Json(CheckRestoreResponse {
|
|
exists: false,
|
|
keys_valid: None,
|
|
xmr_start_height: None,
|
|
wow_start_height: None,
|
|
}))
|
|
}
|
|
Some(user) => {
|
|
let _ = state
|
|
.db
|
|
.record_restore_attempt(&req.fingerprint, Some(&ip), all_match)
|
|
.await;
|
|
Ok(Json(CheckRestoreResponse {
|
|
exists: true,
|
|
keys_valid: Some(all_match),
|
|
xmr_start_height: user.xmr_start_height,
|
|
wow_start_height: user.wow_start_height,
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── POST /auth/refresh ───────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
pub struct RefreshTokenRequest {
|
|
pub refresh_token: String,
|
|
}
|
|
|
|
/// Rotate a refresh token.
|
|
///
|
|
/// Verifies the JWT, then re-looks-up the ACTIVE session by peppered hash
|
|
/// (`get_session_by_token_hash` already filters revoked/expired) — a missing row
|
|
/// means the token was revoked, expired, or already rotated, and is rejected.
|
|
/// The session's `user_id` is asserted equal to the JWT `sub` (defense-in-depth
|
|
/// against any future token-confusion class). The old session is revoked and a
|
|
/// fresh pair issued (revoke-then-issue); the `revoke_session` race-loser also
|
|
/// rejects, so a stolen token cannot be reused.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/refresh",
|
|
request_body = RefreshTokenRequest,
|
|
responses((status = 200, description = "Token refreshed", body = AuthResponse)),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state, req))]
|
|
pub async fn refresh_token(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<RefreshTokenRequest>,
|
|
) -> Result<Json<AuthResponse>, AppError> {
|
|
let (user_id, _sid) = state.sessions.verify_refresh_token(&req.refresh_token)?;
|
|
|
|
let token_hash = hash_refresh_token(
|
|
&req.refresh_token,
|
|
&state.config.secrets.refresh_token_pepper,
|
|
);
|
|
let session = state
|
|
.db
|
|
.get_session_by_token_hash(&token_hash)
|
|
.await?
|
|
.ok_or_else(|| AppError::AuthError("Invalid or expired token".into()))?;
|
|
|
|
// Defense-in-depth: the JWT subject must match the session owner. With the
|
|
// current minting these cannot diverge (the hash is of the same token whose
|
|
// sub we read), but assert the invariant so a future hashing/minting change
|
|
// can never issue a session for the wrong user.
|
|
if session.user_id != user_id {
|
|
warn!("refresh: JWT sub does not match session user_id; rejecting");
|
|
return Err(AppError::AuthError("Invalid or expired token".into()));
|
|
}
|
|
|
|
let user = state
|
|
.db
|
|
.get_user_by_id(user_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::AuthError("Invalid or expired token".into()))?;
|
|
|
|
// Preserve the originating platform across rotation.
|
|
let platform: Platform = session.platform.parse()?;
|
|
|
|
// Revoke the old session FIRST. If it was already revoked (concurrent
|
|
// refresh / replay), reject — never issue a second live pair for one token.
|
|
if !state.db.revoke_session(session.id).await? {
|
|
return Err(AppError::AuthError("Invalid or expired token".into()));
|
|
}
|
|
|
|
let pair = issue_session(
|
|
&state,
|
|
user.id,
|
|
platform,
|
|
session.device_info.as_deref().unwrap_or("unknown"),
|
|
session.ip_address,
|
|
)
|
|
.await?;
|
|
|
|
state.db.update_user_last_seen(user.id).await?;
|
|
info!(user_id = %user.id, "token refreshed");
|
|
|
|
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,
|
|
}))
|
|
}
|
|
|
|
// ── POST /auth/logout ────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
pub struct LogoutRequest {
|
|
pub refresh_token: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
pub struct LogoutResponse {
|
|
pub success: bool,
|
|
}
|
|
|
|
/// Revoke the session backing a refresh token. Idempotent: an unknown or
|
|
/// already-revoked token still returns success (logout should never error).
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/logout",
|
|
request_body = LogoutRequest,
|
|
responses((status = 200, description = "Session revoked", body = LogoutResponse)),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state, req))]
|
|
pub async fn logout(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<LogoutRequest>,
|
|
) -> Result<Json<LogoutResponse>, AppError> {
|
|
let token_hash = hash_refresh_token(
|
|
&req.refresh_token,
|
|
&state.config.secrets.refresh_token_pepper,
|
|
);
|
|
if let Some(session) = state.db.get_session_by_token_hash(&token_hash).await? {
|
|
let _ = state.db.revoke_session(session.id).await;
|
|
info!(session_id = %session.id, "session revoked via logout");
|
|
}
|
|
Ok(Json(LogoutResponse { success: true }))
|
|
}
|
|
|
|
// ── GET /auth/me ─────────────────────────────────────────────────────────────
|
|
|
|
/// The authenticated user's own info.
|
|
#[utoipa::path(
|
|
security(("bearer_auth" = [])),
|
|
get,
|
|
path = "/auth/me",
|
|
responses(
|
|
(status = 200, description = "Current authenticated user", body = UserInfo),
|
|
(status = 401, description = "Missing or invalid token")
|
|
),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state, headers))]
|
|
pub async fn get_me(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<UserInfo>, 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(user_info(&user)))
|
|
}
|
|
|
|
// ── POST /auth/nostr (NIP-98 login) ──────────────────────────────────────────
|
|
|
|
/// Sign in with a Nostr identity (NIP-98 HTTP Auth, login grade).
|
|
///
|
|
/// The `Nostr <base64(event)>` token is read from the `Authorization` header and
|
|
/// verified against the canonical `config.identity.public_api_url` + `/auth/nostr`
|
|
/// (never the request Host). The npub must ALREADY be linked to a user (see
|
|
/// [`nostr_link`]); this endpoint NEVER creates a user — an unlinked npub is 401.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/nostr",
|
|
responses(
|
|
(status = 200, description = "Session for the linked Nostr identity", body = AuthResponse),
|
|
(status = 401, description = "Invalid NIP-98 token or no linked account")
|
|
),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state, headers, peer))]
|
|
pub async fn nostr_login(
|
|
State(state): State<Arc<AppState>>,
|
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<AuthResponse>, AppError> {
|
|
let ip = client_ip(&state, &headers, peer);
|
|
|
|
let auth = headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|v| v.to_str().ok())
|
|
.ok_or_else(|| AppError::AuthError("Missing authorization header".into()))?;
|
|
|
|
let url = nip98_url(&state, "/auth/nostr")?;
|
|
let pubkey = verify_nip98(
|
|
auth,
|
|
&url,
|
|
"POST",
|
|
Utc::now().timestamp(),
|
|
NIP98_LOGIN_MAX_AGE_SECS,
|
|
)
|
|
.map_err(|_| AppError::AuthError("Invalid Nostr auth".into()))?;
|
|
|
|
// Resolve to an existing user only. Never create.
|
|
let user = state
|
|
.db
|
|
.find_user_by_nostr_pubkey(&pubkey)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
AppError::AuthError(
|
|
"No account is linked to this Nostr identity. Link it from a signed-in session first."
|
|
.into(),
|
|
)
|
|
})?;
|
|
|
|
let pair = issue_session(
|
|
&state,
|
|
user.id,
|
|
Platform::Nostr,
|
|
"Nostr",
|
|
Some(IpNetwork::from(ip)),
|
|
)
|
|
.await?;
|
|
let _ = state
|
|
.db
|
|
.record_login_event(
|
|
Some(user.id),
|
|
"btc",
|
|
Platform::Nostr.as_str(),
|
|
None,
|
|
Some(&ip.to_string()),
|
|
)
|
|
.await;
|
|
|
|
info!(user_id = %user.id, "authenticated via Nostr (NIP-98)");
|
|
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,
|
|
}))
|
|
}
|
|
|
|
// ── GET /auth/nostr/link-challenge (issue the link nonce) ────────────────────
|
|
|
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
pub struct NostrLinkChallengeResponse {
|
|
/// Server-issued single-use nonce (hex). The wallet embeds it as the signed
|
|
/// action's `challenge` tag when calling `POST /auth/nostr/link`.
|
|
pub nonce: String,
|
|
}
|
|
|
|
/// Issue a single-use nonce for linking a Nostr identity.
|
|
///
|
|
/// Authenticated (Bearer JWT). The nonce is bound to the calling user (as the
|
|
/// challenge `subject`) and the `nostr_link` purpose, valid for a short TTL, and
|
|
/// consumed atomically by [`nostr_link`]. It pairs with the signed-action proof
|
|
/// there: the wallet signs an action committing to THIS nonce, so the server can
|
|
/// prove the npub-holder authorized the link for THIS account and the request
|
|
/// cannot be replayed.
|
|
#[utoipa::path(
|
|
security(("bearer_auth" = [])),
|
|
get,
|
|
path = "/auth/nostr/link-challenge",
|
|
responses(
|
|
(status = 200, description = "Single-use link nonce", body = NostrLinkChallengeResponse),
|
|
(status = 401, description = "Missing or invalid session")
|
|
),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state, headers))]
|
|
pub async fn nostr_link_challenge(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<NostrLinkChallengeResponse>, AppError> {
|
|
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
|
let nonce = state
|
|
.db
|
|
.issue_challenge(
|
|
"nostr_link",
|
|
Some(&user_id.to_string()),
|
|
NOSTR_LINK_NONCE_TTL_SECS,
|
|
)
|
|
.await?;
|
|
Ok(Json(NostrLinkChallengeResponse { nonce }))
|
|
}
|
|
|
|
// ── POST /auth/nostr/link (state change) ─────────────────────────────────────
|
|
|
|
/// Link a Nostr identity to the authenticated user. This is a STATE CHANGE, so
|
|
/// it carries a signed-action proof, not a login-grade token.
|
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
pub struct NostrLinkRequest {
|
|
/// The `Nostr <base64(event)>` signed-action token proving control of the
|
|
/// npub AND committing to the server nonce + this request.
|
|
pub nostr_token: String,
|
|
/// The server-issued single-use nonce the signed action must bind (the
|
|
/// event's `challenge` tag).
|
|
pub nonce: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
|
pub struct NostrLinkResponse {
|
|
/// The linked x-only Nostr pubkey (hex).
|
|
pub nostr_pubkey: String,
|
|
}
|
|
|
|
/// Link a Nostr identity (npub) to the authenticated user.
|
|
///
|
|
/// Dual auth: the Bearer JWT identifies the user; a NIP-98 *signed-action* proof
|
|
/// (not a login token) proves control of the npub AND binds a server-issued
|
|
/// single-use `nonce`, the purpose `nostr_link`, and this exact request via the
|
|
/// request-descriptor hash. On success the npub is stored so a later
|
|
/// [`nostr_login`] resolves to the same wallet; a collision is 409.
|
|
///
|
|
/// Flow: the wallet first calls `GET /auth/nostr/link-challenge` (authenticated)
|
|
/// to obtain a single-use `nonce`, signs an action committing to it, then POSTs
|
|
/// here. The nonce is consumed atomically ([`Database::consume_challenge`]) and
|
|
/// cross-checked to have been issued to THIS user.
|
|
///
|
|
/// ## Descriptor binding is a CONTRACT (not an implementation detail)
|
|
///
|
|
/// The `payload` tag binds `descriptor_sha256(request_descriptor("POST",
|
|
/// "/api/v1/auth/nostr/link", "", b""))` — an EMPTY body hash, with no query.
|
|
/// The JSON `{nostr_token, nonce}` rides in the HTTP body but is deliberately NOT
|
|
/// part of the signed descriptor (it carries the proof itself, so it cannot also
|
|
/// be inside it). The wallet MUST build the identical descriptor. This exact
|
|
/// method/path/query/empty-body shape is a cross-impl contract, pinned in a shared
|
|
/// test vector (`descriptor_sha256` KAT in this file's tests, mirroring the
|
|
/// nip98.rs interop test) so the binding cannot silently drift at integration time.
|
|
#[utoipa::path(
|
|
security(("bearer_auth" = [])),
|
|
post,
|
|
path = "/auth/nostr/link",
|
|
request_body = NostrLinkRequest,
|
|
responses(
|
|
(status = 200, description = "Linked npub", body = NostrLinkResponse),
|
|
(status = 401, description = "Invalid proof or missing session"),
|
|
(status = 409, description = "npub already linked to another account")
|
|
),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state, headers, req))]
|
|
pub async fn nostr_link(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<NostrLinkRequest>,
|
|
) -> Result<Json<NostrLinkResponse>, AppError> {
|
|
// 1. JWT identifies the acting user.
|
|
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
|
|
|
// 2. Atomically consume the server-issued nonce (single-use `DELETE …
|
|
// RETURNING`, so a replay loses the race). It was issued to THIS user (subject
|
|
// bound at issue via `/auth/nostr/link-challenge`), so a nonce minted for a
|
|
// different account is rejected. Consume happens BEFORE proof verification, so
|
|
// a failed proof burns the nonce — a legit client simply fetches a fresh one.
|
|
let consumed = state
|
|
.db
|
|
.consume_challenge(&req.nonce, "nostr_link")
|
|
.await?
|
|
.ok_or_else(|| AppError::AuthError("Invalid or expired nonce".into()))?;
|
|
if consumed.subject.as_deref() != Some(user_id.to_string().as_str()) {
|
|
warn!("nostr_link: nonce subject mismatch — refusing (fail-closed)");
|
|
return Err(AppError::AuthError("Invalid or expired nonce".into()));
|
|
}
|
|
|
|
// 3. Verify the signed-action proof binds the nonce, purpose, and this
|
|
// request. The descriptor binds method+path+query+body-hash; the body hash is
|
|
// of an EMPTY body by contract (see the doc comment) — the wallet builds the
|
|
// identical descriptor.
|
|
let url = nip98_url(&state, "/auth/nostr/link")?;
|
|
let descriptor = request_descriptor("POST", "/api/v1/auth/nostr/link", "", b"");
|
|
let payload_sha256 = descriptor_sha256(&descriptor);
|
|
let pubkey = verify_signed_action(
|
|
&req.nostr_token,
|
|
&url,
|
|
"POST",
|
|
"nostr_link",
|
|
&req.nonce,
|
|
&payload_sha256,
|
|
None,
|
|
None,
|
|
Utc::now().timestamp(),
|
|
NIP98_ACTION_MAX_AGE_SECS,
|
|
)
|
|
.map_err(|_| AppError::AuthError("Invalid Nostr proof".into()))?;
|
|
|
|
// 4. Persist. UNIQUE collision -> 409 CONFLICT (handled in set_nostr_pubkey).
|
|
state.db.set_nostr_pubkey(user_id, &pubkey).await?;
|
|
// Observability: linking (incl. a rotation that REPLACES an existing npub) is a
|
|
// security-relevant binding change, so record it in the login history — a
|
|
// session-authed rebind should never be silent.
|
|
let _ = state
|
|
.db
|
|
.record_login_event(Some(user_id), "btc", Platform::Nostr.as_str(), None, None)
|
|
.await;
|
|
info!(user_id = %user_id, "linked Nostr identity");
|
|
Ok(Json(NostrLinkResponse {
|
|
nostr_pubkey: pubkey,
|
|
}))
|
|
}
|
|
|
|
// ── GET /auth/nostr/register-challenge (issue the register nonce) ─────────────
|
|
|
|
/// Issue a single-use nonce for npub-native registration. UNAUTHENTICATED: no
|
|
/// user exists yet, so (unlike the link challenge) the nonce is subject-less; the
|
|
/// register signed-action binds it purely as replay protection.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/auth/nostr/register-challenge",
|
|
responses((status = 200, description = "Single-use register nonce", body = NostrLinkChallengeResponse)),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state))]
|
|
pub async fn nostr_register_challenge(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Result<Json<NostrLinkChallengeResponse>, AppError> {
|
|
let nonce = state
|
|
.db
|
|
.issue_challenge("nostr_register", None, NOSTR_LINK_NONCE_TTL_SECS)
|
|
.await?;
|
|
Ok(Json(NostrLinkChallengeResponse { nonce }))
|
|
}
|
|
|
|
// ── POST /auth/nostr/register (npub-native create) ───────────────────────────
|
|
|
|
/// Register (or resolve) a wallet keyed by its **Nostr identity**: the
|
|
/// self-sovereign, npub-native create path. Unlike [`nostr_login`] (create-never)
|
|
/// and [`nostr_link`] (needs a prior BTC-authed JWT), this MINTS a user from the
|
|
/// npub alone: no BTC signature is ever required. The npub is the identity anchor;
|
|
/// the chain `keys` still ship (for tip addresses + restore) but are NOT the auth
|
|
/// proof.
|
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
|
pub struct NostrRegisterRequest {
|
|
/// `Nostr <base64(event)>` signed-action token proving control of the npub AND
|
|
/// binding the server nonce + the `nostr_register` purpose.
|
|
pub nostr_token: String,
|
|
/// The server-issued single-use nonce (from `/auth/nostr/register-challenge`).
|
|
pub nonce: String,
|
|
/// Chain public keys (for tip addresses + restore). A `btc` key is expected
|
|
/// for pay-to-register invoice binding, but it is no longer the identity.
|
|
pub keys: Vec<AssetPublicKey>,
|
|
pub username: Option<String>,
|
|
/// Wallet creation time (unix seconds), to bound chain scans.
|
|
pub wallet_birthday: Option<i64>,
|
|
pub seed_fingerprint: Option<String>,
|
|
pub xmr_start_height: Option<i64>,
|
|
pub wow_start_height: Option<i64>,
|
|
#[serde(default)]
|
|
#[schema(value_type = Object)]
|
|
pub altcha_solution: Option<altcha::Payload>,
|
|
#[serde(default)]
|
|
pub invite_code: Option<String>,
|
|
#[serde(default)]
|
|
pub payment_invoice_id: Option<String>,
|
|
}
|
|
|
|
/// npub-native registration. Verifies a NIP-98 signed-action over a server nonce,
|
|
/// re-runs the abuse gates keyed on the npub (a create path must not bypass the
|
|
/// PoW/invite/payment gates that `/auth/extension` enforces), then get-or-creates
|
|
/// by npub with a `seed_fingerprint` MERGE so a wallet that already has a row
|
|
/// (BTC-anchored, or previously linked) is never split into two identities.
|
|
///
|
|
/// SECURITY NOTE: the proof binds an EMPTY-body descriptor (the same contract as
|
|
/// `/auth/nostr/link`), so replay is prevented by the single-use nonce and npub
|
|
/// control is proven, but the chain `keys` are TLS-bound rather than
|
|
/// proof-bound. Binding the key list into the signed payload is a documented
|
|
/// hardening follow-up (defends a TLS-breaking active MITM swapping the keys).
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/auth/nostr/register",
|
|
request_body = NostrRegisterRequest,
|
|
responses(
|
|
(status = 200, description = "Wallet registered or authenticated", body = AuthResponse),
|
|
(status = 401, description = "Invalid proof or nonce"),
|
|
(status = 409, description = "Seed already registered under a different Nostr identity")
|
|
),
|
|
tag = "auth"
|
|
)]
|
|
#[instrument(skip(state, headers, req, peer))]
|
|
pub async fn nostr_register(
|
|
State(state): State<Arc<AppState>>,
|
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<NostrRegisterRequest>,
|
|
) -> Result<Json<AuthResponse>, AppError> {
|
|
let ip = client_ip(&state, &headers, peer);
|
|
if let Some(ref username) = req.username {
|
|
validate_username(username)?;
|
|
}
|
|
|
|
// 1. Atomically consume the subject-less register nonce (single-use replay
|
|
// guard). Consume BEFORE proof verification so a failed proof burns it.
|
|
let _consumed = state
|
|
.db
|
|
.consume_challenge(&req.nonce, "nostr_register")
|
|
.await?
|
|
.ok_or_else(|| AppError::AuthError("Invalid or expired nonce".into()))?;
|
|
|
|
// 2. Prove npub control + bind the nonce/purpose. Empty-body descriptor
|
|
// contract (mirrors /auth/nostr/link). The verifier lowercases the pubkey.
|
|
let url = nip98_url(&state, "/auth/nostr/register")?;
|
|
let descriptor = request_descriptor("POST", "/api/v1/auth/nostr/register", "", b"");
|
|
let payload_sha256 = descriptor_sha256(&descriptor);
|
|
let pubkey = verify_signed_action(
|
|
&req.nostr_token,
|
|
&url,
|
|
"POST",
|
|
"nostr_register",
|
|
&req.nonce,
|
|
&payload_sha256,
|
|
None,
|
|
None,
|
|
Utc::now().timestamp(),
|
|
NIP98_ACTION_MAX_AGE_SECS,
|
|
)
|
|
.map_err(|_| AppError::AuthError("Invalid Nostr proof".into()))?;
|
|
|
|
// 3. Returning npub bypasses the abuse gates, exactly like a returning
|
|
// pubkey_hash on the BTC path.
|
|
let returning = state.db.find_user_by_nostr_pubkey(&pubkey).await?.is_some();
|
|
|
|
// 4. Re-run the abuse gates on the npub path; a user-minting endpoint must
|
|
// not be a gate bypass. PoW keys on the npub (the per-pubkey allowlist is
|
|
// BTC-only, so only the GLOBAL requirement applies here). Pay-to-register
|
|
// invoices bind to sha256(btc pubkey), so the payment check still uses the
|
|
// BTC key carried in `keys` even though it is no longer the identity.
|
|
enforce_pow(&state, &pubkey, returning, req.altcha_solution.as_ref())?;
|
|
// Reject a taken username BEFORE consuming any single-use gate token.
|
|
ensure_username_available(&state, returning, req.username.as_deref()).await?;
|
|
let btc_hash = req
|
|
.keys
|
|
.iter()
|
|
.find(|k| k.asset.eq_ignore_ascii_case("btc"))
|
|
.map(|k| hash_public_key(&k.public_key));
|
|
enforce_method_gates(
|
|
&state,
|
|
returning,
|
|
btc_hash.as_deref().unwrap_or(&pubkey),
|
|
req.invite_code.as_deref(),
|
|
req.payment_invoice_id.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
// 5. Get-or-create by npub (seed_fingerprint MERGE dedup inside).
|
|
let wallet_birthday = req
|
|
.wallet_birthday
|
|
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0));
|
|
let user = state
|
|
.db
|
|
.get_or_create_user_by_nostr_pubkey(
|
|
&pubkey,
|
|
req.username.clone(),
|
|
wallet_birthday,
|
|
req.seed_fingerprint.clone(),
|
|
req.xmr_start_height,
|
|
req.wow_start_height,
|
|
)
|
|
.await?;
|
|
|
|
// 5b. Persist the submitted chain keys (tip addresses + restore anchors).
|
|
// The BTC path does this at registration; the npub path previously
|
|
// skipped it, leaving user_keys empty so receive-by-username lookups and
|
|
// restore self-verification broke for npub-native wallets. Idempotent.
|
|
upsert_all_keys(&state, user.id, &req.keys).await?;
|
|
|
|
// 6. Session.
|
|
let pair = issue_session(
|
|
&state,
|
|
user.id,
|
|
Platform::Nostr,
|
|
"Nostr",
|
|
Some(IpNetwork::from(ip)),
|
|
)
|
|
.await?;
|
|
let _ = state
|
|
.db
|
|
.record_login_event(
|
|
Some(user.id),
|
|
"nostr",
|
|
Platform::Nostr.as_str(),
|
|
None,
|
|
Some(&ip.to_string()),
|
|
)
|
|
.await;
|
|
info!(user_id = %user.id, is_new = %!returning, "npub-native register/auth (NIP-98)");
|
|
Ok(Json(AuthResponse {
|
|
access_token: pair.access_token,
|
|
refresh_token: pair.refresh_token,
|
|
expires_in: pair.expires_in,
|
|
user: user_info(&user),
|
|
is_new: !returning,
|
|
}))
|
|
}
|
|
|
|
// ── router ───────────────────────────────────────────────────────────────────
|
|
|
|
/// Auth routes, RELATIVE to the `/api/v1` mount point. The application is
|
|
/// expected to `Router::new().nest("/api/v1", auth::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/extension", post(extension_register))
|
|
.route("/auth/check-restore", post(check_restore))
|
|
.route("/auth/pow-challenge", post(pow_challenge))
|
|
.route("/auth/payment-invoice", post(payment_invoice))
|
|
.route("/auth/refresh", post(refresh_token))
|
|
.route("/auth/logout", post(logout))
|
|
.route("/auth/me", get(get_me))
|
|
.route("/auth/nostr", post(nostr_login))
|
|
.route("/auth/nostr/register-challenge", get(nostr_register_challenge))
|
|
.route("/auth/nostr/register", post(nostr_register))
|
|
.route("/auth/nostr/link-challenge", get(nostr_link_challenge))
|
|
.route("/auth/nostr/link", post(nostr_link))
|
|
}
|
|
|
|
#[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 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("Alice").is_err()); // uppercase
|
|
assert!(validate_username("al ice").is_err()); // space
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
|
|
// ── registration gate composition (plan_gates) ─────────────────────────
|
|
// The pure one-of / conjunction dispatch. Money-safety hinges on `any`
|
|
// mode never planning to run (and thus consume) more than one token.
|
|
mod gate_plan {
|
|
use super::super::{plan_gates, GatePlan};
|
|
use crate::config::GateMode::{All, Any};
|
|
|
|
#[test]
|
|
fn no_gates_enabled_is_open_in_either_mode() {
|
|
assert_eq!(plan_gates(All, false, false, false, false), GatePlan::Open);
|
|
assert_eq!(plan_gates(Any, false, false, true, true), GatePlan::Open);
|
|
}
|
|
|
|
#[test]
|
|
fn all_mode_is_conjunction_regardless_of_presented_creds() {
|
|
// Both enabled -> All (run both). Single enabled -> still All (the
|
|
// off gate self-disables inside enforce_*).
|
|
assert_eq!(plan_gates(All, true, true, false, false), GatePlan::All);
|
|
assert_eq!(plan_gates(All, true, false, false, false), GatePlan::All);
|
|
assert_eq!(plan_gates(All, false, true, true, true), GatePlan::All);
|
|
}
|
|
|
|
#[test]
|
|
fn any_mode_single_gate_is_just_that_gate() {
|
|
assert_eq!(plan_gates(Any, true, false, false, false), GatePlan::Invite);
|
|
assert_eq!(plan_gates(Any, false, true, false, false), GatePlan::Payment);
|
|
}
|
|
|
|
#[test]
|
|
fn any_mode_both_enabled_dispatches_on_presented_credential() {
|
|
assert_eq!(plan_gates(Any, true, true, true, false), GatePlan::Invite);
|
|
assert_eq!(plan_gates(Any, true, true, false, true), GatePlan::Payment);
|
|
}
|
|
|
|
#[test]
|
|
fn any_mode_rejects_both_credentials_to_avoid_double_consume() {
|
|
// The load-bearing money-safety case: never plan to burn two tokens.
|
|
assert_eq!(
|
|
plan_gates(Any, true, true, true, true),
|
|
GatePlan::RejectBothPresented
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn any_mode_both_enabled_no_credential_requires_one() {
|
|
assert_eq!(
|
|
plan_gates(Any, true, true, false, false),
|
|
GatePlan::RequireOne
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Wire-shape regression: the extension request must accept the wrapped
|
|
/// `altcha::Payload` envelope and reject a bare `Solution`.
|
|
#[test]
|
|
fn extension_request_wire_shape() {
|
|
let wrapped = r#"{
|
|
"keys": [{"asset":"btc","public_key":"deadbeef"}],
|
|
"seed_fingerprint": "fp-1",
|
|
"signed_timestamp": 1700000000,
|
|
"signature": "sig",
|
|
"altcha_solution": {
|
|
"challenge": {
|
|
"parameters": {
|
|
"algorithm": "PBKDF2/SHA-256",
|
|
"cost": 100,
|
|
"keyLength": 32,
|
|
"keyPrefix": "00",
|
|
"nonce": "n",
|
|
"salt": "s"
|
|
},
|
|
"signature": "hmac"
|
|
},
|
|
"solution": { "counter": 42, "derivedKey": "00aabb", "time": 1.0 }
|
|
}
|
|
}"#;
|
|
let req: ExtensionRegisterRequest =
|
|
serde_json::from_str(wrapped).expect("wrapped envelope deserializes");
|
|
assert!(req.altcha_solution.is_some());
|
|
assert!(req.rotation_signature.is_none());
|
|
|
|
let bare = r#"{
|
|
"keys": [{"asset":"btc","public_key":"deadbeef"}],
|
|
"signed_timestamp": 1700000000,
|
|
"signature": "sig",
|
|
"altcha_solution": { "counter": 42, "derivedKey": "00aabb" }
|
|
}"#;
|
|
assert!(serde_json::from_str::<ExtensionRegisterRequest>(bare).is_err());
|
|
}
|
|
|
|
/// The rotation-proof field is optional and round-trips when present.
|
|
#[test]
|
|
fn extension_request_accepts_rotation_signature() {
|
|
let with_rot = r#"{
|
|
"keys": [{"asset":"btc","public_key":"deadbeef"}],
|
|
"seed_fingerprint": "fp-1",
|
|
"signed_timestamp": 1700000000,
|
|
"signature": "newsig",
|
|
"rotation_signature": "oldsig"
|
|
}"#;
|
|
let req: ExtensionRegisterRequest =
|
|
serde_json::from_str(with_rot).expect("rotation envelope deserializes");
|
|
assert_eq!(req.rotation_signature.as_deref(), Some("oldsig"));
|
|
}
|
|
}
|