diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index ce1c343f..41dddd51 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -8,10 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **2026-07-18** - Passwordless WebAuthn / passkey login + - New `fetch`-based endpoints `POST /api/v1/webauthn/register/start` + `/register/finish` (creates a passwordless account) and `POST /api/v1/webauthn/login/start` + `/login/finish` (usernameless / discoverable login). Each `start` returns a `challenge` for the browser `navigator.credentials` API plus an opaque signed `state`; the matching `finish` posts back that `state` with the credential and returns a `{ token, token_type, expires_in }` session response. + - Uses the same stateless session **JWT** as OAuth (`Authorization: Bearer `), so passkey users reach every existing authenticated endpoint. Configured under a new `webauthn` config section (`rp-id`, `rp-origin`, `rp-name`); the signing secret lives in the shared `session` block (below). + - Passkey accounts are stored with a new `account_type` of `webauthn` and a synthetic identity (`sha256("webauthn\\0{user_handle}")`, in a namespace provably disjoint from OAuth). Like OAuth accounts they have no usable Nostr key: NIP-17 DMs, npub display and LIR signing are gated off, and `GET /api/v1/account` reports `account_type: "webauthn"`. Credentials are stored in the new `user_webauthn_credentials` table (one account may register several devices). + - **Add passkeys to existing accounts:** authenticated endpoints `GET /api/v1/webauthn/credentials` (list), `POST /api/v1/webauthn/credentials/start` + `/finish` (register another passkey to the current account — any account type, Nostr/OAuth/passkey), and `DELETE /api/v1/webauthn/credentials/{id}` (remove one). A later discoverable login with such a passkey resolves back to the same account (by credential id), so its session token carries the account's real identity. A pure passkey account cannot delete its only credential. + - **2026-07-18** - Generic OAuth / OIDC login (Google, GitHub, Facebook, Apple) - New endpoints `GET /api/v1/oauth/{provider}/login` (redirects to the provider) and `GET`/`POST /api/v1/oauth/{provider}/callback` (exchanges the authorization code, resolves/creates the user and issues a session token). Providers are configured under the new `oauth` config section, each with a `type` of `google`, `github`, `facebook`, `apple`, or generic `oidc`. - Built-in flavors handle each provider's quirks: GitHub's `User-Agent` requirement and numeric `id` subject, Facebook's Graph `me` endpoint, and Sign in with Apple's `id_token`-based subject, dynamically-signed **ES256** client secret, and `form_post` (POST) callback. - - After a successful login the API issues a stateless session **JWT**. It is accepted on every existing authenticated endpoint via `Authorization: Bearer `, alongside the existing `Authorization: Nostr ` (NIP-98) scheme. + - After a successful login the API issues a stateless session **JWT**. It is accepted on every existing authenticated endpoint via `Authorization: Bearer `, alongside the existing `Authorization: Nostr ` (NIP-98) scheme. The JWT signing secret and lifetime live in a shared top-level `session` config block (`session.secret`, `session.ttl`) used by both OAuth and WebAuthn — required whenever either login method is enabled. - On first login the provider's email is synced into the account (marked verified when the provider asserts it) and email notifications are enabled by default, since OAuth accounts have no NIP-17 channel. GitHub's primary verified address is fetched from `/user/emails`; Apple's email comes from the `id_token`. The sync is non-destructive — a user's later email edits are not overwritten — and best-effort (a sync failure never blocks login). - OAuth accounts are stored with a new `account_type` of `oauth` and a synthetic identity (`sha256("{provider}:{subject}")`) in place of a Nostr pubkey. Nostr-only features (NIP-17 DMs, npub display, LIR agreement signing) are gated to native Nostr accounts. - `GET /api/v1/account` now returns `account_type` (`nostr` | `oauth`, read-only) so the frontend can hide Nostr-only UI for OAuth users. `PATCH /api/v1/account` rejects enabling `contact_nip17` for OAuth accounts (their pubkey is not a usable Nostr key). diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 89702018..16bebaa3 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -105,6 +105,60 @@ On first OAuth login the provider's email is synced into the account (and marked verified when the provider asserts it), so `AccountInfo.email` / `email_verified` may already be populated for these users. +### Passkey (WebAuthn) login + +Passwordless login with a platform passkey (Face ID / Touch ID / Windows Hello) +or a security key. Like OAuth, a passkey **is** the account (`account_type` is +`oauth`-style synthetic — see the note below) and login yields the same +`Bearer` session token. Unlike OAuth this is a **`fetch`/XHR flow** driven by the +browser's `navigator.credentials` API, not a redirect. + +Each ceremony is two calls — `start` returns a challenge plus an opaque signed +`state`; you run the WebAuthn browser API, then post the result back with that +same `state` to `finish`, which returns the session token. + +**Register (new account):** + +```typescript +import { startRegistration } from '@simplewebauthn/browser'; + +// 1. begin +const start = await fetch(`${API}/api/v1/webauthn/register/start`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'my device' }), // optional label +}).then(r => r.json()); +// start.data = { challenge, state } + +// 2. run the authenticator (challenge.publicKey per the WebAuthn spec) +const credential = await startRegistration(start.data.challenge.publicKey); + +// 3. finish -> session token +const done = await fetch(`${API}/api/v1/webauthn/register/finish`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: start.data.state, credential }), +}).then(r => r.json()); +localStorage.setItem('session_token', done.data.token); // { token, token_type, expires_in } +``` + +**Login (existing passkey, usernameless):** + +```typescript +import { startAuthentication } from '@simplewebauthn/browser'; + +const start = await fetch(`${API}/api/v1/webauthn/login/start`, { method: 'POST' }) + .then(r => r.json()); // { challenge, state } +const credential = await startAuthentication(start.data.challenge.publicKey); +const done = await fetch(`${API}/api/v1/webauthn/login/finish`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: start.data.state, credential }), +}).then(r => r.json()); +localStorage.setItem('session_token', done.data.token); +``` + +The signed `state` must be round-tripped unchanged and expires after ~5 minutes. +Passkey accounts are Nostr-less exactly like OAuth accounts — hide npub / NIP-17 +UI for them (they are reported with `account_type` distinct from `nostr`). + ## Core Data Types ### User Account @@ -113,7 +167,7 @@ verified when the provider asserts it), so `AccountInfo.email` / interface AccountInfo { email?: string; email_verified?: boolean; // Present when email is set; true if email has been verified - account_type: 'nostr' | 'oauth'; // Read-only. 'oauth' accounts have no usable Nostr key — hide npub / NIP-17 UI. Enabling contact_nip17 for an 'oauth' account is rejected. + account_type: 'nostr' | 'oauth' | 'webauthn'; // Read-only. Only 'nostr' has a usable Nostr key — hide npub / NIP-17 UI for 'oauth' and 'webauthn'. Enabling contact_nip17 for a non-'nostr' account is rejected. contact_nip17: boolean; contact_email: boolean; country_code?: string; // ISO 3166-1 alpha-3 country code @@ -433,6 +487,58 @@ interface VmUpgradeQuote { ## API Endpoints +### Passkey (WebAuthn) Authentication + +Unauthenticated `fetch` endpoints (JSON in/out) for passwordless passkey login. +See [Authentication](#authentication-types) for the full flow and browser +examples. Each ceremony is a `start` then `finish` pair; the opaque signed +`state` from `start` must be posted back to `finish` unchanged. + +#### Register Start +- **POST** `/api/v1/webauthn/register/start` +- **Auth**: None +- **Body**: `{ name?: string }` (optional device label) +- **Response**: `{ challenge: PublicKeyCredentialCreationOptions, state: string }` + +#### Register Finish +- **POST** `/api/v1/webauthn/register/finish` +- **Auth**: None +- **Body**: `{ state: string, credential: RegisterPublicKeyCredential, name?: string }` +- **Response**: `{ token: string, token_type: "Bearer", expires_in: number }` (creates the account) + +#### Login Start +- **POST** `/api/v1/webauthn/login/start` +- **Auth**: None +- **Body**: none (usernameless / discoverable) +- **Response**: `{ challenge: PublicKeyCredentialRequestOptions, state: string }` + +#### Login Finish +- **POST** `/api/v1/webauthn/login/finish` +- **Auth**: None +- **Body**: `{ state: string, credential: PublicKeyCredential }` +- **Response**: `{ token: string, token_type: "Bearer", expires_in: number }` + +#### Manage passkeys on the current account + +These endpoints are **authenticated** (any scheme — a Nostr, OAuth or passkey +user can add passkeys to their own account). A passkey added here is stored +against the current account, so a later discoverable login with it resolves back +to that same account (its session token then carries the account's real +identity — e.g. a Nostr user's npub still works). + +- **GET** `/api/v1/webauthn/credentials` — list this account's passkeys. + Response: `Array<{ id: number, name?: string, created: string, last_used?: string }>` +- **POST** `/api/v1/webauthn/credentials/start` — begin adding a passkey. + Body `{ name?: string }`; Response `{ challenge, state }` (already excludes + credentials registered to this account). Run `startRegistration(...)` then: +- **POST** `/api/v1/webauthn/credentials/finish` — Body + `{ state, credential, name? }`; Response is the created + `{ id, name?, created, last_used? }` (no session token — you are already + logged in). +- **DELETE** `/api/v1/webauthn/credentials/{id}` — remove a passkey. A pure + passkey account (`account_type: "webauthn"`) cannot delete its **only** + credential (that would lock the account out). + ### OAuth Authentication These endpoints are **unauthenticated** and drive full-page browser navigation diff --git a/Cargo.lock b/Cargo.lock index 0f9115ce..7777eabc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,45 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -520,6 +559,17 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "base64urlsafedata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + [[package]] name = "bech32" version = "0.11.1" @@ -1094,6 +1144,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_builder" version = "0.20.2" @@ -1721,6 +1791,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2821,6 +2902,7 @@ dependencies = [ "urlencoding", "uuid", "virt", + "webauthn-rs", "wiremock", ] @@ -3296,6 +3378,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -3346,6 +3434,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3541,6 +3638,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3771,6 +3874,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4340,6 +4449,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4667,6 +4785,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -5296,6 +5424,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -5971,6 +6129,74 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom 7.1.3", + "openssl", + "openssl-sys", + "rand 0.9.4", + "rand_chacha 0.9.0", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-root-certs" version = "1.0.8" @@ -6359,6 +6585,23 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xxhash-rust" version = "0.8.15" diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index 578d104a..8b2c5e73 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -78,6 +78,10 @@ urlencoding = "2.1" rand = "0.9" # ES256 signing for the "Sign in with Apple" client-secret JWT (.p8 key). p256 = { version = "0.13", features = ["ecdsa", "pkcs8", "pem"] } +# Passwordless WebAuthn / passkey login. conditional-ui enables discoverable +# (usernameless) credentials; danger-allow-state-serialisation lets us carry the +# ceremony state through the client inside a signed challenge token. +webauthn-rs = { version = "0.5", features = ["conditional-ui", "danger-allow-state-serialisation"] } clap = { version = "4.5", features = ["derive"] } ssh-key = "0.6" lettre = { version = "0.11", features = ["tokio1-native-tls"] } diff --git a/lnvps_api/config.yaml b/lnvps_api/config.yaml index 59f1eab1..928f8ee5 100644 --- a/lnvps_api/config.yaml +++ b/lnvps_api/config.yaml @@ -53,12 +53,8 @@ captcha: # Generic OAuth / OIDC login (optional). When set, users can log in via # external identity providers and receive a session JWT that is accepted on # authenticated endpoints as `Authorization: Bearer ` (alongside NIP-98). +# Requires the shared `session` block below to be set. #oauth: -# # Strong, stable random secret used to sign session tokens. Changing it -# # invalidates all outstanding sessions. -# session-secret: "change-me-to-a-long-random-string" -# # Session lifetime in seconds (default 30 days). -# session-ttl: 2592000 # # Optional: after login, redirect the browser here with `#token=`. # # When omitted, the callback returns the token as JSON. # success-redirect: "https://app.example.com/oauth/complete" @@ -100,3 +96,26 @@ captcha: # # userinfo-url: "https://id.example.com/userinfo" # # scopes: ["openid", "email"] # # subject-field: "sub" +# Passwordless WebAuthn / passkey login (optional). When set, users can create +# an account and log in with a platform passkey (Face ID / Touch ID / Windows +# Hello) or a security key, and receive the same session JWT as OAuth +# (`Authorization: Bearer `). Login is usernameless (discoverable). +# Requires the shared `session` block below to be set. +#webauthn: +# # Relying Party ID: the registrable domain passkeys are bound to. PERMANENT — +# # changing it invalidates every registered passkey. +# rp-id: "app.example.com" +# # The exact origin the frontend runs on (host must equal or be a subdomain of +# # rp-id). +# rp-origin: "https://app.example.com" +# # Human-friendly name shown in the authenticator prompt. +# rp-name: "LNVPS" +# Shared session-token settings. Required whenever `oauth` or `webauthn` is +# enabled — both issue the same stateless session JWTs. Omit to disable Bearer +# session auth entirely (Nostr / NIP-98 only). +#session: +# # Strong, stable random secret used to sign session JWTs (and the OAuth CSRF +# # `state` / WebAuthn challenge tokens). Changing it invalidates all sessions. +# secret: "change-me-to-a-long-random-string" +# # Session lifetime in seconds (default 30 days). +# ttl: 2592000 diff --git a/lnvps_api/src/api/mod.rs b/lnvps_api/src/api/mod.rs index 1c29e18d..05287609 100644 --- a/lnvps_api/src/api/mod.rs +++ b/lnvps_api/src/api/mod.rs @@ -9,6 +9,7 @@ mod oauth; mod referral; mod routes; mod subscriptions; +mod webauthn; mod webhook; use crate::settings::Settings; @@ -29,6 +30,7 @@ pub use routes::routes as main_router; use serde::Deserialize; use std::sync::Arc; pub use subscriptions::router as subscriptions_router; +pub use webauthn::router as webauthn_router; pub use webhook::router as webhook_router; #[derive(Deserialize)] diff --git a/lnvps_api/src/api/oauth.rs b/lnvps_api/src/api/oauth.rs index 7bfe3332..fb00ce34 100644 --- a/lnvps_api/src/api/oauth.rs +++ b/lnvps_api/src/api/oauth.rs @@ -160,7 +160,7 @@ async fn handle_callback( warn!("Failed to sync OAuth email for user {}: {}", uid, e); } - let token = issue_session_token(&pubkey, uid, cfg_session_ttl(&cfg)) + let token = issue_session_token(&pubkey, uid, session_ttl(this)) .map_err(|e| ApiError::internal(format!("Failed to issue session: {}", e)))?; // Redirect to the frontend with the token in the fragment, or return JSON. @@ -172,7 +172,7 @@ async fn handle_callback( let resp = ApiData::ok(OAuthTokenResponse { token, token_type: "Bearer".to_string(), - expires_in: cfg_session_ttl(&cfg), + expires_in: session_ttl(this), })?; Ok(resp.into_response()) } @@ -197,8 +197,13 @@ fn resolve_provider( Ok((cfg, provider_cfg)) } -fn cfg_session_ttl(cfg: &crate::settings::OAuthConfig) -> u64 { - cfg.session_ttl +/// Session token lifetime from the shared `[session]` config (default 30 days). +fn session_ttl(this: &RouterState) -> u64 { + this.settings + .session + .as_ref() + .map(|s| s.ttl) + .unwrap_or(lnvps_api_common::DEFAULT_SESSION_TTL_SECS) } fn cfg_success_redirect(cfg: &crate::settings::OAuthConfig) -> Option<&str> { diff --git a/lnvps_api/src/api/webauthn.rs b/lnvps_api/src/api/webauthn.rs new file mode 100644 index 00000000..4a435260 --- /dev/null +++ b/lnvps_api/src/api/webauthn.rs @@ -0,0 +1,602 @@ +//! Passwordless WebAuthn / passkey login. +//! +//! Mirrors the OAuth flow (`api/oauth.rs`): a passkey *is* the account. A fresh +//! account is minted on registration with a synthetic identity +//! (`sha256("webauthn:{user_handle}")`, see [`lnvps_db::webauthn_pubkey`]) and, +//! on success, the user is issued the same stateless session JWT that OAuth +//! users get — accepted by the shared `Nip98Auth` extractor as +//! `Authorization: Bearer `. +//! +//! Login is **usernameless/discoverable**: the browser presents whichever +//! passkey the user picks, the authenticator returns the account's user handle, +//! and the server resolves the account from the stored credential. +//! +//! Both ceremonies are two round-trips. The intermediate server-owned state +//! (`PasskeyRegistration` / `DiscoverableAuthentication`) is carried back to the +//! client inside a signed, short-lived *challenge token* (HS256, see +//! [`lnvps_api_common::issue_challenge_token`]) so the API stays stateless while +//! remaining tamper-proof against the client. + +use axum::Router; +use axum::extract::{Json, Path, State}; +use axum::routing::{delete, get, post}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use lnvps_api_common::{ + ApiData, ApiError, ApiResult, DEFAULT_CHALLENGE_TTL_SECS, Nip98Auth, issue_challenge_token, + issue_session_token, verify_challenge_token, +}; +use lnvps_db::{WebauthnCredential, webauthn_pubkey}; + +use webauthn_rs::prelude::{ + CreationChallengeResponse, CredentialID, DiscoverableAuthentication, DiscoverableKey, Passkey, + PasskeyRegistration, PublicKeyCredential, RegisterPublicKeyCredential, + RequestChallengeResponse, Url, Uuid, Webauthn, WebauthnBuilder, +}; + +use crate::api::RouterState; +use crate::settings::WebauthnConfig; + +const PURPOSE_REG: &str = "webauthn-reg"; +const PURPOSE_AUTH: &str = "webauthn-auth"; +/// Registration ceremony for adding a passkey to an already-authenticated account. +const PURPOSE_CRED_REG: &str = "webauthn-cred-reg"; + +pub fn router() -> Router { + Router::new() + .route("/api/v1/webauthn/register/start", post(register_start)) + .route("/api/v1/webauthn/register/finish", post(register_finish)) + .route("/api/v1/webauthn/login/start", post(login_start)) + .route("/api/v1/webauthn/login/finish", post(login_finish)) + // Manage passkeys on the authenticated account. + .route("/api/v1/webauthn/credentials", get(list_credentials)) + .route( + "/api/v1/webauthn/credentials/start", + post(add_credential_start), + ) + .route( + "/api/v1/webauthn/credentials/finish", + post(add_credential_finish), + ) + .route( + "/api/v1/webauthn/credentials/{id}", + delete(delete_credential), + ) +} + +/// Session token handed back after a successful passkey register/login. Same +/// shape as the OAuth token response. +#[derive(Serialize)] +pub struct WebauthnTokenResponse { + /// Session JWT to be sent as `Authorization: Bearer `. + pub token: String, + /// Token type, always `Bearer`. + pub token_type: String, + /// Lifetime in seconds. + pub expires_in: u64, +} + +#[derive(Deserialize)] +struct RegisterStartRequest { + /// Optional friendly label shown in the authenticator UI (not an identity). + name: Option, +} + +#[derive(Serialize)] +struct RegisterStartResponse { + /// Pass straight to `navigator.credentials.create({ publicKey })`. + challenge: CreationChallengeResponse, + /// Opaque signed state to echo back on the finish step. + state: String, +} + +/// Registration ceremony state we carry through the client (signed). +#[derive(Serialize, Deserialize)] +struct RegState { + /// Per-account user handle minted at start; basis of the synthetic pubkey. + handle: String, + reg: PasskeyRegistration, +} + +#[derive(Deserialize)] +struct RegisterFinishRequest { + /// The signed state returned by `register/start`. + state: String, + /// The credential produced by `navigator.credentials.create`. + credential: RegisterPublicKeyCredential, + /// Optional friendly label to store with the credential. + name: Option, +} + +#[derive(Serialize)] +struct LoginStartResponse { + /// Pass straight to `navigator.credentials.get({ publicKey })`. + challenge: RequestChallengeResponse, + /// Opaque signed state to echo back on the finish step. + state: String, +} + +#[derive(Deserialize)] +struct LoginFinishRequest { + /// The signed state returned by `login/start`. + state: String, + /// The assertion produced by `navigator.credentials.get`. + credential: PublicKeyCredential, +} + +/// Public view of a registered passkey on the authenticated account. +#[derive(Serialize)] +pub struct WebauthnCredentialInfo { + pub id: u64, + pub name: Option, + pub created: DateTime, + pub last_used: Option>, +} + +impl From<&WebauthnCredential> for WebauthnCredentialInfo { + fn from(c: &WebauthnCredential) -> Self { + WebauthnCredentialInfo { + id: c.id, + name: c.name.clone(), + created: c.created, + last_used: c.last_used, + } + } +} + +/// Stable per-account WebAuthn user handle so every passkey a user adds to their +/// account shares one identity in the authenticator. Derived from the account's +/// pubkey; login still resolves the account by credential id, so this value is +/// not security-critical. +fn account_handle(pubkey: &[u8; 32]) -> Uuid { + let mut b = [0u8; 16]; + b.copy_from_slice(&pubkey[..16]); + Uuid::from_bytes(b) +} + +/// Build a `Webauthn` instance from the configured relying-party identity. +fn build_webauthn(cfg: &WebauthnConfig) -> Result { + let origin = Url::parse(&cfg.rp_origin) + .map_err(|e| ApiError::internal(format!("Invalid webauthn rp_origin: {}", e)))?; + let builder = WebauthnBuilder::new(&cfg.rp_id, &origin) + .map_err(|e| ApiError::internal(format!("Invalid webauthn config: {}", e)))?; + builder + .rp_name(&cfg.rp_name) + .build() + .map_err(|e| ApiError::internal(format!("Failed to build webauthn: {}", e))) +} + +/// Session token lifetime from the shared `[session]` config (default 30 days). +fn session_ttl(this: &RouterState) -> u64 { + this.settings + .session + .as_ref() + .map(|s| s.ttl) + .unwrap_or(lnvps_api_common::DEFAULT_SESSION_TTL_SECS) +} + +/// Resolve the WebAuthn config or 4xx if passkeys are disabled. +fn webauthn_cfg(this: &RouterState) -> Result { + this.settings + .webauthn + .clone() + .ok_or_else(|| ApiError::from(anyhow::anyhow!("WebAuthn not configured"))) +} + +/// Begin registration of a brand-new passwordless account. +async fn register_start( + State(this): State, + body: Option>, +) -> ApiResult { + let cfg = webauthn_cfg(&this)?; + let webauthn = build_webauthn(&cfg)?; + + let name = body + .and_then(|b| b.0.name) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "LNVPS user".to_string()); + + // Fresh, stable account handle. Stored inside the credential and returned by + // the authenticator during discoverable login. + let handle = Uuid::new_v4(); + + let (ccr, reg) = webauthn + .start_passkey_registration(handle, &name, &name, None) + .map_err(|e| ApiError::internal(format!("Failed to start registration: {}", e)))?; + + let state = RegState { + handle: handle.to_string(), + reg, + }; + let token = issue_challenge_token( + PURPOSE_REG, + &serde_json::to_string(&state).map_err(ApiError::internal)?, + DEFAULT_CHALLENGE_TTL_SECS, + ) + .map_err(|e| ApiError::internal(format!("Failed to create challenge: {}", e)))?; + + ApiData::ok(RegisterStartResponse { + challenge: ccr, + state: token, + }) +} + +/// Complete registration: verify the attestation, create the account, store the +/// credential and issue a session token. +async fn register_finish( + State(this): State, + Json(req): Json, +) -> ApiResult { + let cfg = webauthn_cfg(&this)?; + let webauthn = build_webauthn(&cfg)?; + + let state_json = verify_challenge_token(PURPOSE_REG, &req.state) + .map_err(|e| ApiError::from(anyhow::anyhow!("Invalid registration state: {}", e)))?; + let state: RegState = serde_json::from_str(&state_json).map_err(ApiError::internal)?; + + let passkey = webauthn + .finish_passkey_registration(&req.credential, &state.reg) + .map_err(|e| ApiError::from(anyhow::anyhow!("Registration failed: {}", e)))?; + + let cred_id = passkey.cred_id().as_ref().to_vec(); + + // A credential id must never be registered to two accounts. + if this.db.get_webauthn_credential(&cred_id).await.is_ok() { + return Err(ApiError::from(anyhow::anyhow!( + "Credential already registered" + ))); + } + + // Mint the account from the handle-derived synthetic identity. + let pubkey = webauthn_pubkey(&state.handle); + let uid = this.db.upsert_webauthn_user(&pubkey).await?; + + let passkey_json = serde_json::to_string(&passkey).map_err(ApiError::internal)?; + this.db + .insert_webauthn_credential(&WebauthnCredential { + user_id: uid, + cred_id, + passkey: passkey_json, + name: req.name.filter(|s| !s.trim().is_empty()), + ..Default::default() + }) + .await?; + + issue_token(&pubkey, uid, session_ttl(&this)) +} + +/// Begin a usernameless (discoverable) login. +async fn login_start(State(this): State) -> ApiResult { + let cfg = webauthn_cfg(&this)?; + let webauthn = build_webauthn(&cfg)?; + + let (rcr, auth) = webauthn + .start_discoverable_authentication() + .map_err(|e| ApiError::internal(format!("Failed to start authentication: {}", e)))?; + + let token = issue_challenge_token( + PURPOSE_AUTH, + &serde_json::to_string(&auth).map_err(ApiError::internal)?, + DEFAULT_CHALLENGE_TTL_SECS, + ) + .map_err(|e| ApiError::internal(format!("Failed to create challenge: {}", e)))?; + + ApiData::ok(LoginStartResponse { + challenge: rcr, + state: token, + }) +} + +/// Complete a discoverable login: identify the account from the assertion, +/// verify it against the stored passkeys, bump the counter and issue a token. +async fn login_finish( + State(this): State, + Json(req): Json, +) -> ApiResult { + let cfg = webauthn_cfg(&this)?; + let webauthn = build_webauthn(&cfg)?; + + let state_json = verify_challenge_token(PURPOSE_AUTH, &req.state) + .map_err(|e| ApiError::from(anyhow::anyhow!("Invalid authentication state: {}", e)))?; + let auth: DiscoverableAuthentication = + serde_json::from_str(&state_json).map_err(ApiError::internal)?; + + // Extract the credential id the user asserted with, and locate the account. + let (_uuid, cred_id) = webauthn + .identify_discoverable_authentication(&req.credential) + .map_err(|e| ApiError::from(anyhow::anyhow!("Unknown credential: {}", e)))?; + + let used = this + .db + .get_webauthn_credential(cred_id.as_ref()) + .await + .map_err(|_| ApiError::from(anyhow::anyhow!("Unknown credential")))?; + + // Load all of the account's passkeys as discoverable keys for verification. + let stored = this.db.list_webauthn_credentials(used.user_id).await?; + let passkeys: Vec = stored + .iter() + .filter_map(|c| serde_json::from_str::(&c.passkey).ok()) + .collect(); + let discoverable: Vec = passkeys.iter().map(DiscoverableKey::from).collect(); + + let result = webauthn + .finish_discoverable_authentication(&req.credential, auth, &discoverable) + .map_err(|e| ApiError::from(anyhow::anyhow!("Authentication failed: {}", e)))?; + + // Persist any counter/backup-state change on the credential that was used. + if let Some(mut pk) = passkeys + .iter() + .find(|p| p.cred_id() == result.cred_id()) + .cloned() + && pk.update_credential(&result).is_some() + && let Ok(json) = serde_json::to_string(&pk) + { + // Best-effort — a failed counter write must not block a valid login. + let _ = this.db.update_webauthn_credential(used.id, &json).await; + } + + let user = this.db.get_user(used.user_id).await?; + let pubkey: [u8; 32] = user + .pubkey + .as_slice() + .try_into() + .map_err(|_| ApiError::internal("Invalid stored pubkey"))?; + + issue_token(&pubkey, used.user_id, session_ttl(&this)) +} + +/// List the passkeys registered to the authenticated account. +async fn list_credentials( + auth: Nip98Auth, + State(this): State, +) -> ApiResult> { + // Passkeys require WebAuthn to be configured at all. + webauthn_cfg(&this)?; + let uid = this.db.upsert_user(&auth.pubkey()).await?; + let creds = this.db.list_webauthn_credentials(uid).await?; + ApiData::ok(creds.iter().map(WebauthnCredentialInfo::from).collect()) +} + +/// Begin adding a passkey to the authenticated account (any account type). +async fn add_credential_start( + auth: Nip98Auth, + State(this): State, + body: Option>, +) -> ApiResult { + let cfg = webauthn_cfg(&this)?; + let webauthn = build_webauthn(&cfg)?; + let uid = this.db.upsert_user(&auth.pubkey()).await?; + + let name = body + .and_then(|b| b.0.name) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "LNVPS user".to_string()); + + // Exclude already-registered credentials so the same authenticator cannot be + // enrolled twice on this account. + let existing = this.db.list_webauthn_credentials(uid).await?; + let exclude: Vec = existing + .iter() + .map(|c| CredentialID::from(c.cred_id.clone())) + .collect(); + + let handle = account_handle(&auth.pubkey()); + let (ccr, reg) = webauthn + .start_passkey_registration(handle, &name, &name, Some(exclude)) + .map_err(|e| ApiError::internal(format!("Failed to start registration: {}", e)))?; + + let token = issue_challenge_token( + PURPOSE_CRED_REG, + &serde_json::to_string(®).map_err(ApiError::internal)?, + DEFAULT_CHALLENGE_TTL_SECS, + ) + .map_err(|e| ApiError::internal(format!("Failed to create challenge: {}", e)))?; + + ApiData::ok(RegisterStartResponse { + challenge: ccr, + state: token, + }) +} + +/// Complete adding a passkey to the authenticated account and store it. +async fn add_credential_finish( + auth: Nip98Auth, + State(this): State, + Json(req): Json, +) -> ApiResult { + let cfg = webauthn_cfg(&this)?; + let webauthn = build_webauthn(&cfg)?; + + let state_json = verify_challenge_token(PURPOSE_CRED_REG, &req.state) + .map_err(|e| ApiError::from(anyhow::anyhow!("Invalid registration state: {}", e)))?; + let reg: PasskeyRegistration = serde_json::from_str(&state_json).map_err(ApiError::internal)?; + + let passkey = webauthn + .finish_passkey_registration(&req.credential, ®) + .map_err(|e| ApiError::from(anyhow::anyhow!("Registration failed: {}", e)))?; + + let cred_id = passkey.cred_id().as_ref().to_vec(); + if this.db.get_webauthn_credential(&cred_id).await.is_ok() { + return Err(ApiError::from(anyhow::anyhow!( + "Credential already registered" + ))); + } + + let uid = this.db.upsert_user(&auth.pubkey()).await?; + let passkey_json = serde_json::to_string(&passkey).map_err(ApiError::internal)?; + let id = this + .db + .insert_webauthn_credential(&WebauthnCredential { + user_id: uid, + cred_id, + passkey: passkey_json, + name: req.name.filter(|s| !s.trim().is_empty()), + ..Default::default() + }) + .await?; + + let created = this + .db + .list_webauthn_credentials(uid) + .await? + .iter() + .find(|c| c.id == id) + .map(WebauthnCredentialInfo::from) + .ok_or_else(|| ApiError::internal("Stored credential not found"))?; + ApiData::ok(created) +} + +/// Remove a passkey from the authenticated account. A pure passkey account may +/// not delete its only credential (that would lock the user out permanently). +async fn delete_credential( + auth: Nip98Auth, + State(this): State, + Path(id): Path, +) -> ApiResult<()> { + webauthn_cfg(&this)?; + let uid = this.db.upsert_user(&auth.pubkey()).await?; + let user = this.db.get_user(uid).await?; + let creds = this.db.list_webauthn_credentials(uid).await?; + + if !creds.iter().any(|c| c.id == id) { + return ApiData::err("Credential not found"); + } + if user.account_type == lnvps_db::AccountType::Webauthn && creds.len() <= 1 { + return ApiData::err("Cannot remove your only passkey"); + } + + this.db.delete_webauthn_credential(id, uid).await?; + ApiData::ok(()) +} + +/// Issue the session JWT response. +fn issue_token(pubkey: &[u8; 32], uid: u64, ttl: u64) -> ApiResult { + let token = issue_session_token(pubkey, uid, ttl) + .map_err(|e| ApiError::internal(format!("Failed to issue session: {}", e)))?; + ApiData::ok(WebauthnTokenResponse { + token, + token_type: "Bearer".to_string(), + expires_in: ttl, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lnvps_api_common::MockDb; + use lnvps_db::LNVpsDb; + use std::sync::Arc; + + fn test_cfg() -> WebauthnConfig { + WebauthnConfig { + rp_id: "example.com".to_string(), + rp_origin: "https://example.com".to_string(), + rp_name: "Example".to_string(), + } + } + + /// A valid relying-party config builds; a bad origin is rejected. + #[test] + fn build_webauthn_validates_origin() { + assert!(build_webauthn(&test_cfg()).is_ok()); + + let mut bad = test_cfg(); + bad.rp_origin = "not a url".to_string(); + assert!(build_webauthn(&bad).is_err()); + } + + /// The credential store round-trips: a passkey account can be minted, its + /// credential inserted, looked up by credential id, listed and updated. + #[tokio::test] + async fn credential_store_roundtrip() { + let db: Arc = Arc::new(MockDb::default()); + + let pubkey = webauthn_pubkey("handle-abc"); + let uid = db.upsert_webauthn_user(&pubkey).await.unwrap(); + // Account is marked as a webauthn account. + assert_eq!( + db.get_user(uid).await.unwrap().account_type, + lnvps_db::AccountType::Webauthn + ); + // Same handle resolves to the same account (idempotent upsert). + assert_eq!(db.upsert_webauthn_user(&pubkey).await.unwrap(), uid); + + let cred_id = vec![1u8, 2, 3, 4]; + let id = db + .insert_webauthn_credential(&WebauthnCredential { + user_id: uid, + cred_id: cred_id.clone(), + passkey: "{\"v\":1}".to_string(), + name: Some("YubiKey".to_string()), + ..Default::default() + }) + .await + .unwrap(); + + let got = db.get_webauthn_credential(&cred_id).await.unwrap(); + assert_eq!(got.user_id, uid); + assert_eq!(got.name.as_deref(), Some("YubiKey")); + + let list = db.list_webauthn_credentials(uid).await.unwrap(); + assert_eq!(list.len(), 1); + + db.update_webauthn_credential(id, "{\"v\":2}") + .await + .unwrap(); + assert_eq!( + db.get_webauthn_credential(&cred_id).await.unwrap().passkey, + "{\"v\":2}" + ); + } + + /// A per-account handle is stable for the same pubkey and differs across + /// accounts. + #[test] + fn account_handle_is_stable_per_account() { + let a = webauthn_pubkey("handle-a"); + let b = webauthn_pubkey("handle-b"); + assert_eq!(account_handle(&a), account_handle(&a)); + assert_ne!(account_handle(&a), account_handle(&b)); + } + + /// Deleting a credential is scoped to its owner and removes only that row. + #[tokio::test] + async fn delete_credential_is_owner_scoped() { + let db: Arc = Arc::new(MockDb::default()); + let uid = db + .upsert_webauthn_user(&webauthn_pubkey("owner")) + .await + .unwrap(); + let other = db + .upsert_webauthn_user(&webauthn_pubkey("intruder")) + .await + .unwrap(); + + let mk = |user_id: u64, cid: Vec| WebauthnCredential { + user_id, + cred_id: cid, + passkey: "{}".to_string(), + ..Default::default() + }; + let id1 = db + .insert_webauthn_credential(&mk(uid, vec![1])) + .await + .unwrap(); + let id2 = db + .insert_webauthn_credential(&mk(uid, vec![2])) + .await + .unwrap(); + + // Another account cannot delete our credential. + db.delete_webauthn_credential(id1, other).await.unwrap(); + assert_eq!(db.list_webauthn_credentials(uid).await.unwrap().len(), 2); + + // Owner can delete their own; only that row is removed. + db.delete_webauthn_credential(id1, uid).await.unwrap(); + let left = db.list_webauthn_credentials(uid).await.unwrap(); + assert_eq!(left.len(), 1); + assert_eq!(left[0].id, id2); + } +} diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index f0c2e62b..c19ff14b 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -113,16 +113,21 @@ async fn main() -> Result<(), Error> { } let db: Arc = Arc::new(db); - // Enable session (Bearer JWT) auth for external OAuth logins when configured. - if let Some(ref oauth) = settings.oauth { - if lnvps_api_common::init_session_secret(oauth.session_secret.as_bytes().to_vec()) { + // Enable session (Bearer JWT) auth, shared by OAuth and WebAuthn logins. + if let Some(ref session) = settings.session { + if lnvps_api_common::init_session_secret(session.secret.as_bytes().to_vec()) { info!( - "OAuth session auth enabled ({} provider(s))", - oauth.providers.len() + "Session (Bearer JWT) auth enabled (oauth={}, webauthn={})", + settings.oauth.is_some(), + settings.webauthn.is_some(), ); } else { - warn!("OAuth configured but session secret was empty or already set"); + warn!("Session secret was empty or already set"); } + } else if settings.oauth.is_some() || settings.webauthn.is_some() { + warn!( + "oauth/webauthn configured but no [session] secret is set — Bearer login is disabled" + ); } let nostr_client = if let Some(ref c) = settings.nostr { @@ -334,7 +339,8 @@ async fn main() -> Result<(), Error> { .merge(ip_space_router()) .merge(referral_router()) .merge(legal_router()) - .merge(oauth_router()); + .merge(oauth_router()) + .merge(webauthn_router()); #[cfg(feature = "openapi")] { diff --git a/lnvps_api/src/settings.rs b/lnvps_api/src/settings.rs index 6b6d7e2d..ce690c86 100644 --- a/lnvps_api/src/settings.rs +++ b/lnvps_api/src/settings.rs @@ -75,20 +75,46 @@ pub struct Settings { /// External OAuth/OIDC login support. When omitted, only Nostr (NIP-98) /// authentication is available. pub oauth: Option, + + /// Passwordless WebAuthn / passkey login. When omitted, passkey endpoints + /// are disabled. Issues the same session JWTs as OAuth. + pub webauthn: Option, + + /// Shared session-token settings. Required when `oauth` or `webauthn` is + /// configured — both issue the same stateless session JWTs. When omitted, + /// `Bearer` session auth is disabled and only Nostr (NIP-98) auth works. + pub session: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] -pub struct OAuthConfig { - /// Secret used to sign stateless session JWTs issued after a successful - /// OAuth login (and the short-lived CSRF `state` values). Must be a strong, - /// stable random string — changing it invalidates all outstanding sessions. - pub session_secret: String, - +pub struct SessionConfig { + /// Secret used to sign session JWTs (and the OAuth CSRF `state` / WebAuthn + /// challenge tokens). Must be a strong, stable random string — changing it + /// invalidates all outstanding sessions. + pub secret: String, /// Session token lifetime in seconds. Defaults to 30 days. #[serde(default = "default_session_ttl")] - pub session_ttl: u64, + pub ttl: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct WebauthnConfig { + /// Relying Party ID — the registrable domain passkeys are bound to (e.g. + /// `app.lnvps.com`). PERMANENT: changing it invalidates every passkey. + pub rp_id: String, + /// Relying Party origin — the exact origin the frontend runs on (e.g. + /// `https://app.lnvps.com`). Its host must equal or be a subdomain of + /// `rp_id`. + pub rp_origin: String, + /// Human-friendly Relying Party name shown in the authenticator UI. + pub rp_name: String, +} +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct OAuthConfig { /// After a successful login the browser is redirected here with the issued /// token appended as `#token=` (fragment). Typically your frontend. /// When omitted, the callback returns the token as JSON instead. @@ -590,6 +616,8 @@ pub fn mock_settings() -> Settings { geoip_database: None, referral: None, oauth: None, + webauthn: None, + session: None, } } diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index e4bff648..575f1dff 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -11,7 +11,7 @@ use lnvps_db::{ Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserPaymentMethod, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmFirewallPolicy, VmFirewallRule, VmHistory, VmHost, VmHostDisk, VmHostKind, - VmHostRegion, VmIpAssignment, VmOsImage, VmTemplate, + VmHostRegion, VmIpAssignment, VmOsImage, VmTemplate, WebauthnCredential, }; use async_trait::async_trait; @@ -56,6 +56,7 @@ pub struct MockDb { pub router_bgp_sessions: Arc>>, pub router_bgp_routes: Arc>>, pub firewall_rules: Arc>>, + pub webauthn_credentials: Arc>>, } impl MockDb { @@ -332,6 +333,7 @@ impl Default for MockDb { router_bgp_sessions: Arc::new(Default::default()), router_bgp_routes: Arc::new(Default::default()), firewall_rules: Arc::new(Default::default()), + webauthn_credentials: Arc::new(Default::default()), } } } @@ -383,6 +385,71 @@ impl LNVpsDbBase for MockDb { } } + async fn upsert_webauthn_user(&self, pubkey: &[u8; 32]) -> DbResult { + let mut users = self.users.lock().await; + if let Some(e) = users.iter().find(|(_k, u)| u.pubkey == *pubkey) { + Ok(*e.0) + } else { + let max = *users.keys().max().unwrap_or(&0); + users.insert( + max + 1, + User { + id: max + 1, + pubkey: pubkey.to_vec(), + account_type: lnvps_db::AccountType::Webauthn, + created: Utc::now(), + country_code: Some("USA".to_string()), + ..Default::default() + }, + ); + Ok(max + 1) + } + } + + async fn insert_webauthn_credential(&self, cred: &WebauthnCredential) -> DbResult { + let mut creds = self.webauthn_credentials.lock().await; + let max = *creds.keys().max().unwrap_or(&0); + let id = max + 1; + let mut stored = cred.clone(); + stored.id = id; + stored.created = Utc::now(); + creds.insert(id, stored); + Ok(id) + } + + async fn list_webauthn_credentials(&self, user_id: u64) -> DbResult> { + let creds = self.webauthn_credentials.lock().await; + Ok(creds + .values() + .filter(|c| c.user_id == user_id) + .cloned() + .collect()) + } + + async fn get_webauthn_credential(&self, cred_id: &[u8]) -> DbResult { + let creds = self.webauthn_credentials.lock().await; + Ok(creds + .values() + .find(|c| c.cred_id == cred_id) + .ok_or(anyhow!("no credential"))? + .clone()) + } + + async fn update_webauthn_credential(&self, id: u64, passkey: &str) -> DbResult<()> { + let mut creds = self.webauthn_credentials.lock().await; + if let Some(c) = creds.get_mut(&id) { + c.passkey = passkey.to_string(); + c.last_used = Some(Utc::now()); + } + Ok(()) + } + + async fn delete_webauthn_credential(&self, id: u64, user_id: u64) -> DbResult<()> { + let mut creds = self.webauthn_credentials.lock().await; + creds.retain(|_, c| !(c.id == id && c.user_id == user_id)); + Ok(()) + } + async fn get_user(&self, id: u64) -> DbResult { let users = self.users.lock().await; Ok(users.get(&id).ok_or(anyhow!("no user"))?.clone()) diff --git a/lnvps_api_common/src/session.rs b/lnvps_api_common/src/session.rs index 59a1e8fe..9e847b96 100644 --- a/lnvps_api_common/src/session.rs +++ b/lnvps_api_common/src/session.rs @@ -195,10 +195,87 @@ pub fn verify_state_token(token: &str) -> Result { Ok(claims.prov) } +/// Claims wrapping an opaque, server-owned challenge state (e.g. a serialised +/// WebAuthn registration/authentication ceremony) so it can round-trip through +/// the client without server-side storage. The `payload` is signed, so the +/// client cannot tamper with the challenge; `purpose` prevents a token minted +/// for one ceremony being replayed into another. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ChallengeClaims { + /// Ceremony tag, e.g. `webauthn-reg` / `webauthn-auth`. + purpose: String, + /// Opaque serialised ceremony state (JSON). + payload: String, + /// Expiry (unix seconds). + exp: u64, +} + +/// Default challenge lifetime (5 minutes) — a WebAuthn ceremony round-trip. +pub const DEFAULT_CHALLENGE_TTL_SECS: u64 = 300; + +/// Issue a signed, short-lived token wrapping an opaque ceremony `payload` under +/// a `purpose` tag. The client echoes it back on the finish step; the server +/// recovers the exact state via [`verify_challenge_token`]. Tamper-proof +/// (HS256), so it is safe to hand server-owned challenge state to the client. +pub fn issue_challenge_token(purpose: &str, payload: &str, ttl_secs: u64) -> Result { + let secret = SESSION_SECRET + .get() + .ok_or_else(|| anyhow::anyhow!("Session auth not configured"))?; + let claims = ChallengeClaims { + purpose: purpose.to_string(), + payload: payload.to_string(), + exp: now_secs() + ttl_secs, + }; + let payload_b64 = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?); + let sig = sign(payload_b64.as_bytes(), secret); + Ok(format!("{payload_b64}.{sig}")) +} + +/// Verify a challenge token, assert its `purpose` matches, check expiry, and +/// return the wrapped ceremony `payload`. +pub fn verify_challenge_token(purpose: &str, token: &str) -> Result { + let secret = SESSION_SECRET + .get() + .ok_or_else(|| anyhow::anyhow!("Session auth not configured"))?; + let mut parts = token.split('.'); + let payload_b64 = parts.next().unwrap_or_default(); + let sig_b64 = parts.next().unwrap_or_default(); + if payload_b64.is_empty() || sig_b64.is_empty() || parts.next().is_some() { + bail!("Malformed challenge token"); + } + let expected_sig = BASE64_URL_SAFE_NO_PAD.decode(sig_b64.as_bytes())?; + let mut mac = Hmac::::new_from_slice(secret).expect("HMAC accepts any key length"); + mac.update(payload_b64.as_bytes()); + mac.verify_slice(&expected_sig) + .map_err(|_| anyhow::anyhow!("Invalid challenge signature"))?; + let claims: ChallengeClaims = + serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(payload_b64.as_bytes())?)?; + if claims.purpose != purpose { + bail!("Challenge purpose mismatch"); + } + if now_secs() >= claims.exp { + bail!("Challenge token expired"); + } + Ok(claims.payload) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn challenge_token_roundtrip() { + init_session_secret(b"unit-test-secret".to_vec()); + let token = + issue_challenge_token("webauthn-reg", "{\"k\":1}", DEFAULT_CHALLENGE_TTL_SECS).unwrap(); + assert_eq!( + verify_challenge_token("webauthn-reg", &token).unwrap(), + "{\"k\":1}" + ); + // Wrong purpose is rejected. + assert!(verify_challenge_token("webauthn-auth", &token).is_err()); + } + #[test] fn state_token_roundtrip() { init_session_secret(b"unit-test-secret".to_vec()); diff --git a/lnvps_db/migrations/20260718004000_webauthn_credentials.sql b/lnvps_db/migrations/20260718004000_webauthn_credentials.sql new file mode 100644 index 00000000..2ec71af0 --- /dev/null +++ b/lnvps_db/migrations/20260718004000_webauthn_credentials.sql @@ -0,0 +1,18 @@ +-- Passwordless WebAuthn / passkey login (account_type = 2). +-- One account may register several credentials (one per device). `passkey` +-- holds the JSON-serialised webauthn-rs Passkey (public key + signature +-- counter) and is the source of truth, re-serialised after each login. +create table user_webauthn_credentials +( + id integer unsigned not null auto_increment primary key, + user_id integer unsigned not null, + cred_id varbinary(1024) not null, + passkey text not null, + name varchar(100), + created timestamp default current_timestamp, + last_used timestamp null, + + constraint fk_webauthn_cred_user foreign key (user_id) references users (id) +); +create unique index ix_webauthn_cred_id on user_webauthn_credentials (cred_id); +create index ix_webauthn_cred_user on user_webauthn_credentials (user_id); diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index ff3ed389..7a24b3ec 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -56,6 +56,27 @@ pub fn oauth_pubkey(provider: &str, subject: &str) -> [u8; 32] { out } +/// Derive the synthetic 32-byte identity used as the `pubkey` for a passwordless +/// WebAuthn/passkey account: `SHA-256("webauthn:{user_handle}")`. +/// +/// The `user_handle` is the stable per-account UUID minted at registration and +/// stored in the credential (returned by the authenticator during discoverable +/// login). Like [`oauth_pubkey`] the result is an opaque identity, NOT a real +/// Nostr key; such accounts are stored with [`model::AccountType::Webauthn`]. +pub fn webauthn_pubkey(user_handle: &str) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + // NUL separator (not the `:` used by `oauth_pubkey`) so the webauthn and + // oauth identity namespaces are provably disjoint: an OAuth provider tag + // cannot contain a NUL byte, so no `(provider, subject)` can collide here. + hasher.update(b"webauthn\0"); + hasher.update(user_handle.as_bytes()); + let result = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&result); + out +} + #[derive(Error, Debug)] pub enum DbError { #[error("sqlx: {0}")] @@ -127,6 +148,28 @@ pub trait LNVpsDbBase: Send + Sync { /// `pubkey` is not a real Nostr key). async fn upsert_oauth_user(&self, pubkey: &[u8; 32]) -> DbResult; + /// Insert/Fetch a passwordless WebAuthn/passkey user by their synthetic + /// identity (see [`webauthn_pubkey`]). Created accounts are marked + /// [`model::AccountType::Webauthn`] and do not opt into NIP-17 DMs. + async fn upsert_webauthn_user(&self, pubkey: &[u8; 32]) -> DbResult; + + /// Store a newly-registered WebAuthn credential; returns the new row id. + async fn insert_webauthn_credential(&self, cred: &WebauthnCredential) -> DbResult; + + /// List all WebAuthn credentials belonging to a user. + async fn list_webauthn_credentials(&self, user_id: u64) -> DbResult>; + + /// Look up a single WebAuthn credential by its raw credential id. + async fn get_webauthn_credential(&self, cred_id: &[u8]) -> DbResult; + + /// Persist an updated passkey blob (e.g. bumped signature counter) and mark + /// the credential as just used. + async fn update_webauthn_credential(&self, id: u64, passkey: &str) -> DbResult<()>; + + /// Delete a WebAuthn credential owned by `user_id`. Scoped by user so a + /// caller can never remove another account's credential. + async fn delete_webauthn_credential(&self, id: u64, user_id: u64) -> DbResult<()>; + /// Get a user by id async fn get_user(&self, id: u64) -> DbResult; @@ -903,4 +946,26 @@ mod tests { assert!(!DbError::Unknown.is_row_not_found()); assert!(!DbError::SqlxError(sqlx::Error::PoolClosed).is_row_not_found()); } + + #[test] + fn test_webauthn_pubkey_stable_and_distinct() { + // Deterministic for the same handle. + let a = webauthn_pubkey("11111111-2222-3333-4444-555555555555"); + let b = webauthn_pubkey("11111111-2222-3333-4444-555555555555"); + assert_eq!(a, b); + // Different handles differ. + assert_ne!(a, webauthn_pubkey("00000000-0000-0000-0000-000000000000")); + // Namespaced away from oauth identities sharing the same string. + assert_ne!( + a, + oauth_pubkey("webauthn", "11111111-2222-3333-4444-555555555555") + ); + } + + #[test] + fn test_account_type_display() { + assert_eq!(model::AccountType::Nostr.to_string(), "nostr"); + assert_eq!(model::AccountType::OAuth.to_string(), "oauth"); + assert_eq!(model::AccountType::Webauthn.to_string(), "webauthn"); + } } diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 3d8c2c77..de88169d 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -22,6 +22,11 @@ pub enum AccountType { /// primary identity — it is NOT a real Nostr key and must never be treated /// as one (no NIP-17, no npub, no signing). OAuth = 1, + /// Passwordless WebAuthn / passkey account. `pubkey` is a synthetic + /// `sha256("webauthn:{user_handle}")` identifier derived from the + /// credential's user handle — like [`AccountType::OAuth`] it is NOT a real + /// Nostr key. The account's login factors live in `user_webauthn_credentials`. + Webauthn = 2, } impl Display for AccountType { @@ -29,6 +34,7 @@ impl Display for AccountType { match self { AccountType::Nostr => write!(f, "nostr"), AccountType::OAuth => write!(f, "oauth"), + AccountType::Webauthn => write!(f, "webauthn"), } } } @@ -154,6 +160,27 @@ pub struct UserSshKey { pub key_data: EncryptedString, } +/// A registered WebAuthn / passkey credential belonging to a +/// [`AccountType::Webauthn`] account. One account may have several (e.g. one +/// per device). +#[derive(FromRow, Clone, Debug, Default)] +pub struct WebauthnCredential { + pub id: u64, + /// Owning user id. + pub user_id: u64, + /// Raw credential id bytes (unique across all accounts). + pub cred_id: Vec, + /// JSON-serialised `webauthn_rs::prelude::Passkey`. This is the source of + /// truth for the credential's public key and signature counter; it is + /// re-serialised after each successful authentication. + pub passkey: String, + /// Optional user-facing label for the credential/device. + pub name: Option, + pub created: DateTime, + /// When the credential was last used to authenticate. + pub last_used: Option>, +} + #[derive(FromRow, Clone, Debug, Default)] pub struct AdminUserInfo { #[sqlx(flatten)] diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 31ef9578..fcd9787d 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -6,7 +6,7 @@ use crate::{ SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserPaymentMethod, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmFirewallPolicy, VmFirewallRule, VmHistory, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, - VmTemplate, + VmTemplate, WebauthnCredential, }; #[cfg(feature = "admin")] use crate::{AdminDb, AdminRole, AdminRoleAssignment, AdminVmHost}; @@ -82,6 +82,76 @@ impl LNVpsDbBase for LNVpsDbMysql { }) } + async fn upsert_webauthn_user(&self, pubkey: &[u8; 32]) -> DbResult { + // account_type=2 (Webauthn), contact_nip17=0 — the synthetic pubkey is + // not a real Nostr key so NIP-17 DMs must not be attempted. + let res = sqlx::query( + "insert ignore into users(pubkey,contact_nip17,account_type) values(?,0,2) returning id", + ) + .bind(pubkey.as_slice()) + .fetch_optional(&self.db) + .await?; + Ok(match res { + None => sqlx::query("select id from users where pubkey = ?") + .bind(pubkey.as_slice()) + .fetch_one(&self.db) + .await? + .try_get(0)?, + Some(res) => res.try_get(0)?, + }) + } + + async fn insert_webauthn_credential(&self, cred: &WebauthnCredential) -> DbResult { + Ok(sqlx::query( + "insert into user_webauthn_credentials(user_id,cred_id,passkey,name) values(?,?,?,?) returning id", + ) + .bind(cred.user_id) + .bind(&cred.cred_id) + .bind(&cred.passkey) + .bind(&cred.name) + .fetch_one(&self.db) + .await? + .try_get(0)?) + } + + async fn list_webauthn_credentials(&self, user_id: u64) -> DbResult> { + Ok( + sqlx::query_as("select * from user_webauthn_credentials where user_id=?") + .bind(user_id) + .fetch_all(&self.db) + .await?, + ) + } + + async fn get_webauthn_credential(&self, cred_id: &[u8]) -> DbResult { + Ok( + sqlx::query_as("select * from user_webauthn_credentials where cred_id=?") + .bind(cred_id) + .fetch_one(&self.db) + .await?, + ) + } + + async fn update_webauthn_credential(&self, id: u64, passkey: &str) -> DbResult<()> { + sqlx::query( + "update user_webauthn_credentials set passkey=?, last_used=current_timestamp where id=?", + ) + .bind(passkey) + .bind(id) + .execute(&self.db) + .await?; + Ok(()) + } + + async fn delete_webauthn_credential(&self, id: u64, user_id: u64) -> DbResult<()> { + sqlx::query("delete from user_webauthn_credentials where id=? and user_id=?") + .bind(id) + .bind(user_id) + .execute(&self.db) + .await?; + Ok(()) + } + async fn get_user(&self, id: u64) -> DbResult { Ok(sqlx::query_as("select * from users where id=?") .bind(id)