From 054f56723fa99f2fc6b0af27739f26219f96a25c Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Sun, 8 Nov 2026 19:16:36 +0100 Subject: [PATCH 01/24] docs: ADR 0014 push sign-in approval, accepted; plan M14 --- docs/adr/0014-push-mfa.md | 69 +++++++++++++++++++++++++++++++++++++++ docs/plan.md | 1 + 2 files changed, 70 insertions(+) create mode 100644 docs/adr/0014-push-mfa.md diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md new file mode 100644 index 0000000..c4a350d --- /dev/null +++ b/docs/adr/0014-push-mfa.md @@ -0,0 +1,69 @@ +# ADR 0014: Push sign-in approval with the RustMFA app + +- Status: **Accepted** by the project owner on 2026-11-08 as proposed (approval point 3 granted) +- Date: 2026-11-08 +- Inputs: [ADR 0003](0003-app-passwords-and-login-flow.md), [ADR 0007](0007-two-factor-authentication.md), [ADR 0008](0008-metadata-database-turso.md); the RustMFA design spec (`rostdav-mfa` repository, `docs/superpowers/specs/2026-11-08-rustmfa-android-design.md`), the machine-readable contract (`contract/openapi/rustmfa-v1.yaml`) and its test vectors (`contract/vectors/v1.json`). + +## Context + +Browser sign-in can already use a TOTP code as second factor (ADR 0007). The owner builds a self-hosted authenticator app, RustMFA (Android first), for this server only. The app should let a user approve a sign-in on the phone: the server sends a hint, the user compares a number and confirms with biometrics, the app returns a signed answer. Microsoft and Google Authenticator cannot do this for a custom server, so an own app and an own device API are needed. + +This ADR covers the server side: devices, enrollment, challenges and the HTTP API. The sign-in pages, push delivery (FCM, later UnifiedPush) and the device pages of the web interface follow in a second step and amend this ADR if they need decisions not made here. + +## Decision + +### What the user gets + +- A user enrolls a phone: in the web interface they create an enrollment QR code (single use, 5 minutes). The RustMFA app scans it and registers the phone. Several phones per user are possible. A phone can be revoked at any time, from the web interface or from another enrolled phone. +- A sign-in with a password-correct step 1 then offers "approve on your phone": the page shows a two-digit number; the app shows three numbers; the user picks the matching one and confirms with biometrics. A wrong number denies the request. A code from an authenticator app stays available as an alternative. +- Push is only a hint that a request is pending. Access is granted only after a valid signature over that one challenge. + +### Rules + +- **Device keys.** Each phone holds two ES256 (P-256) keys: a `request_key` that signs every API request, and a biometric-gated `approval_key` that signs challenge answers. The server stores only the public keys (base64 of the X.509 SubjectPublicKeyInfo DER). +- **Enrollment.** Enroll token: 256 random bits, stored only as a hash (`secrets::hash_token`), single use, 5 minutes, bound to the creating user. Registration uses the token and a **key attestation** chain for the approval key. The attestation challenge is the SHA-256 of the enroll token. By default the chain must verify to a pinned Google attestation root and show a hardware-backed key (TEE or StrongBox); software-only keys are rejected. `[mfa] attestation = "off"` allows custom ROMs and is documented with its trade-off. A token is consumed before the chain is checked, so a failed attempt burns the token. +- **Request authentication.** Header `Authorization: RustMFA-Sig device_id="…",ts="…",nonce="…",sig="…"`. Values are limited to `[A-Za-z0-9_-]` and 128 bytes. `sig` is a raw 64-byte R‖S signature, base64url, over `METHOD \n path-with-query \n ts \n nonce \n base64url(sha256(body))`. The server rejects a timestamp more than 60 seconds off and any nonce seen within 5 minutes for that device. A nonce is recorded only after the signature verified. Five failures per device in 15 minutes lock that device's requests for 15 minutes (existing limiter style). +- **Challenge.** Created in process by the sign-in code for one explicit device (the most recently seen active one). 90 seconds by default (`[mfa] challenge_ttl_seconds`, 10 to 300). Contains a 128-bit nonce, a two-digit number (10 to 99) and two decoys. The device receives the three candidates but never a marker of the right one. +- **Answer.** The approve and deny requests carry a raw R‖S signature by the `approval_key` over the canonical message `v1 \n challenge_id \n nonce \n login_id \n device_id \n expires_at \n audience` (exact format and test vectors in the contract). The server checks: device active and owner of the challenge; challenge pending and not expired; number matches (a mismatch resolves the challenge to `denied`); signature valid. Only then one conditional `UPDATE … WHERE status = 'pending' AND expires_at > now` consumes the challenge, so two simultaneous answers cannot both win. A bad signature never consumes a challenge. +- **No existence leaks.** Another user's challenge or device is `404`; another device of the same user is `403`. +- **Errors.** One format `{"error":{"code","message"}}`, codes as in the contract. No secrets, signatures, push tokens or keys in logs or errors. +- **Audit.** `mfa.device_registered`, `mfa.device_revoked`, `mfa.key_rotated`, `mfa.challenge_created`, `mfa.challenge_approved`, `mfa.challenge_denied`, `mfa.number_mismatch`, `mfa.challenge_expired`. +- **Join with ADR 0007.** A user has a second factor when TOTP is enabled **or** at least one active device exists. Step 2 accepts either. Rule of ADR 0007 extended accordingly: with a second factor active, Basic authentication with the account password on WebDAV and OCS is rejected, app passwords still work. This changes a rule of ADR 0007 and needs the same approval. + +### Storage + +Migration `0013_push_mfa.sql` with four `STRICT` tables, a plain index on every foreign key column (see the database notes in `CLAUDE.md`), cascading deletes from `users`: + +- `mfa_devices(id, user_id, name, status active|revoked, request_pub, approval_pub, push_channel, push_token, hardware_backed, strongbox, created_at, last_seen_at, revoked_at)` +- `mfa_enroll_tokens(token_hash, user_id, expires_at, used_at)` +- `mfa_challenges(id, user_id, device_id, login_ref, nonce, number, candidates, audience, created_at, expires_at, status pending|approved|denied|expired, consumed_at)` +- `mfa_nonces(device_id, nonce, expires_at)`, purged opportunistically. + +### Structure + +- Logic in a `push_mfa` module of `rostdav-app`, reachable only through `AppState` methods; authorization stays in `rostdav-app`. +- HTTP API (10 device operations under `/api/v1`) in a new protocol crate `rostdav-mfa-api` that depends on `rostdav-app` and `rostdav-core` only. `architecture.md` gets the new row. +- The eleventh contract operation, the enroll-token route, lives in `rostdav-web` because it needs the session and CSRF protection. +- `PushChannel` trait in `rostdav-app`; delivery adapters follow in the second step. + +### Dependencies (justification) + +- `rustmfa-contract` (own crate, `rostdav-mfa` repository): signing formats and verification shared with the app and its test vectors. **Decision:** git dependency pinned by revision. Until that repository is published, development uses a local `[patch]` in an uncommitted `.cargo/config.toml`; the dependency is committed only once the repository exists. +- `x509-cert` and `der` (RustCrypto, MIT/Apache): parse the attestation chain and the Android key description. +- `rcgen` (dev only): synthetic attestation chains in tests. +- Android attestation root certificates are pinned in the source. They are fetched from Google's published key-attestation documentation at implementation time, with the source address and each SHA-256 fingerprint recorded in this ADR before the code merges. + +### Out of scope + +- FCM and UnifiedPush adapters, the sign-in page changes, the device pages in the web interface (second step). +- Number matching bypass, "remember this browser", approving sign-ins to other services. +- Attestation of key rotation beyond the rule below, remote attestation of boot state (`verifiedBootState`). +- Key rotation attestation: the rotation request carries a fresh attestation chain whose challenge is the SHA-256 of `"rotate:" + device_id + ":" + request_pub`. + +## Consequences + +- Sign-in is the most security-sensitive code. Acceptance criteria are tests for: concurrent approve (one winner), nonce replay and window, wrong device and wrong user, revoked device with a pending challenge, enroll token reuse/expiry/other user, software-only and wrong-challenge attestation, DER signature rejected, altered signed fields rejected, error table, no secrets in logs. +- Attestation logic is verified with synthetic chains first. A real-device chain fixture is added once the Android app exists, and recorded in `docs/compatibility.md`. +- A user whose only phone is lost can revoke it from the web interface after signing in with the password and a TOTP code, or needs an administrator if neither is available. +- Google's attestation roots rotate. A new root needs a release, and `attestation = "off"` is the escape hatch. +- Contract changes need coordinated releases of the server and the app. The versioned test vectors detect drift. diff --git a/docs/plan.md b/docs/plan.md index 592319a..c5383c6 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -71,6 +71,7 @@ Prioritized by smallest testable benefit. Every milestone ends with green tests | M11 | Web interface: files, devices, settings, administration, light/dark ([ADR 0006](adr/0006-web-interface.md)). **11a ✅** (sign-in, Files, Trash, Devices, Settings, public link page); **11b (share dialog, versions panel, details, folder filter, chunked browser upload) ✅**; **11c administration (users, event log, status/fsck, server settings) ✅**, **11d: profile, change password, data export, languages (de/en) ✅** | M7–M10 | | M12 ✅ | Optional two-factor authentication (TOTP + recovery codes; [ADR 0007](adr/0007-two-factor-authentication.md), 2026-10-22) | M11 | | M13 ✅ | Retention rules, data export (2026-10-22; the export is streamed as a tar archive instead of a background job with a download link, see [ui.md](ui.md)) | M8, M10 | +| M14 | Push sign-in approval with the RustMFA app: device enrollment, signed challenges, device API ([ADR 0014](adr/0014-push-mfa.md), accepted 2026-11-08, approval point 3 granted) | M12 | ## Phase 5: Hardening and delivery From 9ee517396131b886581abd04f7bf9d4c88bdb670 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Sun, 8 Nov 2026 19:43:50 +0100 Subject: [PATCH 02/24] feat(mfa): [mfa] settings and AppState wiring (ADR 0014) --- CHANGELOG.md | 4 + crates/rostdav-app/src/lib.rs | 9 ++ crates/rostdav-app/src/push_mfa/mod.rs | 83 ++++++++++++++++++ crates/rostdav/src/config.rs | 114 +++++++++++++++++++++++++ crates/rostdav/src/lib.rs | 9 ++ docs/admin.md | 13 +++ rostdav.example.toml | 12 +++ 7 files changed, 244 insertions(+) create mode 100644 crates/rostdav-app/src/push_mfa/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e4c1f..154cba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes are listed here. The format follows [Keep a Changelog](https ## [Unreleased] +### Added + +- **Settings for push sign-in approval** (`[mfa]` section: challenge lifetime, enrollment lifetime, audience, attestation policy), the first step of ADR 0014. They have no effect yet. + ## [0.10.0] - 2026-10-25 ### Changed diff --git a/crates/rostdav-app/src/lib.rs b/crates/rostdav-app/src/lib.rs index ca4ed21..3648338 100644 --- a/crates/rostdav-app/src/lib.rs +++ b/crates/rostdav-app/src/lib.rs @@ -19,6 +19,7 @@ mod files; mod indexer; mod login_flow; mod password; +mod push_mfa; mod ratelimit; mod search; #[cfg(test)] @@ -55,6 +56,7 @@ pub use indexer::{PassReport, SearchStatus}; pub use login_flow::FlowStep; pub use login_flow::{Granted, StartedFlow}; pub use password::{MIN_PASSWORD_CHARS, hash_password, verify_password}; +pub use push_mfa::{AttestationPolicy, MfaConfig}; pub use search::{KindFilter, MAX_VISITED, SearchHit, SearchPage, SearchQuery, SnippetPart}; pub use shares::{ LINK_TOKEN_LEN, ShareChange, ShareCreate, ShareError, ShareFilter, ShareView, Viewer, @@ -124,6 +126,7 @@ struct AppConfig { extractor: Option, limits: Limits, audit: AuditOptions, + mfa: MfaConfig, versioning: VersioningOptions, trash: TrashOptions, web: WebOptions, @@ -363,6 +366,12 @@ impl AppState { self.reconfigured(config) } + #[must_use] + pub fn with_mfa(self, mfa: MfaConfig) -> Self { + let config = AppConfig { mfa, ..self.cfg() }; + self.reconfigured(config) + } + /// Readiness check: the database responds and the data directory is writable. pub async fn readiness(&self) -> Result<()> { let (db, store) = tokio::join!(self.inner.db.ping(), self.inner.store.check_writable()); diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs new file mode 100644 index 0000000..9481665 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -0,0 +1,83 @@ +//! Push sign-in approval with the RustMFA app (ADR 0014). + +use crate::AppState; + +/// Whether a registering phone must prove a hardware-backed key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AttestationPolicy { + /// The attestation chain must verify to a pinned Google root and show a TEE or StrongBox key. + Required, + /// No attestation check (custom ROMs); documented with its trade-off. + Off, +} + +/// Settings of the push sign-in approval. +#[derive(Clone, Debug)] +pub struct MfaConfig { + /// How long a challenge can be answered. + pub challenge_ttl_secs: i64, + /// How long an enrollment token is valid. + pub enroll_ttl_secs: i64, + /// Service identifier that is part of every signed challenge message. + pub audience: String, + pub attestation: AttestationPolicy, +} + +impl Default for MfaConfig { + fn default() -> Self { + Self { + challenge_ttl_secs: 90, + enroll_ttl_secs: 300, + audience: "rostdav".into(), + attestation: AttestationPolicy::Required, + } + } +} + +impl AppState { + /// The settings in force (a copy, like the other settings). + pub fn mfa_cfg(&self) -> MfaConfig { + self.cfg().mfa + } +} + +#[cfg(test)] +mod tests { + use crate::{AppState, AttestationPolicy, MfaConfig}; + use rostdav_meta::Db; + use rostdav_storage::LocalStore; + + async fn state() -> (tempfile::TempDir, AppState) { + let dir = tempfile::tempdir().expect("tempdir"); + let store = LocalStore::open(dir.path().join("data")) + .await + .expect("store"); + let db = Db::open(&Db::default_url(store.root())).await.expect("db"); + db.migrate().await.expect("migrate"); + (dir, AppState::new(db, store).await.expect("state")) + } + + #[tokio::test] + async fn a_new_state_has_the_designed_defaults() { + let (_d, st) = state().await; + let c = st.mfa_cfg(); + assert_eq!(c.challenge_ttl_secs, 90); + assert_eq!(c.enroll_ttl_secs, 300); + assert_eq!(c.audience, "rostdav"); + assert_eq!(c.attestation, AttestationPolicy::Required); + } + + #[tokio::test] + async fn with_mfa_replaces_the_settings() { + let (_d, st) = state().await; + let st = st.with_mfa(MfaConfig { + challenge_ttl_secs: 30, + enroll_ttl_secs: 120, + audience: "x".into(), + attestation: AttestationPolicy::Off, + }); + let c = st.mfa_cfg(); + assert_eq!((c.challenge_ttl_secs, c.enroll_ttl_secs), (30, 120)); + assert_eq!(c.attestation, AttestationPolicy::Off); + } +} diff --git a/crates/rostdav/src/config.rs b/crates/rostdav/src/config.rs index 3f22f22..2786eed 100644 --- a/crates/rostdav/src/config.rs +++ b/crates/rostdav/src/config.rs @@ -25,6 +25,7 @@ pub struct Config { pub search: Search, pub web: Web, pub security: Security, + pub mfa: Mfa, pub log: Log, /// Keys (`section.name`) that the file or an environment variable set. The web interface /// shows those as "set by configuration" and does not let an administrator change them. @@ -61,6 +62,61 @@ pub struct Security { pub totp_key_file: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum MfaAttestation { + /// Phones must prove a hardware-backed key (default). + Required, + /// No check; for custom ROMs, with the trade-off described in `docs/admin.md`. + Off, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct Mfa { + /// Seconds a sign-in challenge can be answered (10 to 300). + pub challenge_ttl_seconds: u32, + /// Seconds an enrollment QR code stays valid (60 to 900). + pub enroll_ttl_seconds: u32, + /// Service identifier signed into every challenge (1 to 64 of `A-Za-z0-9._-`). + pub audience: String, + /// Whether phones must attest a hardware-backed key. + pub attestation: MfaAttestation, +} + +impl Default for Mfa { + fn default() -> Self { + Self { + challenge_ttl_seconds: 90, + enroll_ttl_seconds: 300, + audience: "rostdav".into(), + attestation: MfaAttestation::Required, + } + } +} + +impl Mfa { + fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!( + (10..=300).contains(&self.challenge_ttl_seconds), + "mfa.challenge_ttl_seconds must be between 10 and 300" + ); + anyhow::ensure!( + (60..=900).contains(&self.enroll_ttl_seconds), + "mfa.enroll_ttl_seconds must be between 60 and 900" + ); + anyhow::ensure!( + (1..=64).contains(&self.audience.len()) + && self + .audience + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')), + "mfa.audience must be 1 to 64 characters of A-Z a-z 0-9 . _ -" + ); + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct Web { @@ -276,6 +332,7 @@ impl Config { } figment = figment.merge(Env::prefixed("ROSTDAV_").split("__")); let mut cfg: Config = figment.extract()?; + cfg.mfa.validate()?; // the same sources without the defaults layer: what is in there was set on purpose let mut given = Figment::new(); match explicit_file { @@ -306,6 +363,63 @@ mod tests { assert_eq!(cfg.log.format, LogFormat::Json); } + #[test] + fn mfa_defaults_match_the_design() { + let m = Config::default().mfa; + assert_eq!(m.challenge_ttl_seconds, 90); + assert_eq!(m.enroll_ttl_seconds, 300); + assert_eq!(m.audience, "rostdav"); + assert_eq!(m.attestation, MfaAttestation::Required); + } + + #[test] + fn mfa_section_overrides_defaults() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "m.toml", + "[mfa]\nchallenge_ttl_seconds = 60\nattestation = \"off\"\n", + )?; + let cfg = Config::load(Some(Path::new("m.toml"))).map_err(|e| e.to_string())?; + assert_eq!(cfg.mfa.challenge_ttl_seconds, 60); + assert_eq!(cfg.mfa.attestation, MfaAttestation::Off); + assert_eq!(cfg.mfa.enroll_ttl_seconds, 300); + Ok(()) + }); + } + + #[test] + fn mfa_rejects_values_out_of_range() { + let cases = [ + ("challenge_ttl_seconds = 9", "mfa.challenge_ttl_seconds"), + ("challenge_ttl_seconds = 301", "mfa.challenge_ttl_seconds"), + ("enroll_ttl_seconds = 59", "mfa.enroll_ttl_seconds"), + ("enroll_ttl_seconds = 901", "mfa.enroll_ttl_seconds"), + ("audience = \"\"", "mfa.audience"), + ("audience = \"a b\"", "mfa.audience"), + ("audience = \"a\\nb\"", "mfa.audience"), + ]; + for (line, key) in cases { + figment::Jail::expect_with(|jail| { + jail.create_file("m.toml", &format!("[mfa]\n{line}\n"))?; + let err = Config::load(Some(Path::new("m.toml"))).expect_err(line); + assert!(err.to_string().contains(key), "{line}: {err}"); + Ok(()) + }); + } + } + + #[test] + fn mfa_accepts_the_range_limits() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "m.toml", + "[mfa]\nchallenge_ttl_seconds = 10\nenroll_ttl_seconds = 900\naudience = \"a.b_c-1\"\n", + )?; + Config::load(Some(Path::new("m.toml"))).map_err(|e| e.to_string())?; + Ok(()) + }); + } + #[test] fn file_and_env_precedence() { figment::Jail::expect_with(|jail| { diff --git a/crates/rostdav/src/lib.rs b/crates/rostdav/src/lib.rs index 8c3aca5..f866b95 100644 --- a/crates/rostdav/src/lib.rs +++ b/crates/rostdav/src/lib.rs @@ -61,6 +61,15 @@ pub async fn build_state(cfg: &config::Config) -> anyhow::Result { anonymize_ip: cfg.audit.anonymize_ip, retention_days: cfg.audit.retention_days, }) + .with_mfa(rostdav_app::MfaConfig { + challenge_ttl_secs: i64::from(cfg.mfa.challenge_ttl_seconds), + enroll_ttl_secs: i64::from(cfg.mfa.enroll_ttl_seconds), + audience: cfg.mfa.audience.clone(), + attestation: match cfg.mfa.attestation { + config::MfaAttestation::Required => rostdav_app::AttestationPolicy::Required, + config::MfaAttestation::Off => rostdav_app::AttestationPolicy::Off, + }, + }) // what the file and the environment leave open can be changed in the web interface .with_locked_settings(cfg.set_explicitly.iter().cloned()) .load_setting_overrides() diff --git a/docs/admin.md b/docs/admin.md index f474753..e9779b6 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -129,6 +129,19 @@ Users turn on TOTP (authenticator app) themselves under Devices & security; see - **Lockout.** Five wrong codes lock the second step for that user for 15 minutes. A user who lost the authenticator and the recovery codes needs `rostdav admin user reset-2fa ` or Administration › Users › Manage › Reset two-factor authentication. - **Event log:** `auth.2fa_enabled`, `auth.2fa_disabled`, `auth.2fa_codes_regenerated`, `admin.2fa_reset`, failed codes as `auth.login_failed` with `second_factor`. Codes and secrets are never logged. +## Push sign-in approval (RustMFA) + +Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa.md)). The feature is being built; so far only the settings exist. + +| Key | Default | Meaning | +|---|---|---| +| `mfa.challenge_ttl_seconds` | 90 | Seconds a sign-in challenge can be answered (10 to 300). | +| `mfa.enroll_ttl_seconds` | 300 | Seconds an enrollment QR code stays valid (60 to 900). | +| `mfa.audience` | `rostdav` | Service identifier signed into every challenge: 1 to 64 characters of `A-Z a-z 0-9 . _ -`. Changing it invalidates challenges in flight. | +| `mfa.attestation` | `required` | `required`: a phone must prove a hardware-backed key at registration. `off`: no check, for custom ROMs without Google key attestation. With `off`, a stolen enrollment QR code is enough to register software keys, so keep the enrollment time short. | + +A value outside its range stops the server at start-up with a message naming the key. + ## Version check Off by default. Administration, Server, "Updates" turns it on: the server then asks the GitHub releases API once a day (and on "Check now") which version is the newest, and shows a newer one with its release notes. The request carries only `User-Agent: rostdav/`. While it is off, the server makes no outbound connection. After an error the server tries again in an hour; the page shows the reason. The switch is kept in the database, so a restart keeps it. Events: `admin.update_check_on`, `admin.update_check_off`, `admin.update_installed`. The About page (every user) shows the release notes of the running build and the versions before it; they are compiled in and need no network. diff --git a/rostdav.example.toml b/rostdav.example.toml index 8cc4a4d..7e23c29 100644 --- a/rostdav.example.toml +++ b/rostdav.example.toml @@ -59,6 +59,18 @@ data_dir = "./data" # when the first user starts a two-factor setup. Back it up with the data. # totp_key_file = "/var/lib/rostdav/totp.key" +[mfa] +# Push sign-in approval with the RustMFA app (ADR 0014). +# Seconds a sign-in challenge can be answered (10 to 300, default 90). +# challenge_ttl_seconds = 90 +# Seconds an enrollment QR code stays valid (60 to 900, default 300). +# enroll_ttl_seconds = 300 +# Service identifier signed into every challenge (A-Z a-z 0-9 . _ -, up to 64 characters). +# audience = "rostdav" +# "required": a phone must prove a hardware-backed key when it registers. +# "off": no check, for custom ROMs without Google key attestation. +# attestation = "required" + [limits] # Maximum size of a single upload in bytes (default: 16 GiB). # max_upload_bytes = 17179869184 From 2af311bd4bd2426920e174f0e797131fbdc47190 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Sun, 8 Nov 2026 19:55:23 +0100 Subject: [PATCH 03/24] feat(mfa): schema and repository (ADR 0014) --- CHANGELOG.md | 1 + .../rostdav-meta/migrations/0013_push_mfa.sql | 62 ++ crates/rostdav-meta/src/lib.rs | 7 + crates/rostdav-meta/src/push_mfa.rs | 735 ++++++++++++++++++ docs/architecture.md | 4 + 5 files changed, 809 insertions(+) create mode 100644 crates/rostdav-meta/migrations/0013_push_mfa.sql create mode 100644 crates/rostdav-meta/src/push_mfa.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 154cba2..e286b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https ### Added - **Settings for push sign-in approval** (`[mfa]` section: challenge lifetime, enrollment lifetime, audience, attestation policy), the first step of ADR 0014. They have no effect yet. +- **Database tables for push sign-in approval** (migration 0013: phones, enrollment tokens, challenges, request nonces). Nothing uses them yet. ## [0.10.0] - 2026-10-25 diff --git a/crates/rostdav-meta/migrations/0013_push_mfa.sql b/crates/rostdav-meta/migrations/0013_push_mfa.sql new file mode 100644 index 0000000..bb9d3da --- /dev/null +++ b/crates/rostdav-meta/migrations/0013_push_mfa.sql @@ -0,0 +1,62 @@ +-- M14: push sign-in approval with the RustMFA app (ADR 0014). +-- +-- Every foreign key column has a plain index (see 0012 and the schema test in `lib.rs`). + +-- A phone of a user. The server keeps only public keys (base64 of the X.509 SubjectPublicKeyInfo DER): +-- `request_pub` verifies request signatures, `approval_pub` verifies challenge answers. +CREATE TABLE mfa_devices ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')), + request_pub TEXT NOT NULL, + approval_pub TEXT NOT NULL, + push_channel TEXT NOT NULL CHECK (push_channel IN ('fcm', 'unifiedpush')), + push_token TEXT NOT NULL, + hardware_backed INTEGER NOT NULL, + strongbox INTEGER NOT NULL, + created_at INTEGER NOT NULL, + last_seen_at INTEGER, + revoked_at INTEGER +) STRICT; +CREATE INDEX mfa_devices_user ON mfa_devices(user_id); + +-- Single-use enrollment tokens. Only the hash is stored. +CREATE TABLE mfa_enroll_tokens ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + used_at INTEGER +) STRICT; +CREATE INDEX mfa_enroll_tokens_user ON mfa_enroll_tokens(user_id); + +-- One sign-in approval request for one phone. `login_ref` ties it to the sign-in that asked. +-- `candidates` is a JSON array of the three numbers shown on the phone, `number` the right one. +CREATE TABLE mfa_challenges ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_id TEXT NOT NULL REFERENCES mfa_devices(id) ON DELETE CASCADE, + login_ref TEXT NOT NULL, + nonce TEXT NOT NULL, + number INTEGER NOT NULL CHECK (number BETWEEN 10 AND 99), + candidates TEXT NOT NULL, + audience TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'approved', 'denied', 'expired')), + consumed_at INTEGER +) STRICT; +CREATE INDEX mfa_challenges_user ON mfa_challenges(user_id); +CREATE INDEX mfa_challenges_device ON mfa_challenges(device_id, status); +CREATE INDEX mfa_challenges_login ON mfa_challenges(login_ref); + +-- Request nonces seen within the replay window. +CREATE TABLE mfa_nonces ( + device_id TEXT NOT NULL REFERENCES mfa_devices(id) ON DELETE CASCADE, + nonce TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (device_id, nonce) +) STRICT; +CREATE INDEX mfa_nonces_device ON mfa_nonces(device_id); +CREATE INDEX mfa_nonces_expiry ON mfa_nonces(expires_at); diff --git a/crates/rostdav-meta/src/lib.rs b/crates/rostdav-meta/src/lib.rs index 0f6872d..251e454 100644 --- a/crates/rostdav-meta/src/lib.rs +++ b/crates/rostdav-meta/src/lib.rs @@ -8,6 +8,7 @@ mod auth; mod avatars; mod favorites; mod nodes; +mod push_mfa; mod search_index; mod shares; mod trash; @@ -21,6 +22,7 @@ pub use auth::{ }; pub use avatars::StoredAvatar; pub use nodes::{CommitOpts, NewBlob, Node, NodeKind, VersionPolicy, WriteCondition, Written}; +pub use push_mfa::{ChallengeRow, DeviceRow, NewChallenge, NewDevice}; pub use search_index::{ Candidate, Content, DocFacts, FORMAT_VERSION as SEARCH_FORMAT_VERSION, IndexStatus, MAX_FAILS, NodeFacts, Pending, SearchIndex, state as search_state, @@ -78,6 +80,11 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ "cascade_indexes", include_str!("../migrations/0012_cascade_indexes.sql"), ), + ( + 13, + "push_mfa", + include_str!("../migrations/0013_push_mfa.sql"), + ), ]; /// The metadata database (Turso Database, SQLite file format): one writer, a few readers. diff --git a/crates/rostdav-meta/src/push_mfa.rs b/crates/rostdav-meta/src/push_mfa.rs new file mode 100644 index 0000000..971d26c --- /dev/null +++ b/crates/rostdav-meta/src/push_mfa.rs @@ -0,0 +1,735 @@ +//! Storage for push sign-in approval (ADR 0014): enrollment tokens, phones, challenges and +//! request nonces. Nothing here knows how signatures are checked. + +use rostdav_core::{Error, Result}; + +use crate::Db; +use crate::sql; +use crate::users::now_secs; + +fn err(e: sql::Error) -> Error { + Error::internal(e) +} + +/// A phone to register. +pub struct NewDevice<'a> { + pub id: &'a str, + pub user_id: i64, + pub name: &'a str, + pub request_pub: &'a str, + pub approval_pub: &'a str, + pub push_channel: &'a str, + pub push_token: &'a str, + pub hardware_backed: bool, + pub strongbox: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeviceRow { + pub id: String, + pub user_id: i64, + pub name: String, + /// `active` or `revoked`. + pub status: String, + pub request_pub: String, + pub approval_pub: String, + pub push_channel: String, + pub push_token: String, + pub hardware_backed: bool, + pub strongbox: bool, + pub created_at: i64, + pub last_seen_at: Option, + pub revoked_at: Option, +} + +const DEVICE_COLUMNS: &str = "id, user_id, name, status, request_pub, approval_pub, push_channel, \ + push_token, hardware_backed, strongbox, created_at, last_seen_at, revoked_at"; + +type DeviceTuple = ( + String, + i64, + String, + String, + String, + String, + String, + String, + bool, + bool, + i64, + Option, + Option, +); + +impl From for DeviceRow { + fn from(t: DeviceTuple) -> Self { + Self { + id: t.0, + user_id: t.1, + name: t.2, + status: t.3, + request_pub: t.4, + approval_pub: t.5, + push_channel: t.6, + push_token: t.7, + hardware_backed: t.8, + strongbox: t.9, + created_at: t.10, + last_seen_at: t.11, + revoked_at: t.12, + } + } +} + +/// A challenge to create. `created_at` is taken from the clock. +pub struct NewChallenge<'a> { + pub id: &'a str, + pub user_id: i64, + pub device_id: &'a str, + pub login_ref: &'a str, + pub nonce: &'a str, + /// The number the user must pick (10 to 99). + pub number: i64, + /// JSON array of the three numbers shown on the phone. + pub candidates: &'a str, + pub audience: &'a str, + pub expires_at: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChallengeRow { + pub id: String, + pub user_id: i64, + pub device_id: String, + pub login_ref: String, + pub nonce: String, + pub number: i64, + pub candidates: String, + pub audience: String, + pub created_at: i64, + pub expires_at: i64, + /// `pending`, `approved`, `denied` or `expired`. + pub status: String, + pub consumed_at: Option, +} + +const CHALLENGE_COLUMNS: &str = "id, user_id, device_id, login_ref, nonce, number, candidates, \ + audience, created_at, expires_at, status, consumed_at"; + +type ChallengeTuple = ( + String, + i64, + String, + String, + String, + i64, + String, + String, + i64, + i64, + String, + Option, +); + +impl From for ChallengeRow { + fn from(t: ChallengeTuple) -> Self { + Self { + id: t.0, + user_id: t.1, + device_id: t.2, + login_ref: t.3, + nonce: t.4, + number: t.5, + candidates: t.6, + audience: t.7, + created_at: t.8, + expires_at: t.9, + status: t.10, + consumed_at: t.11, + } + } +} + +impl Db { + pub async fn mfa_enroll_token_put( + &self, + token_hash: &str, + user_id: i64, + expires_at: i64, + ) -> Result<()> { + sql::query( + "INSERT INTO mfa_enroll_tokens (token_hash, user_id, expires_at, used_at) + VALUES (?, ?, ?, NULL)", + ) + .bind(token_hash) + .bind(user_id) + .bind(expires_at) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(()) + } + + /// Uses a token: one statement, so two callers cannot both get the user. + pub async fn mfa_enroll_token_consume( + &self, + token_hash: &str, + now: i64, + ) -> Result> { + sql::query_scalar( + "UPDATE mfa_enroll_tokens SET used_at = ? + WHERE token_hash = ? AND used_at IS NULL AND expires_at > ? RETURNING user_id", + ) + .bind(now) + .bind(token_hash) + .bind(now) + .fetch_optional(self.pool()) + .await + .map_err(err) + } + + pub async fn mfa_device_insert(&self, d: &NewDevice<'_>) -> Result<()> { + sql::query( + "INSERT INTO mfa_devices (id, user_id, name, status, request_pub, approval_pub, + push_channel, push_token, hardware_backed, strongbox, created_at) + VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(d.id) + .bind(d.user_id) + .bind(d.name) + .bind(d.request_pub) + .bind(d.approval_pub) + .bind(d.push_channel) + .bind(d.push_token) + .bind(d.hardware_backed) + .bind(d.strongbox) + .bind(now_secs()) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(()) + } + + pub async fn mfa_device_get(&self, id: &str) -> Result> { + let row: Option = sql::query_as(format!( + "SELECT {DEVICE_COLUMNS} FROM mfa_devices WHERE id = ?" + )) + .bind(id) + .fetch_optional(self.pool()) + .await + .map_err(err)?; + Ok(row.map(DeviceRow::from)) + } + + pub async fn mfa_devices_of(&self, user_id: i64) -> Result> { + let rows: Vec = sql::query_as(format!( + "SELECT {DEVICE_COLUMNS} FROM mfa_devices WHERE user_id = ? ORDER BY created_at, id" + )) + .bind(user_id) + .fetch_all(self.pool()) + .await + .map_err(err)?; + Ok(rows.into_iter().map(DeviceRow::from).collect()) + } + + /// Revokes an active device of this user; false if there is no such active device. + pub async fn mfa_device_revoke(&self, id: &str, user_id: i64, now: i64) -> Result { + let r = sql::query( + "UPDATE mfa_devices SET status = 'revoked', revoked_at = ? + WHERE id = ? AND user_id = ? AND status = 'active'", + ) + .bind(now) + .bind(id) + .bind(user_id) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(r.rows_affected() > 0) + } + + pub async fn mfa_device_set_push(&self, id: &str, channel: &str, token: &str) -> Result { + let r = sql::query( + "UPDATE mfa_devices SET push_channel = ?, push_token = ? + WHERE id = ? AND status = 'active'", + ) + .bind(channel) + .bind(token) + .bind(id) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(r.rows_affected() > 0) + } + + pub async fn mfa_device_set_keys( + &self, + id: &str, + request_pub: &str, + approval_pub: &str, + ) -> Result { + let r = sql::query( + "UPDATE mfa_devices SET request_pub = ?, approval_pub = ? + WHERE id = ? AND status = 'active'", + ) + .bind(request_pub) + .bind(approval_pub) + .bind(id) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(r.rows_affected() > 0) + } + + pub async fn mfa_device_touch(&self, id: &str, now: i64) -> Result<()> { + sql::query("UPDATE mfa_devices SET last_seen_at = ? WHERE id = ?") + .bind(now) + .bind(id) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(()) + } + + /// Records a request nonce; false if the device already used it. + pub async fn mfa_nonce_insert( + &self, + device_id: &str, + nonce: &str, + expires_at: i64, + ) -> Result { + match sql::query("INSERT INTO mfa_nonces (device_id, nonce, expires_at) VALUES (?, ?, ?)") + .bind(device_id) + .bind(nonce) + .bind(expires_at) + .execute(self.pool()) + .await + { + Ok(_) => Ok(true), + Err(e) if e.is_unique_violation() => Ok(false), + Err(e) => Err(err(e)), + } + } + + /// Removes nonces whose window has ended. + pub async fn mfa_nonces_purge(&self, now: i64) -> Result { + let r = sql::query("DELETE FROM mfa_nonces WHERE expires_at <= ?") + .bind(now) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(r.rows_affected()) + } + + pub async fn mfa_challenge_insert(&self, c: &NewChallenge<'_>) -> Result<()> { + sql::query( + "INSERT INTO mfa_challenges (id, user_id, device_id, login_ref, nonce, number, + candidates, audience, created_at, expires_at, status, consumed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL)", + ) + .bind(c.id) + .bind(c.user_id) + .bind(c.device_id) + .bind(c.login_ref) + .bind(c.nonce) + .bind(c.number) + .bind(c.candidates) + .bind(c.audience) + .bind(now_secs()) + .bind(c.expires_at) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(()) + } + + pub async fn mfa_challenge_get(&self, id: &str) -> Result> { + let row: Option = sql::query_as(format!( + "SELECT {CHALLENGE_COLUMNS} FROM mfa_challenges WHERE id = ?" + )) + .bind(id) + .fetch_optional(self.pool()) + .await + .map_err(err)?; + Ok(row.map(ChallengeRow::from)) + } + + pub async fn mfa_challenges_pending( + &self, + device_id: &str, + now: i64, + ) -> Result> { + let rows: Vec = sql::query_as(format!( + "SELECT {CHALLENGE_COLUMNS} FROM mfa_challenges + WHERE device_id = ? AND status = 'pending' AND expires_at > ? + ORDER BY created_at, id" + )) + .bind(device_id) + .bind(now) + .fetch_all(self.pool()) + .await + .map_err(err)?; + Ok(rows.into_iter().map(ChallengeRow::from).collect()) + } + + /// Moves a pending, unexpired challenge to `to` (`approved`, `denied` or `expired`). + /// One conditional statement: true for exactly one caller. + pub async fn mfa_challenge_resolve(&self, id: &str, to: &str, now: i64) -> Result { + let r = sql::query( + "UPDATE mfa_challenges SET status = ?, consumed_at = ? + WHERE id = ? AND status = 'pending' AND expires_at > ?", + ) + .bind(to) + .bind(now) + .bind(id) + .bind(now) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(r.rows_affected() > 0) + } +} + +#[cfg(test)] +mod tests { + use crate::{Db, NewChallenge, NewDevice, NewUser}; + + async fn db_with_user() -> (Db, i64) { + let db = Db::open("sqlite::memory:").await.expect("db"); + db.migrate().await.expect("migrate"); + let u = db + .create_user(NewUser { + name: "anna", + password_hash: "x", + is_admin: false, + }) + .await + .expect("user"); + (db, u.id) + } + + fn device(id: &str, user_id: i64) -> NewDevice<'_> { + NewDevice { + id, + user_id, + name: "Pixel 8", + request_pub: "req-pub", + approval_pub: "app-pub", + push_channel: "fcm", + push_token: "tok", + hardware_backed: true, + strongbox: false, + } + } + + fn challenge<'a>( + id: &'a str, + device_id: &'a str, + user_id: i64, + expires_at: i64, + ) -> NewChallenge<'a> { + NewChallenge { + id, + user_id, + device_id, + login_ref: "login-1", + nonce: "n0nce", + number: 47, + candidates: "[47,12,83]", + audience: "rostdav", + expires_at, + } + } + + #[tokio::test] + async fn enroll_token_is_single_use_and_expires() { + let (db, uid) = db_with_user().await; + db.mfa_enroll_token_put("h1", uid, 1_000) + .await + .expect("put"); + assert_eq!( + db.mfa_enroll_token_consume("h1", 999).await.expect("c"), + Some(uid) + ); + assert_eq!( + db.mfa_enroll_token_consume("h1", 999).await.expect("c"), + None + ); + db.mfa_enroll_token_put("h2", uid, 1_000) + .await + .expect("put"); + assert_eq!( + db.mfa_enroll_token_consume("h2", 1_000).await.expect("c"), + None + ); + assert_eq!( + db.mfa_enroll_token_consume("unknown", 1).await.expect("c"), + None + ); + } + + #[tokio::test] + async fn device_roundtrip_and_updates() { + let (db, uid) = db_with_user().await; + db.mfa_device_insert(&device("dev_a", uid)) + .await + .expect("insert"); + let d = db.mfa_device_get("dev_a").await.expect("get").expect("row"); + assert_eq!( + (d.user_id, d.status.as_str(), d.hardware_backed, d.strongbox), + (uid, "active", true, false) + ); + assert_eq!(d.last_seen_at, None); + assert!( + db.mfa_device_set_push("dev_a", "unifiedpush", "t2") + .await + .expect("push") + ); + assert!( + db.mfa_device_set_keys("dev_a", "r2", "a2") + .await + .expect("keys") + ); + db.mfa_device_touch("dev_a", 77).await.expect("touch"); + let d = db.mfa_device_get("dev_a").await.expect("get").expect("row"); + assert_eq!( + (d.push_channel.as_str(), d.push_token.as_str()), + ("unifiedpush", "t2") + ); + assert_eq!( + ( + d.request_pub.as_str(), + d.approval_pub.as_str(), + d.last_seen_at + ), + ("r2", "a2", Some(77)) + ); + assert!( + !db.mfa_device_set_push("missing", "fcm", "t") + .await + .expect("push") + ); + assert!( + db.mfa_device_insert(&device("dev_a", uid)).await.is_err(), + "duplicate id" + ); + assert_eq!(db.mfa_devices_of(uid).await.expect("list").len(), 1); + } + + #[tokio::test] + async fn revoke_requires_matching_user() { + let (db, uid) = db_with_user().await; + let other = db + .create_user(NewUser { + name: "bob", + password_hash: "x", + is_admin: false, + }) + .await + .expect("user") + .id; + db.mfa_device_insert(&device("dev_a", uid)) + .await + .expect("insert"); + assert!( + !db.mfa_device_revoke("dev_a", other, 5) + .await + .expect("revoke") + ); + assert_eq!( + db.mfa_device_get("dev_a") + .await + .expect("get") + .expect("row") + .status, + "active" + ); + assert!(db.mfa_device_revoke("dev_a", uid, 5).await.expect("revoke")); + let d = db.mfa_device_get("dev_a").await.expect("get").expect("row"); + assert_eq!((d.status.as_str(), d.revoked_at), ("revoked", Some(5))); + assert!( + !db.mfa_device_revoke("dev_a", uid, 6).await.expect("again"), + "already revoked" + ); + } + + #[tokio::test] + async fn nonce_insert_detects_replay_and_purge_removes_expired() { + let (db, uid) = db_with_user().await; + db.mfa_device_insert(&device("dev_a", uid)) + .await + .expect("insert"); + assert!(db.mfa_nonce_insert("dev_a", "n1", 100).await.expect("n")); + assert!( + !db.mfa_nonce_insert("dev_a", "n1", 100) + .await + .expect("replay") + ); + assert!(db.mfa_nonce_insert("dev_a", "n2", 500).await.expect("n")); + assert_eq!(db.mfa_nonces_purge(100).await.expect("purge"), 1); + assert!( + db.mfa_nonce_insert("dev_a", "n1", 600) + .await + .expect("again after purge") + ); + assert!( + !db.mfa_nonce_insert("dev_a", "n2", 600) + .await + .expect("still there") + ); + } + + #[tokio::test] + async fn challenge_resolve_is_atomic() { + let (db, uid) = db_with_user().await; + db.mfa_device_insert(&device("dev_a", uid)) + .await + .expect("insert"); + db.mfa_challenge_insert(&challenge("ch_1", "dev_a", uid, 1_000)) + .await + .expect("ins"); + assert!( + db.mfa_challenge_resolve("ch_1", "approved", 10) + .await + .expect("r") + ); + assert!( + !db.mfa_challenge_resolve("ch_1", "approved", 11) + .await + .expect("second") + ); + assert!( + !db.mfa_challenge_resolve("ch_1", "denied", 12) + .await + .expect("other") + ); + let c = db + .mfa_challenge_get("ch_1") + .await + .expect("get") + .expect("row"); + assert_eq!((c.status.as_str(), c.consumed_at), ("approved", Some(10))); + } + + #[tokio::test] + async fn challenge_resolve_refuses_after_expiry() { + let (db, uid) = db_with_user().await; + db.mfa_device_insert(&device("dev_a", uid)) + .await + .expect("insert"); + db.mfa_challenge_insert(&challenge("ch_1", "dev_a", uid, 1_000)) + .await + .expect("ins"); + assert!( + !db.mfa_challenge_resolve("ch_1", "approved", 1_000) + .await + .expect("r") + ); + let c = db + .mfa_challenge_get("ch_1") + .await + .expect("get") + .expect("row"); + assert_eq!((c.status.as_str(), c.consumed_at), ("pending", None)); + assert!( + db.mfa_challenge_get("missing") + .await + .expect("get") + .is_none() + ); + } + + #[tokio::test] + async fn pending_list_excludes_resolved_expired_and_other_devices() { + let (db, uid) = db_with_user().await; + db.mfa_device_insert(&device("dev_a", uid)) + .await + .expect("insert"); + db.mfa_device_insert(&device("dev_b", uid)) + .await + .expect("insert"); + db.mfa_challenge_insert(&challenge("ch_live", "dev_a", uid, 1_000)) + .await + .expect("ins"); + db.mfa_challenge_insert(&challenge("ch_old", "dev_a", uid, 50)) + .await + .expect("ins"); + db.mfa_challenge_insert(&challenge("ch_done", "dev_a", uid, 1_000)) + .await + .expect("ins"); + db.mfa_challenge_insert(&challenge("ch_other", "dev_b", uid, 1_000)) + .await + .expect("ins"); + assert!( + db.mfa_challenge_resolve("ch_done", "denied", 10) + .await + .expect("r") + ); + let ids: Vec = db + .mfa_challenges_pending("dev_a", 100) + .await + .expect("pending") + .into_iter() + .map(|c| c.id) + .collect(); + assert_eq!(ids, vec!["ch_live".to_string()]); + } + + #[tokio::test] + async fn deleting_the_user_cascades_to_everything() { + let (db, uid) = db_with_user().await; + db.mfa_enroll_token_put("h1", uid, 1_000) + .await + .expect("put"); + db.mfa_device_insert(&device("dev_a", uid)) + .await + .expect("insert"); + db.mfa_nonce_insert("dev_a", "n1", 500).await.expect("n"); + db.mfa_challenge_insert(&challenge("ch_1", "dev_a", uid, 1_000)) + .await + .expect("ins"); + db.delete_user(uid).await.expect("delete"); + assert!(db.mfa_device_get("dev_a").await.expect("get").is_none()); + assert!(db.mfa_challenge_get("ch_1").await.expect("get").is_none()); + assert_eq!(db.mfa_enroll_token_consume("h1", 1).await.expect("c"), None); + assert!( + db.mfa_nonce_insert("dev_a", "n1", 500).await.is_err(), + "device row is gone" + ); + } + /// The engine can ignore an index (see `CLAUDE.md`, "Database"); these are the queries that + /// run on every request or every sign-in, so a changed plan must fail a test. + #[tokio::test] + async fn hot_queries_use_their_indexes() { + use crate::sql; + let (db, _uid) = db_with_user().await; + let cases = [ + ( + "SELECT id FROM mfa_challenges WHERE device_id = 'd' AND status = 'pending' AND expires_at > 1", + "mfa_challenges_device", + ), + ( + "SELECT id FROM mfa_challenges WHERE login_ref = 'x'", + "mfa_challenges_login", + ), + ( + "DELETE FROM mfa_nonces WHERE expires_at <= 5", + "mfa_nonces_expiry", + ), + ( + "SELECT id FROM mfa_devices WHERE user_id = 1", + "mfa_devices_user", + ), + ]; + for (q, index) in cases { + let rows: Vec<(i64, i64, i64, String)> = + sql::query_as(format!("EXPLAIN QUERY PLAN {q}")) + .fetch_all(db.pool()) + .await + .expect("plan"); + assert!( + rows.iter().any(|r| r.3.contains(index)), + "{q} does not use {index}: {rows:?}" + ); + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 4392b93..9667a8f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,6 +103,10 @@ A central error type in `rostdav-core` (`NotFound`, `Forbidden`, `Conflict`, `Pr | `login_flows` | poll_token_hash, login_token_hash, client_name, state, user_id, app_password_id, expires_at | | `totp` | user_id, secret_enc, enabled_at, last_used_step | | `recovery_codes` | id, user_id, code_hash, used_at | +| `mfa_devices` | id, user_id, name, status (active/revoked), request_pub, approval_pub, push_channel, push_token, hardware_backed, strongbox, created_at, last_seen_at, revoked_at ([ADR 0014](adr/0014-push-mfa.md)) | +| `mfa_enroll_tokens` | token_hash, user_id, expires_at, used_at | +| `mfa_challenges` | id, user_id, device_id, login_ref, nonce, number, candidates, audience, created_at, expires_at, status, consumed_at | +| `mfa_nonces` | device_id, nonce, expires_at | | `nodes` | see ADR 0002 | | `blobs` | id, size, checksum, created_at | | `versions` | id, node_id, blob_id, size, mtime, author_app_password_id, created_at | From 4c637087aac4fcda054bcc84c997c7cd57d85055 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Sun, 8 Nov 2026 20:07:58 +0100 Subject: [PATCH 04/24] feat(mfa): key attestation verification (ADR 0014) --- CHANGELOG.md | 1 + Cargo.lock | 61 +- Cargo.toml | 4 + crates/rostdav-app/Cargo.toml | 3 + crates/rostdav-app/src/lib.rs | 4 +- .../rostdav-app/src/push_mfa/attestation.rs | 766 ++++++++++++++++++ crates/rostdav-app/src/push_mfa/error.rs | 112 +++ .../rostdav-app/src/push_mfa/google_roots.rs | 108 +++ crates/rostdav-app/src/push_mfa/mod.rs | 17 + crates/rostdav-app/src/secrets.rs | 10 +- docs/adr/0014-push-mfa.md | 9 +- 11 files changed, 1088 insertions(+), 7 deletions(-) create mode 100644 crates/rostdav-app/src/push_mfa/attestation.rs create mode 100644 crates/rostdav-app/src/push_mfa/error.rs create mode 100644 crates/rostdav-app/src/push_mfa/google_roots.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e286b5f..efc8f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https - **Settings for push sign-in approval** (`[mfa]` section: challenge lifetime, enrollment lifetime, audience, attestation policy), the first step of ADR 0014. They have no effect yet. - **Database tables for push sign-in approval** (migration 0013: phones, enrollment tokens, challenges, request nonces). Nothing uses them yet. +- **Key attestation check for push sign-in approval phones**: verifies that a phone's key lives in secure hardware (certificate chain to a pinned Google root, hardware security level, challenge, key match). Nothing calls it yet. ## [0.10.0] - 2026-10-25 diff --git a/Cargo.lock b/Cargo.lock index 3ea94cc..bb1c72e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -758,6 +758,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -976,6 +982,29 @@ dependencies = [ "thiserror", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.9" @@ -1003,7 +1032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -1177,6 +1206,12 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.10" @@ -2972,6 +3007,7 @@ dependencies = [ "base64 0.23.1", "blake3", "chacha20poly1305", + "der", "encoding_rs", "hmac", "minisign-verify", @@ -2979,6 +3015,7 @@ dependencies = [ "pdf_oxide", "qrcode", "quick-xml", + "ring", "rostdav-core", "rostdav-meta", "rostdav-storage", @@ -2988,6 +3025,7 @@ dependencies = [ "tokio", "tracing", "ureq", + "x509-cert", "zip", ] @@ -3450,6 +3488,16 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4796,6 +4844,17 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 5845333..d0057ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,10 @@ argon2 = { version = "0.5", features = ["std"] } ulid = "3" chacha20poly1305 = "0.11" hmac = "0.13" +# Push sign-in approval (ADR 0014): attestation chains +x509-cert = { version = "0.2", default-features = false } +der = { version = "0.7", features = ["alloc", "oid"] } +ring = { version = "0.17", default-features = false, features = ["alloc"] } sha1 = "0.11" qrcode = { version = "0.14", default-features = false, features = ["svg"] } ureq = { version = "3", default-features = false, features = ["rustls"] } diff --git a/crates/rostdav-app/Cargo.toml b/crates/rostdav-app/Cargo.toml index 5965b7c..a0ec65d 100644 --- a/crates/rostdav-app/Cargo.toml +++ b/crates/rostdav-app/Cargo.toml @@ -8,6 +8,9 @@ rust-version.workspace = true publish.workspace = true [dependencies] +x509-cert.workspace = true +der.workspace = true +ring.workspace = true rostdav-core.workspace = true rostdav-meta.workspace = true rostdav-storage.workspace = true diff --git a/crates/rostdav-app/src/lib.rs b/crates/rostdav-app/src/lib.rs index 3648338..ea28cec 100644 --- a/crates/rostdav-app/src/lib.rs +++ b/crates/rostdav-app/src/lib.rs @@ -56,7 +56,9 @@ pub use indexer::{PassReport, SearchStatus}; pub use login_flow::FlowStep; pub use login_flow::{Granted, StartedFlow}; pub use password::{MIN_PASSWORD_CHARS, hash_password, verify_password}; -pub use push_mfa::{AttestationPolicy, MfaConfig}; +pub use push_mfa::{ + AttestationPolicy, AttestationResult, MfaConfig, MfaError, google_roots, verify_attestation, +}; pub use search::{KindFilter, MAX_VISITED, SearchHit, SearchPage, SearchQuery, SnippetPart}; pub use shares::{ LINK_TOKEN_LEN, ShareChange, ShareCreate, ShareError, ShareFilter, ShareView, Viewer, diff --git a/crates/rostdav-app/src/push_mfa/attestation.rs b/crates/rostdav-app/src/push_mfa/attestation.rs new file mode 100644 index 0000000..79c765c --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/attestation.rs @@ -0,0 +1,766 @@ +//! Verification of Android key attestation chains (ADR 0014). +//! +//! A phone proves that its approval key lives in secure hardware: the certificate chain that +//! Android issues for the key must lead to a pinned Google root, and the key description in the +//! leaf must name the expected challenge and a hardware security level. Format: +//! . + +use base64::{Engine, engine::general_purpose::STANDARD}; +use der::asn1::{AnyRef, ObjectIdentifier}; +use der::{Decode, Encode, Reader, SliceReader, Tag, Tagged}; +use ring::signature::{self, UnparsedPublicKey, VerificationAlgorithm}; +use x509_cert::Certificate; +use x509_cert::spki::SubjectPublicKeyInfoOwned; + +use super::MfaError; + +/// Most certificates a chain may have (Android sends three to five). +const MAX_CHAIN: usize = 8; +/// Largest accepted certificate: real ones are about 1 KiB. +const MAX_CERT_BYTES: usize = 16 * 1024; + +const KEY_DESCRIPTION: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.4.1.11129.2.1.17"); +const RSA_ENCRYPTION: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1"); +const EC_PUBLIC_KEY: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); +const CURVE_P256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7"); +const CURVE_P384: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.132.0.34"); +const ECDSA_SHA256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2"); +const ECDSA_SHA384: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.3"); +const RSA_SHA256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.11"); +const RSA_SHA384: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.12"); +const RSA_SHA512: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.13"); + +/// What the chain proved about the approval key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AttestationResult { + /// The key lives in a trusted execution environment or in a StrongBox. + pub hardware_backed: bool, + /// The key lives in a StrongBox (a separate secure element). + pub strongbox: bool, +} + +fn invalid(reason: &'static str) -> MfaError { + MfaError::InvalidRequest(reason) +} + +/// Checks an attestation chain. +/// +/// `chain_b64` is leaf first, base64 (standard) DER; `trust_roots` are DER certificates. The chain +/// may end with a trusted root or with a certificate that one of the roots signed. Every +/// certificate must be valid at `now` (Unix seconds). +pub fn verify_attestation( + chain_b64: &[String], + approval_spki_b64: &str, + expected_challenge: &[u8], + trust_roots: &[Vec], + now: i64, +) -> Result { + if chain_b64.is_empty() || chain_b64.len() > MAX_CHAIN { + return Err(invalid("attestation chain is missing or too long")); + } + let mut ders = Vec::with_capacity(chain_b64.len()); + let mut certs = Vec::with_capacity(chain_b64.len()); + for b in chain_b64 { + let unreadable = || invalid("attestation certificate cannot be read"); + let der_bytes = STANDARD.decode(b).map_err(|_| unreadable())?; + if der_bytes.len() > MAX_CERT_BYTES { + return Err(unreadable()); + } + certs.push(Certificate::from_der(&der_bytes).map_err(|_| unreadable())?); + ders.push(der_bytes); + } + if !certs.iter().all(|c| valid_at(c, now)) { + return Err(invalid("attestation certificate is not valid now")); + } + for i in 0..certs.len() - 1 { + let issuer = &certs[i + 1]; + let linked = certs[i].tbs_certificate.issuer == issuer.tbs_certificate.subject + && verify_cert_signature( + &ders[i], + &certs[i], + &issuer.tbs_certificate.subject_public_key_info, + ); + if !linked { + return Err(invalid("attestation chain is not signed correctly")); + } + } + check_trust(&ders, &certs, trust_roots, now)?; + + let leaf = &certs[0]; + let description = leaf + .tbs_certificate + .extensions + .iter() + .flatten() + .find(|e| e.extn_id == KEY_DESCRIPTION) + .ok_or_else(|| invalid("attestation key description is missing"))?; + let kd = parse_key_description(description.extn_value.as_bytes()) + .ok_or_else(|| invalid("attestation key description cannot be read"))?; + + // levels: 0 software, 1 trusted environment, 2 StrongBox + if kd.attestation_level < 1 || kd.keymaster_level < 1 { + return Err(invalid("key is not hardware backed")); + } + if !crate::secrets::ct_eq(kd.challenge, expected_challenge) { + return Err(invalid("attestation challenge does not match")); + } + let attested = leaf.tbs_certificate.subject_public_key_info.to_der().ok(); + let claimed = STANDARD.decode(approval_spki_b64).ok(); + if attested.is_none() || attested != claimed { + return Err(invalid("attested key is not the approval key")); + } + Ok(AttestationResult { + hardware_backed: true, + strongbox: kd.attestation_level == 2 && kd.keymaster_level == 2, + }) +} + +/// The chain must end in a trusted root: the last certificate is one, or one signed it. +fn check_trust( + ders: &[Vec], + certs: &[Certificate], + trust_roots: &[Vec], + now: i64, +) -> Result<(), MfaError> { + let (Some(last_der), Some(last)) = (ders.last(), certs.last()) else { + return Err(invalid("attestation chain is missing or too long")); + }; + let trusted = trust_roots.iter().any(|root_der| { + if root_der == last_der { + return true; + } + let Ok(root) = Certificate::from_der(root_der) else { + return false; + }; + valid_at(&root, now) + && root.tbs_certificate.subject == last.tbs_certificate.issuer + && verify_cert_signature( + last_der, + last, + &root.tbs_certificate.subject_public_key_info, + ) + }); + if trusted { + Ok(()) + } else { + Err(invalid("attestation chain does not end in a trusted root")) + } +} + +fn valid_at(cert: &Certificate, now: i64) -> bool { + let secs = |t: &x509_cert::time::Time| i64::try_from(t.to_unix_duration().as_secs()).ok(); + let v = &cert.tbs_certificate.validity; + matches!((secs(&v.not_before), secs(&v.not_after)), (Some(a), Some(b)) if a <= now && now <= b) +} + +/// Whether `cert` is signed by the key in `issuer`. The signed bytes are taken from the +/// original encoding, not from a re-encoding. +pub(crate) fn verify_cert_signature( + cert_der: &[u8], + cert: &Certificate, + issuer: &SubjectPublicKeyInfoOwned, +) -> bool { + let Some(tbs) = tbs_bytes(cert_der) else { + return false; + }; + let Some(sig) = cert.signature.as_bytes() else { + return false; + }; + let Some(alg) = signature_algorithm(cert.signature_algorithm.oid, issuer) else { + return false; + }; + let Some(key) = issuer.subject_public_key.as_bytes() else { + return false; + }; + UnparsedPublicKey::new(alg, key).verify(tbs, sig).is_ok() +} + +/// The `tbsCertificate` of a DER certificate, exactly as it was signed. +fn tbs_bytes(cert_der: &[u8]) -> Option<&[u8]> { + let outer = AnyRef::from_der(cert_der).ok()?; + if outer.tag() != Tag::Sequence { + return None; + } + let body = outer.value(); + let mut reader = SliceReader::new(body).ok()?; + AnyRef::decode(&mut reader).ok()?; + let len = usize::try_from(u32::from(reader.position())).ok()?; + body.get(..len) +} + +fn signature_algorithm( + sig_alg: ObjectIdentifier, + issuer: &SubjectPublicKeyInfoOwned, +) -> Option<&'static dyn VerificationAlgorithm> { + let key_alg = issuer.algorithm.oid; + if key_alg == RSA_ENCRYPTION { + return match sig_alg { + RSA_SHA256 => Some(&signature::RSA_PKCS1_2048_8192_SHA256), + RSA_SHA384 => Some(&signature::RSA_PKCS1_2048_8192_SHA384), + RSA_SHA512 => Some(&signature::RSA_PKCS1_2048_8192_SHA512), + _ => None, + }; + } + if key_alg != EC_PUBLIC_KEY { + return None; + } + let curve = issuer + .algorithm + .parameters + .as_ref()? + .decode_as::() + .ok()?; + match (curve, sig_alg) { + (CURVE_P256, ECDSA_SHA256) => Some(&signature::ECDSA_P256_SHA256_ASN1), + (CURVE_P256, ECDSA_SHA384) => Some(&signature::ECDSA_P256_SHA384_ASN1), + (CURVE_P384, ECDSA_SHA256) => Some(&signature::ECDSA_P384_SHA256_ASN1), + (CURVE_P384, ECDSA_SHA384) => Some(&signature::ECDSA_P384_SHA384_ASN1), + _ => None, + } +} + +struct KeyDescription<'a> { + attestation_level: u8, + keymaster_level: u8, + challenge: &'a [u8], +} + +/// Reads the first five fields of the key description: attestationVersion, +/// attestationSecurityLevel, keymasterVersion, keymasterSecurityLevel, attestationChallenge. +fn parse_key_description(value: &[u8]) -> Option> { + let seq = AnyRef::from_der(value).ok()?; + if seq.tag() != Tag::Sequence { + return None; + } + let mut r = SliceReader::new(seq.value()).ok()?; + let level = |any: AnyRef<'_>| -> Option { + match any.value() { + [b] if any.tag() == Tag::Enumerated => Some(*b), + _ => None, + } + }; + let version = AnyRef::decode(&mut r).ok()?; + let attestation_level = level(AnyRef::decode(&mut r).ok()?)?; + let keymaster_version = AnyRef::decode(&mut r).ok()?; + let keymaster_level = level(AnyRef::decode(&mut r).ok()?)?; + let challenge = AnyRef::decode(&mut r).ok()?; + if version.tag() != Tag::Integer + || keymaster_version.tag() != Tag::Integer + || challenge.tag() != Tag::OctetString + { + return None; + } + Some(KeyDescription { + attestation_level, + keymaster_level, + challenge: challenge.value(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{Engine, engine::general_purpose::STANDARD}; + use der::asn1::{BitString, ObjectIdentifier, OctetString, UtcTime}; + use der::{Any, Encode}; + use ring::rand::SystemRandom; + use ring::signature::{ECDSA_P256_SHA256_ASN1_SIGNING, EcdsaKeyPair, KeyPair}; + use std::str::FromStr; + use std::time::Duration; + use x509_cert::Certificate; + use x509_cert::TbsCertificate; + use x509_cert::ext::Extension; + use x509_cert::name::Name; + use x509_cert::serial_number::SerialNumber; + use x509_cert::spki::{AlgorithmIdentifierOwned, SubjectPublicKeyInfoOwned}; + use x509_cert::time::{Time, Validity}; + + const NOW: i64 = 1_800_000_000; + const EC_PUBLIC_KEY: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); + const P256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7"); + const ECDSA_SHA256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2"); + const KEY_DESCRIPTION: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.3.6.1.4.1.11129.2.1.17"); + + fn key() -> EcdsaKeyPair { + let rng = SystemRandom::new(); + let pkcs8 = + EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).expect("key"); + EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng) + .expect("pair") + } + + fn spki(kp: &EcdsaKeyPair) -> SubjectPublicKeyInfoOwned { + SubjectPublicKeyInfoOwned { + algorithm: AlgorithmIdentifierOwned { + oid: EC_PUBLIC_KEY, + parameters: Some(Any::encode_from(&P256).expect("param")), + }, + subject_public_key: BitString::from_bytes(kp.public_key().as_ref()).expect("bits"), + } + } + + fn spki_b64(kp: &EcdsaKeyPair) -> String { + STANDARD.encode(spki(kp).to_der().expect("der")) + } + + fn time(secs: i64) -> Time { + let d = Duration::from_secs(u64::try_from(secs).expect("positive")); + Time::UtcTime(UtcTime::from_unix_duration(d).expect("utc time")) + } + + fn tlv(tag: u8, content: &[u8]) -> Vec { + let mut out = vec![tag]; + match content.len() { + n if n < 128 => out.push(u8::try_from(n).expect("short")), + n => { + out.push(0x82); + out.extend(u16::try_from(n).expect("len").to_be_bytes()); + } + } + out.extend(content); + out + } + + /// The value of the Android key description extension (only the fields the verifier reads). + fn key_description(attestation_level: u8, keymaster_level: u8, challenge: &[u8]) -> Vec { + let mut seq = Vec::new(); + seq.extend(tlv(0x02, &[4])); // attestationVersion + seq.extend(tlv(0x0a, &[attestation_level])); + seq.extend(tlv(0x02, &[4])); // keymasterVersion + seq.extend(tlv(0x0a, &[keymaster_level])); + seq.extend(tlv(0x04, challenge)); // attestationChallenge + seq.extend(tlv(0x04, &[])); // uniqueId + seq.extend(tlv(0x30, &[])); // softwareEnforced + seq.extend(tlv(0x30, &[])); // teeEnforced + tlv(0x30, &seq) + } + + fn issue( + subject: &str, + subject_kp: &EcdsaKeyPair, + issuer: &str, + issuer_kp: &EcdsaKeyPair, + validity: (i64, i64), + extension: Option>, + ) -> Vec { + let alg = AlgorithmIdentifierOwned { + oid: ECDSA_SHA256, + parameters: None, + }; + let tbs = TbsCertificate { + version: x509_cert::certificate::Version::V3, + serial_number: SerialNumber::new(&[1]).expect("serial"), + signature: alg.clone(), + issuer: Name::from_str(issuer).expect("issuer"), + validity: Validity { + not_before: time(validity.0), + not_after: time(validity.1), + }, + subject: Name::from_str(subject).expect("subject"), + subject_public_key_info: spki(subject_kp), + issuer_unique_id: None, + subject_unique_id: None, + extensions: extension.map(|v| { + vec![Extension { + extn_id: KEY_DESCRIPTION, + critical: false, + extn_value: OctetString::new(v).expect("octets"), + }] + }), + }; + let sig = issuer_kp + .sign(&SystemRandom::new(), &tbs.to_der().expect("tbs")) + .expect("sign"); + Certificate { + tbs_certificate: tbs, + signature_algorithm: alg, + signature: BitString::from_bytes(sig.as_ref()).expect("sig"), + } + .to_der() + .expect("cert") + } + + struct Opts { + attestation_level: u8, + keymaster_level: u8, + challenge: Vec, + leaf_validity: (i64, i64), + with_extension: bool, + include_root: bool, + leaf_issuer: &'static str, + } + + impl Default for Opts { + fn default() -> Self { + Self { + attestation_level: 1, + keymaster_level: 1, + challenge: b"challenge-bytes".to_vec(), + leaf_validity: (NOW - 1000, NOW + 1000), + with_extension: true, + include_root: true, + leaf_issuer: "CN=Test Intermediate", + } + } + } + + struct Fixture { + chain: Vec, + approval_spki: String, + root: Vec, + leaf_der: Vec, + intermediate_der: Vec, + } + + fn fixture(o: Opts) -> Fixture { + let (root_kp, inter_kp, leaf_kp) = (key(), key(), key()); + let wide = (NOW - 100_000, NOW + 100_000); + let root = issue( + "CN=Test Root", + &root_kp, + "CN=Test Root", + &root_kp, + wide, + None, + ); + let inter = issue( + "CN=Test Intermediate", + &inter_kp, + "CN=Test Root", + &root_kp, + wide, + None, + ); + let ext = o + .with_extension + .then(|| key_description(o.attestation_level, o.keymaster_level, &o.challenge)); + let leaf = issue( + "CN=Test Leaf", + &leaf_kp, + o.leaf_issuer, + &inter_kp, + o.leaf_validity, + ext, + ); + let mut chain = vec![STANDARD.encode(&leaf), STANDARD.encode(&inter)]; + if o.include_root { + chain.push(STANDARD.encode(&root)); + } + Fixture { + chain, + approval_spki: spki_b64(&leaf_kp), + root, + leaf_der: leaf, + intermediate_der: inter, + } + } + + fn verify(f: &Fixture) -> Result { + verify_attestation( + &f.chain, + &f.approval_spki, + b"challenge-bytes", + std::slice::from_ref(&f.root), + NOW, + ) + } + + fn reason(r: Result) -> &'static str { + match r { + Err(MfaError::InvalidRequest(why)) => why, + other => panic!("expected InvalidRequest, got {other:?}"), + } + } + + #[test] + fn accepts_tee_chain() { + let r = verify(&fixture(Opts::default())).expect("ok"); + assert_eq!((r.hardware_backed, r.strongbox), (true, false)); + } + + #[test] + fn accepts_strongbox_chain() { + let f = fixture(Opts { + attestation_level: 2, + keymaster_level: 2, + ..Opts::default() + }); + let r = verify(&f).expect("ok"); + assert_eq!((r.hardware_backed, r.strongbox), (true, true)); + } + + #[test] + fn strongbox_needs_both_levels() { + let f = fixture(Opts { + attestation_level: 2, + keymaster_level: 1, + ..Opts::default() + }); + let r = verify(&f).expect("ok"); + assert_eq!((r.hardware_backed, r.strongbox), (true, false)); + } + + #[test] + fn accepts_chain_that_leaves_out_the_root() { + let f = fixture(Opts { + include_root: false, + ..Opts::default() + }); + assert_eq!(f.chain.len(), 2); + assert!(verify(&f).is_ok()); + } + + #[test] + fn rejects_software_attestation_level() { + let f = fixture(Opts { + attestation_level: 0, + keymaster_level: 0, + ..Opts::default() + }); + assert_eq!(reason(verify(&f)), "key is not hardware backed"); + } + + #[test] + fn rejects_software_keymaster_under_tee_attestation() { + let f = fixture(Opts { + attestation_level: 1, + keymaster_level: 0, + ..Opts::default() + }); + assert_eq!(reason(verify(&f)), "key is not hardware backed"); + } + + #[test] + fn rejects_wrong_challenge() { + let f = fixture(Opts { + challenge: b"other".to_vec(), + ..Opts::default() + }); + assert_eq!(reason(verify(&f)), "attestation challenge does not match"); + } + + #[test] + fn rejects_challenge_that_only_starts_with_the_expected_one() { + let f = fixture(Opts { + challenge: b"challenge-bytes-and-more".to_vec(), + ..Opts::default() + }); + assert_eq!(reason(verify(&f)), "attestation challenge does not match"); + } + + #[test] + fn rejects_leaf_key_that_is_not_the_approval_key() { + let mut f = fixture(Opts::default()); + f.approval_spki = spki_b64(&key()); + assert_eq!(reason(verify(&f)), "attested key is not the approval key"); + } + + #[test] + fn rejects_unknown_root() { + let f = fixture(Opts::default()); + let other = fixture(Opts::default()).root; + let r = verify_attestation( + &f.chain, + &f.approval_spki, + b"challenge-bytes", + &[other], + NOW, + ); + assert_eq!( + reason(r), + "attestation chain does not end in a trusted root" + ); + } + + #[test] + fn rejects_no_trusted_roots() { + let f = fixture(Opts::default()); + let r = verify_attestation(&f.chain, &f.approval_spki, b"challenge-bytes", &[], NOW); + assert_eq!( + reason(r), + "attestation chain does not end in a trusted root" + ); + } + + #[test] + fn rejects_broken_signature_in_chain() { + let mut f = fixture(Opts::default()); + let mut leaf = f.leaf_der.clone(); + *leaf.last_mut().expect("byte") ^= 0x01; + f.chain[0] = STANDARD.encode(leaf); + assert_eq!( + reason(verify(&f)), + "attestation chain is not signed correctly" + ); + } + + #[test] + fn rejects_chain_in_the_wrong_order() { + let mut f = fixture(Opts::default()); + f.chain.swap(0, 1); + assert!(matches!(verify(&f), Err(MfaError::InvalidRequest(_)))); + f.chain.swap(0, 1); + f.chain[1] = STANDARD.encode(&f.leaf_der); + assert!(matches!(verify(&f), Err(MfaError::InvalidRequest(_)))); + } + + #[test] + fn rejects_a_valid_signature_under_the_wrong_issuer_name() { + let f = fixture(Opts { + leaf_issuer: "CN=Someone Else", + ..Opts::default() + }); + assert_eq!( + reason(verify(&f)), + "attestation chain is not signed correctly" + ); + } + + #[test] + fn rejects_expired_and_not_yet_valid_leaf() { + let f = fixture(Opts { + leaf_validity: (NOW - 1000, NOW - 1), + ..Opts::default() + }); + assert_eq!( + reason(verify(&f)), + "attestation certificate is not valid now" + ); + let f = fixture(Opts { + leaf_validity: (NOW + 1, NOW + 1000), + ..Opts::default() + }); + assert_eq!( + reason(verify(&f)), + "attestation certificate is not valid now" + ); + } + + #[test] + fn validity_bounds_are_inclusive() { + let f = fixture(Opts { + leaf_validity: (NOW, NOW), + ..Opts::default() + }); + assert!(verify(&f).is_ok()); + } + + #[test] + fn rejects_missing_extension() { + let f = fixture(Opts { + with_extension: false, + ..Opts::default() + }); + assert_eq!(reason(verify(&f)), "attestation key description is missing"); + } + + #[test] + fn rejects_garbage_extension() { + let f = fixture(Opts::default()); + // a leaf whose extension is not a key description + let (root_kp, leaf_kp) = (key(), key()); + let wide = (NOW - 1000, NOW + 1000); + let root = issue("CN=R", &root_kp, "CN=R", &root_kp, wide, None); + let leaf = issue( + "CN=L", + &leaf_kp, + "CN=R", + &root_kp, + wide, + Some(vec![0x01, 0x02]), + ); + let chain = vec![STANDARD.encode(leaf)]; + let r = verify_attestation( + &chain, + &spki_b64(&leaf_kp), + b"challenge-bytes", + &[root], + NOW, + ); + assert_eq!(reason(r), "attestation key description cannot be read"); + drop(f); + } + + #[test] + fn rejects_empty_and_too_long_chains() { + let f = fixture(Opts::default()); + let r = verify_attestation( + &[], + &f.approval_spki, + b"challenge-bytes", + std::slice::from_ref(&f.root), + NOW, + ); + assert_eq!(reason(r), "attestation chain is missing or too long"); + let long: Vec = + std::iter::repeat_n(STANDARD.encode(&f.intermediate_der), 9).collect(); + let r = verify_attestation( + &long, + &f.approval_spki, + b"challenge-bytes", + std::slice::from_ref(&f.root), + NOW, + ); + assert_eq!(reason(r), "attestation chain is missing or too long"); + } + + #[test] + fn accepts_a_chain_of_eight() { + // length limit is inclusive; the chain itself is checked after the length + let f = fixture(Opts::default()); + let eight: Vec = std::iter::repeat_n(f.chain[0].clone(), 8).collect(); + let r = verify_attestation( + &eight, + &f.approval_spki, + b"challenge-bytes", + std::slice::from_ref(&f.root), + NOW, + ); + assert_ne!(reason(r), "attestation chain is missing or too long"); + } + + #[test] + fn rejects_bad_base64_and_bad_der() { + let f = fixture(Opts::default()); + let r = verify_attestation( + &["!!!".into()], + &f.approval_spki, + b"c", + std::slice::from_ref(&f.root), + NOW, + ); + assert_eq!(reason(r), "attestation certificate cannot be read"); + let r = verify_attestation( + &["AAAA".into()], + &f.approval_spki, + b"c", + std::slice::from_ref(&f.root), + NOW, + ); + assert_eq!(reason(r), "attestation certificate cannot be read"); + let r = verify_attestation( + &f.chain, + "!!!", + b"challenge-bytes", + std::slice::from_ref(&f.root), + NOW, + ); + assert_eq!(reason(r), "attested key is not the approval key"); + } + + #[test] + fn a_self_signed_chain_of_one_is_not_trusted_unless_it_is_a_root() { + let f = fixture(Opts::default()); + let single = vec![STANDARD.encode(&f.root)]; + let r = verify_attestation( + &single, + &f.approval_spki, + b"challenge-bytes", + std::slice::from_ref(&f.root), + NOW, + ); + // trusted root, but it carries no key description + assert_eq!(reason(r), "attestation key description is missing"); + } +} diff --git a/crates/rostdav-app/src/push_mfa/error.rs b/crates/rostdav-app/src/push_mfa/error.rs new file mode 100644 index 0000000..4328d63 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/error.rs @@ -0,0 +1,112 @@ +//! Errors of the push sign-in approval. Each has the code, HTTP status and message of the +//! contract (`rustmfa-v1.yaml`); messages never contain secrets or key material. + +use rostdav_core::Error; + +#[derive(Debug)] +pub enum MfaError { + /// The request is malformed or breaks a rule (the reason is shown to the client). + InvalidRequest(&'static str), + Unauthenticated, + DeviceNotAuthorized, + DeviceRevoked, + NotFound, + AlreadyConsumed, + NumberMismatch, + Expired, + RateLimited { + retry_after_secs: u64, + }, + Internal(Error), +} + +impl MfaError { + /// The `error.code` of the response body. + pub fn code(&self) -> &'static str { + match self { + Self::InvalidRequest(_) => "INVALID_REQUEST", + Self::Unauthenticated => "UNAUTHENTICATED", + Self::DeviceNotAuthorized => "DEVICE_NOT_AUTHORIZED", + Self::DeviceRevoked => "DEVICE_REVOKED", + Self::NotFound => "RESOURCE_NOT_FOUND", + Self::AlreadyConsumed => "CHALLENGE_ALREADY_CONSUMED", + Self::NumberMismatch => "CHALLENGE_NUMBER_MISMATCH", + Self::Expired => "CHALLENGE_EXPIRED", + Self::RateLimited { .. } => "RATE_LIMITED", + Self::Internal(_) => "INTERNAL_ERROR", + } + } + + /// The HTTP status. + pub fn status(&self) -> u16 { + match self { + Self::InvalidRequest(_) => 400, + Self::Unauthenticated => 401, + Self::DeviceNotAuthorized | Self::DeviceRevoked => 403, + Self::NotFound => 404, + Self::AlreadyConsumed | Self::NumberMismatch => 409, + Self::Expired => 410, + Self::RateLimited { .. } => 429, + Self::Internal(_) => 500, + } + } + + /// The `error.message` of the response body. + pub fn message(&self) -> &'static str { + match self { + Self::InvalidRequest(reason) => reason, + Self::Unauthenticated => "Authentication is missing or invalid.", + Self::DeviceNotAuthorized => "This device is not authorized for this request.", + Self::DeviceRevoked => "This device has been revoked.", + Self::NotFound => "The resource was not found.", + Self::AlreadyConsumed => "The challenge has already been answered.", + Self::NumberMismatch => "The selected number does not match.", + Self::Expired => "The challenge has expired.", + Self::RateLimited { .. } => "Too many requests.", + Self::Internal(_) => "Internal error.", + } + } +} + +impl From for MfaError { + fn from(e: Error) -> Self { + Self::Internal(e) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_and_statuses_match_the_contract() { + let table: Vec<(MfaError, &str, u16)> = vec![ + (MfaError::InvalidRequest("x"), "INVALID_REQUEST", 400), + (MfaError::Unauthenticated, "UNAUTHENTICATED", 401), + (MfaError::DeviceNotAuthorized, "DEVICE_NOT_AUTHORIZED", 403), + (MfaError::DeviceRevoked, "DEVICE_REVOKED", 403), + (MfaError::NotFound, "RESOURCE_NOT_FOUND", 404), + (MfaError::AlreadyConsumed, "CHALLENGE_ALREADY_CONSUMED", 409), + (MfaError::NumberMismatch, "CHALLENGE_NUMBER_MISMATCH", 409), + (MfaError::Expired, "CHALLENGE_EXPIRED", 410), + ( + MfaError::RateLimited { + retry_after_secs: 3, + }, + "RATE_LIMITED", + 429, + ), + (MfaError::Internal(Error::NotFound), "INTERNAL_ERROR", 500), + ]; + for (e, code, status) in table { + assert_eq!((e.code(), e.status()), (code, status)); + assert!(!e.message().is_empty()); + } + } + + #[test] + fn internal_errors_do_not_leak_their_cause() { + let e = MfaError::from(Error::BadRequest("secret detail")); + assert!(!e.message().contains("secret")); + } +} diff --git a/crates/rostdav-app/src/push_mfa/google_roots.rs b/crates/rostdav-app/src/push_mfa/google_roots.rs new file mode 100644 index 0000000..f67351c --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/google_roots.rs @@ -0,0 +1,108 @@ +//! Google's Android key attestation roots, pinned (ADR 0014). +//! +//! Source: the list at , cross-checked against the +//! certificates on +//! on 2026-11-08. Only the two certificates that both sources list are pinned: +//! +//! | SHA-256 of the DER | Key | Valid until | +//! |---|---|---| +//! | `cedb1cb6dc896ae5ec797348bce9286753c2b38ee71ce0fbe34a9a1248800dfc` | RSA 4096, `serialNumber=f92009e853b6b045` | 2042-03-15 | +//! | `6d9db4ce6c5c0b293166d08986e05774a8776ceb525d9e4329520de12ba4bcc0` | ECDSA P-384, `CN=Key Attestation CA1` | 2035-07-15 | +//! +//! The documentation page also lists three older roots (valid from 2016, 2019 and 2021). The +//! endpoint does not return them, so they are not pinned: a phone whose chain ends there is +//! rejected until `[mfa] attestation = "off"` or a new release says otherwise. + +use base64::{Engine, engine::general_purpose::STANDARD}; + +const ROOTS: [&str; 2] = [ + concat!( + "MIIFHDCCAwSgAwIBAgIJAPHBcqaZ6vUdMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV", + "BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMjIwMzIwMTgwNzQ4WhcNNDIwMzE1MTgw", + "NzQ4WjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B", + "AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS", + "Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7", + "tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj", + "nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq", + "C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ", + "oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O", + "JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg", + "sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi", + "igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M", + "RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E", + "aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um", + "AGMCAwEAAaNjMGEwHQYDVR0OBBYEFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMB8GA1Ud", + "IwQYMBaAFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMA8GA1UdEwEB/wQFMAMBAf8wDgYD", + "VR0PAQH/BAQDAgIEMA0GCSqGSIb3DQEBCwUAA4ICAQB8cMqTllHc8U+qCrOlg3H7", + "174lmaCsbo/bJ0C17JEgMLb4kvrqsXZs01U3mB/qABg/1t5Pd5AORHARs1hhqGIC", + "W/nKMav574f9rZN4PC2ZlufGXb7sIdJpGiO9ctRhiLuYuly10JccUZGEHpHSYM2G", + "tkgYbZba6lsCPYAAP83cyDV+1aOkTf1RCp/lM0PKvmxYN10RYsK631jrleGdcdkx", + "oSK//mSQbgcWnmAEZrzHoF1/0gso1HZgIn0YLzVhLSA/iXCX4QT2h3J5z3znluKG", + "1nv8NQdxei2DIIhASWfu804CA96cQKTTlaae2fweqXjdN1/v2nqOhngNyz1361mF", + "mr4XmaKH/ItTwOe72NI9ZcwS1lVaCvsIkTDCEXdm9rCNPAY10iTunIHFXRh+7KPz", + "lHGewCq/8TOohBRn0/NNfh7uRslOSZ/xKbN9tMBtw37Z8d2vvnXq/YWdsm1+JLVw", + "n6yYD/yacNJBlwpddla8eaVMjsF6nBnIgQOf9zKSe06nSTqvgwUHosgOECZJZ1Eu", + "zbH4yswbt02tKtKEFhx+v+OTge/06V+jGsqTWLsfrOCNLuA8H++z+pUENmpqnnHo", + "vaI47gC+TNpkgYGkkBT6B/m/U01BuOBBTzhIlMEZq9qkDWuM2cA5kW5V3FJUcfHn", + "w1IdYIg2Wxg7yHcQZemFQg==", + ), + concat!( + "MIICIjCCAaigAwIBAgIRAISp0Cl7DrWK5/8OgN52BgUwCgYIKoZIzj0EAwMwUjEc", + "MBoGA1UEAwwTS2V5IEF0dGVzdGF0aW9uIENBMTEQMA4GA1UECwwHQW5kcm9pZDET", + "MBEGA1UECgwKR29vZ2xlIExMQzELMAkGA1UEBhMCVVMwHhcNMjUwNzE3MjIzMjE4", + "WhcNMzUwNzE1MjIzMjE4WjBSMRwwGgYDVQQDDBNLZXkgQXR0ZXN0YXRpb24gQ0Ex", + "MRAwDgYDVQQLDAdBbmRyb2lkMRMwEQYDVQQKDApHb29nbGUgTExDMQswCQYDVQQG", + "EwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABCPaI3FO3z5bBQo8cuiEas4HjqCt", + "G/mLFfRT0MsIssPBEEU5Cfbt6sH5yOAxqEi5QagpU1yX4HwnGb7OtBYpDTB57uH5", + "Eczm34A5FNijV3s0/f0UPl7zbJcTx6xwqMIRq6NCMEAwDwYDVR0TAQH/BAUwAwEB", + "/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFIyuyz7RkOb3NaBqQ5lZuA0QepA", + "MAoGCCqGSM49BAMDA2gAMGUCMETfjPO/HwqReR2CS7p0ZWoD/LHs6hDi422opifH", + "EUaYLxwGlT9SLdjkVpz0UUOR5wIxAIoGyxGKRHVTpqpGRFiJtQEOOTp/+s1GcxeY", + "uR2zh/80lQyu9vAFCj6E4AXc+osmRg==", + ), +]; + +/// The pinned roots as DER. +pub fn google_roots() -> Vec> { + ROOTS + .iter() + .filter_map(|b| STANDARD.decode(b).ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::google_roots; + + #[test] + fn two_roots_with_the_recorded_fingerprints() { + let fps: Vec = google_roots() + .iter() + .map(|d| crate::push_mfa::sha256_hex(d)) + .collect(); + assert_eq!( + fps, + vec![ + "cedb1cb6dc896ae5ec797348bce9286753c2b38ee71ce0fbe34a9a1248800dfc", + "6d9db4ce6c5c0b293166d08986e05774a8776ceb525d9e4329520de12ba4bcc0", + ] + ); + } + + #[test] + fn roots_parse_and_are_self_signed() { + use der::{Decode, Encode}; + use x509_cert::Certificate; + for der_bytes in google_roots() { + let cert = Certificate::from_der(&der_bytes).expect("parses"); + let spki = cert.tbs_certificate.subject_public_key_info.clone(); + assert!( + crate::push_mfa::attestation::verify_cert_signature(&der_bytes, &cert, &spki), + "self-signature of {:?}", + cert.tbs_certificate.subject.to_string() + ); + assert_eq!(cert.tbs_certificate.subject, cert.tbs_certificate.issuer); + assert!(cert.to_der().expect("der") == der_bytes, "DER round trip"); + } + } +} diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs index 9481665..40dde9a 100644 --- a/crates/rostdav-app/src/push_mfa/mod.rs +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -1,5 +1,13 @@ //! Push sign-in approval with the RustMFA app (ADR 0014). +mod attestation; +mod error; +mod google_roots; + +pub use attestation::{AttestationResult, verify_attestation}; +pub use error::MfaError; +pub use google_roots::google_roots; + use crate::AppState; /// Whether a registering phone must prove a hardware-backed key. @@ -41,6 +49,15 @@ impl AppState { } } +#[cfg(test)] +pub(crate) fn sha256_hex(data: &[u8]) -> String { + ring::digest::digest(&ring::digest::SHA256, data) + .as_ref() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + #[cfg(test)] mod tests { use crate::{AppState, AttestationPolicy, MfaConfig}; diff --git a/crates/rostdav-app/src/secrets.rs b/crates/rostdav-app/src/secrets.rs index 314aa55..8c909d6 100644 --- a/crates/rostdav-app/src/secrets.rs +++ b/crates/rostdav-app/src/secrets.rs @@ -41,15 +41,19 @@ pub fn mac(key: &[u8; 32], parts: &[&str]) -> String { h.finalize().to_hex().to_string() } -/// Constant-time comparison of two MAC strings. -pub fn mac_eq(a: &str, b: &str) -> bool { - let (a, b) = (a.as_bytes(), b.as_bytes()); +/// Constant-time comparison of two byte strings (the length is not secret). +pub fn ct_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } +/// Constant-time comparison of two MAC strings. +pub fn mac_eq(a: &str, b: &str) -> bool { + ct_eq(a.as_bytes(), b.as_bytes()) +} + /// Encrypts a short secret for storage: `base64(nonce || ciphertext)`. `aad` ties the /// value to its row, so it does not decrypt when copied to another one. pub fn seal(key: &[u8; 32], aad: &str, plain: &str) -> rostdav_core::Result { diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index c4a350d..1fdf6ce 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -50,8 +50,13 @@ Migration `0013_push_mfa.sql` with four `STRICT` tables, a plain index on every - `rustmfa-contract` (own crate, `rostdav-mfa` repository): signing formats and verification shared with the app and its test vectors. **Decision:** git dependency pinned by revision. Until that repository is published, development uses a local `[patch]` in an uncommitted `.cargo/config.toml`; the dependency is committed only once the repository exists. - `x509-cert` and `der` (RustCrypto, MIT/Apache): parse the attestation chain and the Android key description. -- `rcgen` (dev only): synthetic attestation chains in tests. -- Android attestation root certificates are pinned in the source. They are fetched from Google's published key-attestation documentation at implementation time, with the source address and each SHA-256 fingerprint recorded in this ADR before the code merges. +- `ring` (already in the tree through the update check, ISC-style licence the policy allows): verifies the certificate signatures (RSA and ECDSA P-256/P-384). This avoids adding separate RSA and ECDSA crates, and `rcgen` is not needed: the tests build synthetic chains with `x509-cert` and sign them with `ring`. +- Android attestation roots are pinned in `push_mfa/google_roots.rs`. Source and check on 2026-11-08: the list at `https://android.googleapis.com/attestation/root` against the certificates on Google's key-attestation documentation page. Pinned are the two that both list: + - RSA 4096, `serialNumber=f92009e853b6b045`, valid to 2042-03-15, SHA-256 `cedb1cb6dc896ae5ec797348bce9286753c2b38ee71ce0fbe34a9a1248800dfc` + - ECDSA P-384, `CN=Key Attestation CA1`, valid to 2035-07-15, SHA-256 `6d9db4ce6c5c0b293166d08986e05774a8776ceb525d9e4329520de12ba4bcc0` + + The documentation page lists three older roots (from 2016, 2019 and 2021) that the endpoint does not return. They are not pinned. A phone whose chain ends in one of them is rejected, and `attestation = "off"` is the way out until a release pins more roots. +- Google's attestation revocation list (`https://android.googleapis.com/attestation/status`) is not consulted: it needs a network request at registration. A revoked attestation key is therefore still accepted. Out of scope for now; the owner can revoke a device by hand. ### Out of scope From 00e736490da08108d8617d620d5813be8b6cb064 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Sun, 8 Nov 2026 20:51:23 +0100 Subject: [PATCH 05/24] chore: ignore .openidea folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 36e0f13..27884f8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ graphify-out/ scripts/verify/node_modules/ scripts/verify/out/ .playwright-mcp/ +.openidea/ From f697bd7fae1389573073fffafd33756c6b45d101 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Mon, 9 Nov 2026 20:36:25 +0100 Subject: [PATCH 06/24] feat(mfa): device enrollment Add the enrollment service for push-MFA (ADR 0014): a user creates a single-use enroll token (5 per user per 15 minutes), and the phone registers with it after its Android key attestation and public keys validate (5 failures per address per 15 minutes). Share a test kit between the attestation and enrollment tests, and inject test attestation roots through AppState. The rustmfa-contract dependency is a temporary path dependency; ADR 0014 records that it must become a pinned git dependency before merge. --- CHANGELOG.md | 1 + Cargo.lock | 188 ++++++- Cargo.toml | 5 +- crates/rostdav-app/Cargo.toml | 1 + crates/rostdav-app/src/lib.rs | 18 +- .../rostdav-app/src/push_mfa/attestation.rs | 195 +------ crates/rostdav-app/src/push_mfa/enroll.rs | 222 ++++++++ .../rostdav-app/src/push_mfa/enroll_tests.rs | 511 ++++++++++++++++++ crates/rostdav-app/src/push_mfa/mod.rs | 6 + crates/rostdav-app/src/push_mfa/testkit.rs | 237 ++++++++ docs/admin.md | 2 + docs/adr/0014-push-mfa.md | 1 + fuzz/Cargo.lock | 247 ++++++++- 13 files changed, 1432 insertions(+), 202 deletions(-) create mode 100644 crates/rostdav-app/src/push_mfa/enroll.rs create mode 100644 crates/rostdav-app/src/push_mfa/enroll_tests.rs create mode 100644 crates/rostdav-app/src/push_mfa/testkit.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index efc8f81..efb6a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https - **Settings for push sign-in approval** (`[mfa]` section: challenge lifetime, enrollment lifetime, audience, attestation policy), the first step of ADR 0014. They have no effect yet. - **Database tables for push sign-in approval** (migration 0013: phones, enrollment tokens, challenges, request nonces). Nothing uses them yet. - **Key attestation check for push sign-in approval phones**: verifies that a phone's key lives in secure hardware (certificate chain to a pinned Google root, hardware security level, challenge, key match). Nothing calls it yet. +- **Enrollment service for the RustMFA app**: a signed-in user creates a single-use token (QR code), the phone registers its two public keys with the token and an attestation. Wrong tokens and failed attestations are rate limited per address. Not reachable over HTTP yet. ## [0.10.0] - 2026-10-25 diff --git a/Cargo.lock b/Cargo.lock index bb1c72e..59a20cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,6 +339,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -864,6 +870,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -991,6 +1009,7 @@ dependencies = [ "const-oid 0.9.6", "der_derive", "flagset", + "pem-rfc7468", "zeroize", ] @@ -1021,6 +1040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1054,12 +1074,46 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1184,6 +1238,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "figment" version = "0.10.19" @@ -1349,6 +1413,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1408,6 +1473,17 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -1454,6 +1530,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" @@ -2391,6 +2476,18 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "pack1" version = "1.1.0" @@ -2472,7 +2569,7 @@ dependencies = [ "regex", "serde", "serde_json", - "sha2", + "sha2 0.11.1", "smallvec", "stringprep", "subsetter", @@ -2509,6 +2606,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2564,6 +2670,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2679,6 +2795,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2941,6 +3066,16 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2975,7 +3110,7 @@ dependencies = [ "clap", "figment", "futures-core", - "hmac", + "hmac 0.13.0", "http-body-util", "httpdate", "percent-encoding", @@ -3009,7 +3144,7 @@ dependencies = [ "chacha20poly1305", "der", "encoding_rs", - "hmac", + "hmac 0.13.0", "minisign-verify", "office_oxide", "pdf_oxide", @@ -3019,6 +3154,7 @@ dependencies = [ "rostdav-core", "rostdav-meta", "rostdav-storage", + "rustmfa-contract", "serde_json", "sha1", "tempfile", @@ -3215,6 +3351,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustmfa-contract" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "p256", + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3239,6 +3386,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -3337,6 +3498,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.1" @@ -3399,6 +3571,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" diff --git a/Cargo.toml b/Cargo.toml index d0057ee..4098f19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,10 @@ argon2 = { version = "0.5", features = ["std"] } ulid = "3" chacha20poly1305 = "0.11" hmac = "0.13" -# Push sign-in approval (ADR 0014): attestation chains +# Push sign-in approval (ADR 0014) +# TEMPORARY path dependency on the sibling checkout of the rostdav-mfa repository. Replace it with +# a git dependency pinned by revision before this branch is merged (ADR 0014, "Dependencies"). +rustmfa-contract = { path = "../rostdav-mfa/contract/rustmfa-contract" } x509-cert = { version = "0.2", default-features = false } der = { version = "0.7", features = ["alloc", "oid"] } ring = { version = "0.17", default-features = false, features = ["alloc"] } diff --git a/crates/rostdav-app/Cargo.toml b/crates/rostdav-app/Cargo.toml index a0ec65d..fcb3825 100644 --- a/crates/rostdav-app/Cargo.toml +++ b/crates/rostdav-app/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true publish.workspace = true [dependencies] +rustmfa-contract.workspace = true x509-cert.workspace = true der.workspace = true ring.workspace = true diff --git a/crates/rostdav-app/src/lib.rs b/crates/rostdav-app/src/lib.rs index ea28cec..4683492 100644 --- a/crates/rostdav-app/src/lib.rs +++ b/crates/rostdav-app/src/lib.rs @@ -57,7 +57,8 @@ pub use login_flow::FlowStep; pub use login_flow::{Granted, StartedFlow}; pub use password::{MIN_PASSWORD_CHARS, hash_password, verify_password}; pub use push_mfa::{ - AttestationPolicy, AttestationResult, MfaConfig, MfaError, google_roots, verify_attestation, + AttestationPolicy, AttestationResult, EnrollToken, MfaConfig, MfaError, RegisterInput, + Registered, google_roots, verify_attestation, }; pub use search::{KindFilter, MAX_VISITED, SearchHit, SearchPage, SearchQuery, SnippetPart}; pub use shares::{ @@ -94,6 +95,11 @@ struct Inner { limiter: ratelimit::RateLimiter, /// Wrong second-factor codes: five per user in 15 minutes (ADR 0007). totp_limiter: ratelimit::RateLimiter, + /// Push sign-in approval: failed registrations per address and enrollment tokens per user, + /// five in 15 minutes each (ADR 0014). + mfa_limiter: ratelimit::RateLimiter, + /// Trusted attestation roots that replace Google's in tests. + mfa_roots: std::sync::Mutex>>>, /// Instance ID used in Nextcloud-style file IDs (`oc:id`), e.g. `oc` + 10 characters. instance_id: String, /// 32-byte key for stateless tickets and CSRF values (stored in `settings`). @@ -220,6 +226,11 @@ impl AppState { 5, std::time::Duration::from_secs(15 * 60), ), + mfa_limiter: ratelimit::RateLimiter::new( + 5, + std::time::Duration::from_secs(15 * 60), + ), + mfa_roots: std::sync::Mutex::new(None), instance_id, server_key, maintenance: maintenance.into(), @@ -257,6 +268,11 @@ impl AppState { 5, std::time::Duration::from_secs(15 * 60), ), + mfa_limiter: ratelimit::RateLimiter::new( + 5, + std::time::Duration::from_secs(15 * 60), + ), + mfa_roots: std::sync::Mutex::new(None), instance_id: old.instance_id.clone(), server_key: old.server_key, maintenance: old diff --git a/crates/rostdav-app/src/push_mfa/attestation.rs b/crates/rostdav-app/src/push_mfa/attestation.rs index 79c765c..50f8216 100644 --- a/crates/rostdav-app/src/push_mfa/attestation.rs +++ b/crates/rostdav-app/src/push_mfa/attestation.rs @@ -260,201 +260,8 @@ fn parse_key_description(value: &[u8]) -> Option> { #[cfg(test)] mod tests { use super::*; + use crate::push_mfa::testkit::*; use base64::{Engine, engine::general_purpose::STANDARD}; - use der::asn1::{BitString, ObjectIdentifier, OctetString, UtcTime}; - use der::{Any, Encode}; - use ring::rand::SystemRandom; - use ring::signature::{ECDSA_P256_SHA256_ASN1_SIGNING, EcdsaKeyPair, KeyPair}; - use std::str::FromStr; - use std::time::Duration; - use x509_cert::Certificate; - use x509_cert::TbsCertificate; - use x509_cert::ext::Extension; - use x509_cert::name::Name; - use x509_cert::serial_number::SerialNumber; - use x509_cert::spki::{AlgorithmIdentifierOwned, SubjectPublicKeyInfoOwned}; - use x509_cert::time::{Time, Validity}; - - const NOW: i64 = 1_800_000_000; - const EC_PUBLIC_KEY: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); - const P256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7"); - const ECDSA_SHA256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2"); - const KEY_DESCRIPTION: ObjectIdentifier = - ObjectIdentifier::new_unwrap("1.3.6.1.4.1.11129.2.1.17"); - - fn key() -> EcdsaKeyPair { - let rng = SystemRandom::new(); - let pkcs8 = - EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).expect("key"); - EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng) - .expect("pair") - } - - fn spki(kp: &EcdsaKeyPair) -> SubjectPublicKeyInfoOwned { - SubjectPublicKeyInfoOwned { - algorithm: AlgorithmIdentifierOwned { - oid: EC_PUBLIC_KEY, - parameters: Some(Any::encode_from(&P256).expect("param")), - }, - subject_public_key: BitString::from_bytes(kp.public_key().as_ref()).expect("bits"), - } - } - - fn spki_b64(kp: &EcdsaKeyPair) -> String { - STANDARD.encode(spki(kp).to_der().expect("der")) - } - - fn time(secs: i64) -> Time { - let d = Duration::from_secs(u64::try_from(secs).expect("positive")); - Time::UtcTime(UtcTime::from_unix_duration(d).expect("utc time")) - } - - fn tlv(tag: u8, content: &[u8]) -> Vec { - let mut out = vec![tag]; - match content.len() { - n if n < 128 => out.push(u8::try_from(n).expect("short")), - n => { - out.push(0x82); - out.extend(u16::try_from(n).expect("len").to_be_bytes()); - } - } - out.extend(content); - out - } - - /// The value of the Android key description extension (only the fields the verifier reads). - fn key_description(attestation_level: u8, keymaster_level: u8, challenge: &[u8]) -> Vec { - let mut seq = Vec::new(); - seq.extend(tlv(0x02, &[4])); // attestationVersion - seq.extend(tlv(0x0a, &[attestation_level])); - seq.extend(tlv(0x02, &[4])); // keymasterVersion - seq.extend(tlv(0x0a, &[keymaster_level])); - seq.extend(tlv(0x04, challenge)); // attestationChallenge - seq.extend(tlv(0x04, &[])); // uniqueId - seq.extend(tlv(0x30, &[])); // softwareEnforced - seq.extend(tlv(0x30, &[])); // teeEnforced - tlv(0x30, &seq) - } - - fn issue( - subject: &str, - subject_kp: &EcdsaKeyPair, - issuer: &str, - issuer_kp: &EcdsaKeyPair, - validity: (i64, i64), - extension: Option>, - ) -> Vec { - let alg = AlgorithmIdentifierOwned { - oid: ECDSA_SHA256, - parameters: None, - }; - let tbs = TbsCertificate { - version: x509_cert::certificate::Version::V3, - serial_number: SerialNumber::new(&[1]).expect("serial"), - signature: alg.clone(), - issuer: Name::from_str(issuer).expect("issuer"), - validity: Validity { - not_before: time(validity.0), - not_after: time(validity.1), - }, - subject: Name::from_str(subject).expect("subject"), - subject_public_key_info: spki(subject_kp), - issuer_unique_id: None, - subject_unique_id: None, - extensions: extension.map(|v| { - vec![Extension { - extn_id: KEY_DESCRIPTION, - critical: false, - extn_value: OctetString::new(v).expect("octets"), - }] - }), - }; - let sig = issuer_kp - .sign(&SystemRandom::new(), &tbs.to_der().expect("tbs")) - .expect("sign"); - Certificate { - tbs_certificate: tbs, - signature_algorithm: alg, - signature: BitString::from_bytes(sig.as_ref()).expect("sig"), - } - .to_der() - .expect("cert") - } - - struct Opts { - attestation_level: u8, - keymaster_level: u8, - challenge: Vec, - leaf_validity: (i64, i64), - with_extension: bool, - include_root: bool, - leaf_issuer: &'static str, - } - - impl Default for Opts { - fn default() -> Self { - Self { - attestation_level: 1, - keymaster_level: 1, - challenge: b"challenge-bytes".to_vec(), - leaf_validity: (NOW - 1000, NOW + 1000), - with_extension: true, - include_root: true, - leaf_issuer: "CN=Test Intermediate", - } - } - } - - struct Fixture { - chain: Vec, - approval_spki: String, - root: Vec, - leaf_der: Vec, - intermediate_der: Vec, - } - - fn fixture(o: Opts) -> Fixture { - let (root_kp, inter_kp, leaf_kp) = (key(), key(), key()); - let wide = (NOW - 100_000, NOW + 100_000); - let root = issue( - "CN=Test Root", - &root_kp, - "CN=Test Root", - &root_kp, - wide, - None, - ); - let inter = issue( - "CN=Test Intermediate", - &inter_kp, - "CN=Test Root", - &root_kp, - wide, - None, - ); - let ext = o - .with_extension - .then(|| key_description(o.attestation_level, o.keymaster_level, &o.challenge)); - let leaf = issue( - "CN=Test Leaf", - &leaf_kp, - o.leaf_issuer, - &inter_kp, - o.leaf_validity, - ext, - ); - let mut chain = vec![STANDARD.encode(&leaf), STANDARD.encode(&inter)]; - if o.include_root { - chain.push(STANDARD.encode(&root)); - } - Fixture { - chain, - approval_spki: spki_b64(&leaf_kp), - root, - leaf_der: leaf, - intermediate_der: inter, - } - } fn verify(f: &Fixture) -> Result { verify_attestation( diff --git a/crates/rostdav-app/src/push_mfa/enroll.rs b/crates/rostdav-app/src/push_mfa/enroll.rs new file mode 100644 index 0000000..d3e8318 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/enroll.rs @@ -0,0 +1,222 @@ +//! Enrolling a phone (ADR 0014): a single-use token from a signed-in account, then +//! registration with the phone's two public keys and a key attestation. + +use std::net::IpAddr; + +use rostdav_meta::NewDevice; +use serde_json::json; + +use super::attestation::{AttestationResult, verify_attestation}; +use super::{AttestationPolicy, MfaError, google_roots}; +use crate::audit::sanitize_text; +use crate::secrets::{hash_token, random_token}; +use crate::{AppState, Principal, Scope, Via, now_secs}; + +const TOKEN_LEN: usize = 40; +const MAX_TOKEN_INPUT: usize = 128; +const MAX_PUSH_TOKEN: usize = 4096; +/// One message for unknown, used, expired and foreign tokens, so nothing is learned from it. +const TOKEN_REASON: &str = "enrollment token is invalid or expired"; + +/// A token to put in the enrollment QR code. +#[derive(Debug)] +pub struct EnrollToken { + pub token: String, + pub expires_in_seconds: i64, +} + +/// What the phone sends to register. No `Debug`: it holds the enrollment token. +pub struct RegisterInput { + pub enroll_token: String, + pub device_name: String, + /// Base64 of the X.509 SubjectPublicKeyInfo DER; signs the requests. + pub request_public_key: String, + /// Same encoding; biometric-gated, signs the challenge answers. + pub approval_public_key: String, + pub key_algorithm: String, + /// Base64 DER certificates, leaf first, for the approval key. + pub attestation_chain: Vec, + pub push_channel: String, + pub push_token: String, +} + +#[derive(Debug)] +pub struct Registered { + pub device_id: String, + pub status: &'static str, +} + +fn invalid(reason: &'static str) -> MfaError { + MfaError::InvalidRequest(reason) +} + +fn sha256(data: &[u8]) -> Vec { + ring::digest::digest(&ring::digest::SHA256, data) + .as_ref() + .to_vec() +} + +/// Checks the input without touching the token; returns the cleaned device name. +fn validate(i: &RegisterInput) -> Result { + if i.enroll_token.is_empty() || i.enroll_token.len() > MAX_TOKEN_INPUT { + return Err(invalid(TOKEN_REASON)); + } + if i.key_algorithm != "ES256" { + return Err(invalid("key algorithm must be ES256")); + } + if rustmfa_contract::check_spki(&i.request_public_key).is_err() { + return Err(invalid("request public key is not a P-256 key")); + } + if rustmfa_contract::check_spki(&i.approval_public_key).is_err() { + return Err(invalid("approval public key is not a P-256 key")); + } + if i.request_public_key == i.approval_public_key { + return Err(invalid("request key and approval key must differ")); + } + if !matches!(i.push_channel.as_str(), "fcm" | "unifiedpush") { + return Err(invalid("push channel must be fcm or unifiedpush")); + } + if i.push_token.is_empty() + || i.push_token.len() > MAX_PUSH_TOKEN + || i.push_token.chars().any(char::is_control) + { + return Err(invalid("push token is missing or too long")); + } + let name = sanitize_text(&i.device_name).trim().to_owned(); + if name.is_empty() { + return Err(invalid("device name is missing")); + } + Ok(name) +} + +impl AppState { + /// A single-use token for the enrollment QR code. Only a user who signed in with the + /// account itself may create one: not an app password, not a public link. + pub async fn mfa_create_enroll_token(&self, p: &Principal) -> Result { + if p.via != Via::AccountPassword || p.scope != Scope::Full { + return Err(MfaError::Unauthenticated); + } + let key = format!("mfa-enroll:{}", p.user_id); + self.inner + .mfa_limiter + .check(&key) + .map_err(|retry_after_secs| MfaError::RateLimited { retry_after_secs })?; + let ttl = self.mfa_cfg().enroll_ttl_secs; + let token = random_token(TOKEN_LEN); + self.inner + .db + .mfa_enroll_token_put(&hash_token(&token), p.user_id, now_secs() + ttl) + .await?; + // every token counts, not only failures: five per 15 minutes + self.inner.mfa_limiter.record_failure(&key); + self.audit(&p.name, "mfa.enroll_token_created", "", None, json!({})) + .await; + Ok(EnrollToken { + token, + expires_in_seconds: ttl, + }) + } + + /// Registers a phone. A wrong token or a failed attestation counts against the caller's + /// address (five in 15 minutes); a malformed request does not. The token is used up before + /// the attestation is checked, so a failed attempt burns it. + pub async fn mfa_register_device( + &self, + input: RegisterInput, + ip: IpAddr, + ) -> Result { + let key = format!("mfa-register:{ip}"); + self.inner + .mfa_limiter + .check(&key) + .map_err(|retry_after_secs| MfaError::RateLimited { retry_after_secs })?; + let name = validate(&input)?; + let result = self.register_checked(&input, &name, ip).await; + if matches!(result, Err(MfaError::InvalidRequest(_))) { + self.inner.mfa_limiter.record_failure(&key); + } + result + } + + async fn register_checked( + &self, + input: &RegisterInput, + name: &str, + ip: IpAddr, + ) -> Result { + let now = now_secs(); + let user_id = self + .inner + .db + .mfa_enroll_token_consume(&hash_token(&input.enroll_token), now) + .await? + .ok_or(invalid(TOKEN_REASON))?; + let user = self + .inner + .db + .find_user_by_id(user_id) + .await? + .filter(|u| !u.disabled) + .ok_or(invalid(TOKEN_REASON))?; + let attested = match self.mfa_cfg().attestation { + AttestationPolicy::Required => verify_attestation( + &input.attestation_chain, + &input.approval_public_key, + &sha256(input.enroll_token.as_bytes()), + &self.attestation_roots(), + now, + )?, + AttestationPolicy::Off => AttestationResult { + hardware_backed: false, + strongbox: false, + }, + }; + let device_id = format!("dev_{}", random_token(22)); + self.inner + .db + .mfa_device_insert(&NewDevice { + id: &device_id, + user_id, + name, + request_pub: &input.request_public_key, + approval_pub: &input.approval_public_key, + push_channel: &input.push_channel, + push_token: &input.push_token, + hardware_backed: attested.hardware_backed, + strongbox: attested.strongbox, + }) + .await?; + self.audit( + &user.name, + "mfa.device_registered", + &device_id, + Some(ip), + json!({ + "hardware_backed": attested.hardware_backed, + "strongbox": attested.strongbox, + "push_channel": input.push_channel, + }), + ) + .await; + Ok(Registered { + device_id, + status: "active", + }) + } + + /// Google's roots, unless a test set its own. + fn attestation_roots(&self) -> Vec> { + self.inner + .mfa_roots + .lock() + .map_or_else(|e| e.into_inner().clone(), |g| g.clone()) + .unwrap_or_else(google_roots) + } + + #[cfg(test)] + pub(crate) fn set_attestation_roots_for_tests(&self, roots: Vec>) { + if let Ok(mut g) = self.inner.mfa_roots.lock() { + *g = Some(roots); + } + } +} diff --git a/crates/rostdav-app/src/push_mfa/enroll_tests.rs b/crates/rostdav-app/src/push_mfa/enroll_tests.rs new file mode 100644 index 0000000..7967075 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/enroll_tests.rs @@ -0,0 +1,511 @@ +use std::net::{IpAddr, Ipv4Addr}; + +use crate::push_mfa::testkit::{Opts, add_user, fixture, key, spki_b64, state}; +use crate::push_mfa::{AttestationPolicy, MfaConfig, MfaError}; +use crate::{AppState, Principal, Scope, Via}; + +use super::enroll::RegisterInput; + +const IP: IpAddr = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)); + +fn sha256(data: &[u8]) -> Vec { + ring::digest::digest(&ring::digest::SHA256, data) + .as_ref() + .to_vec() +} + +fn input(token: &str, f: &crate::push_mfa::testkit::Fixture) -> RegisterInput { + RegisterInput { + enroll_token: token.to_owned(), + device_name: "Pixel 8".into(), + request_public_key: spki_b64(&key()), + approval_public_key: f.approval_spki.clone(), + key_algorithm: "ES256".into(), + attestation_chain: f.chain.clone(), + push_channel: "fcm".into(), + push_token: "fcm-token".into(), + } +} + +/// A token for `p`, and a chain that attests a key for exactly that token. +async fn enrollable(st: &AppState, p: &Principal) -> (String, crate::push_mfa::testkit::Fixture) { + let token = st.mfa_create_enroll_token(p).await.expect("token").token; + let f = fixture(Opts { + challenge: sha256(token.as_bytes()), + ..opts() + }); + st.set_attestation_roots_for_tests(vec![f.root.clone()]); + (token, f) +} + +/// Chains that are valid now: the service reads the real clock. +fn opts() -> Opts { + let now = crate::now_secs(); + Opts { + base: now, + leaf_validity: (now - 1000, now + 1000), + ..Opts::default() + } +} + +/// One way to break a registration request. +type Mutation = Box; + +fn invalid_reason(r: Result) -> &'static str { + match r { + Err(MfaError::InvalidRequest(why)) => why, + other => panic!("expected InvalidRequest, got {other:?}"), + } +} + +#[tokio::test] +async fn enrollment_happy_path_creates_an_active_device() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let (token, f) = enrollable(&st, &anna).await; + let done = st + .mfa_register_device(input(&token, &f), IP) + .await + .expect("register"); + assert_eq!(done.status, "active"); + assert!(done.device_id.starts_with("dev_") && done.device_id.len() > 12); + let row = st + .inner + .db + .mfa_device_get(&done.device_id) + .await + .expect("get") + .expect("row"); + assert_eq!((row.user_id, row.status.as_str()), (anna.user_id, "active")); + assert_eq!((row.hardware_backed, row.strongbox), (true, false)); + assert_eq!(row.approval_pub, f.approval_spki); + assert_eq!( + (row.push_channel.as_str(), row.push_token.as_str()), + ("fcm", "fcm-token") + ); +} + +#[tokio::test] +async fn token_has_the_configured_lifetime() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let t = st.mfa_create_enroll_token(&anna).await.expect("token"); + assert_eq!(t.expires_in_seconds, 300); + assert!(t.token.len() >= 40); + let st = st.with_mfa(MfaConfig { + enroll_ttl_secs: 120, + ..MfaConfig::default() + }); + assert_eq!( + st.mfa_create_enroll_token(&anna) + .await + .expect("token") + .expires_in_seconds, + 120 + ); +} + +#[tokio::test] +async fn token_is_single_use() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let (token, f) = enrollable(&st, &anna).await; + st.mfa_register_device(input(&token, &f), IP) + .await + .expect("first"); + let again = st.mfa_register_device(input(&token, &f), IP).await; + assert_eq!( + invalid_reason(again), + "enrollment token is invalid or expired" + ); + assert_eq!( + st.inner + .db + .mfa_devices_of(anna.user_id) + .await + .expect("list") + .len(), + 1 + ); +} + +#[tokio::test] +async fn unknown_and_expired_tokens_look_the_same() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let (_token, f) = enrollable(&st, &anna).await; + let unknown = st.mfa_register_device(input("no-such-token", &f), IP).await; + assert_eq!( + invalid_reason(unknown), + "enrollment token is invalid or expired" + ); + let expired_hash = crate::secrets::hash_token("old-token"); + st.inner + .db + .mfa_enroll_token_put(&expired_hash, anna.user_id, crate::now_secs() - 1) + .await + .expect("put"); + let expired = st.mfa_register_device(input("old-token", &f), IP).await; + assert_eq!( + invalid_reason(expired), + "enrollment token is invalid or expired" + ); +} + +#[tokio::test] +async fn device_belongs_to_the_user_who_created_the_token() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let bob = add_user(&st, "bob").await; + let (anna_token, f) = enrollable(&st, &anna).await; + let (bob_token, g) = enrollable(&st, &bob).await; + st.set_attestation_roots_for_tests(vec![f.root.clone(), g.root.clone()]); + let a = st + .mfa_register_device(input(&anna_token, &f), IP) + .await + .expect("anna"); + let b = st + .mfa_register_device(input(&bob_token, &g), IP) + .await + .expect("bob"); + let owner = |id: String| { + let st = st.clone(); + async move { + st.inner + .db + .mfa_device_get(&id) + .await + .expect("get") + .expect("row") + .user_id + } + }; + assert_eq!(owner(a.device_id).await, anna.user_id); + assert_eq!(owner(b.device_id).await, bob.user_id); +} + +#[tokio::test] +async fn token_is_stored_only_hashed() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let token = st + .mfa_create_enroll_token(&anna) + .await + .expect("token") + .token; + let now = crate::now_secs(); + assert_eq!( + st.inner + .db + .mfa_enroll_token_consume(&token, now) + .await + .expect("plain"), + None + ); + let hashed = crate::secrets::hash_token(&token); + assert_eq!( + st.inner + .db + .mfa_enroll_token_consume(&hashed, now) + .await + .expect("hash"), + Some(anna.user_id) + ); +} + +#[tokio::test] +async fn software_attestation_is_rejected_and_burns_the_token() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let token = st + .mfa_create_enroll_token(&anna) + .await + .expect("token") + .token; + let f = fixture(Opts { + attestation_level: 0, + keymaster_level: 0, + challenge: sha256(token.as_bytes()), + ..opts() + }); + st.set_attestation_roots_for_tests(vec![f.root.clone()]); + let r = st.mfa_register_device(input(&token, &f), IP).await; + assert_eq!(invalid_reason(r), "key is not hardware backed"); + assert!( + st.inner + .db + .mfa_devices_of(anna.user_id) + .await + .expect("list") + .is_empty() + ); + let retry = st.mfa_register_device(input(&token, &f), IP).await; + assert_eq!( + invalid_reason(retry), + "enrollment token is invalid or expired" + ); +} + +#[tokio::test] +async fn attestation_for_another_token_is_rejected() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let token = st + .mfa_create_enroll_token(&anna) + .await + .expect("token") + .token; + let f = fixture(Opts { + challenge: sha256(b"some other token"), + ..opts() + }); + st.set_attestation_roots_for_tests(vec![f.root.clone()]); + let r = st.mfa_register_device(input(&token, &f), IP).await; + assert_eq!(invalid_reason(r), "attestation challenge does not match"); + assert!( + st.inner + .db + .mfa_devices_of(anna.user_id) + .await + .expect("list") + .is_empty() + ); +} + +#[tokio::test] +async fn untrusted_root_is_rejected_by_default() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let token = st + .mfa_create_enroll_token(&anna) + .await + .expect("token") + .token; + let f = fixture(Opts { + challenge: sha256(token.as_bytes()), + ..opts() + }); + // no test roots set: the pinned Google roots apply, and a synthetic chain does not reach them + let r = st.mfa_register_device(input(&token, &f), IP).await; + assert_eq!( + invalid_reason(r), + "attestation chain does not end in a trusted root" + ); +} + +#[tokio::test] +async fn policy_off_skips_attestation() { + let (_d, st) = state().await; + let st = st.with_mfa(MfaConfig { + attestation: AttestationPolicy::Off, + ..MfaConfig::default() + }); + let anna = add_user(&st, "anna").await; + let token = st + .mfa_create_enroll_token(&anna) + .await + .expect("token") + .token; + let f = fixture(opts()); + let mut i = input(&token, &f); + i.attestation_chain = Vec::new(); + let done = st.mfa_register_device(i, IP).await.expect("register"); + let row = st + .inner + .db + .mfa_device_get(&done.device_id) + .await + .expect("get") + .expect("row"); + assert_eq!((row.hardware_backed, row.strongbox), (false, false)); +} + +#[tokio::test] +async fn bad_input_is_rejected_without_burning_the_token() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let (token, f) = enrollable(&st, &anna).await; + let cases: Vec<(&str, Mutation)> = vec![ + ( + "key algorithm must be ES256", + Box::new(|i| i.key_algorithm = "RS256".into()), + ), + ( + "request public key is not a P-256 key", + Box::new(|i| i.request_public_key = "AAAA".into()), + ), + ( + "approval public key is not a P-256 key", + Box::new(|i| i.approval_public_key = "!!!".into()), + ), + ( + "push channel must be fcm or unifiedpush", + Box::new(|i| i.push_channel = "apns".into()), + ), + ( + "push token is missing or too long", + Box::new(|i| i.push_token = String::new()), + ), + ( + "push token is missing or too long", + Box::new(|i| i.push_token = "x".repeat(4097)), + ), + ( + "push token is missing or too long", + Box::new(|i| i.push_token = "to\nken".into()), + ), + ( + "device name is missing", + Box::new(|i| i.device_name = " \n\t".into()), + ), + ]; + for (why, mutate) in cases { + let mut i = input(&token, &f); + mutate(&mut i); + assert_eq!(invalid_reason(st.mfa_register_device(i, IP).await), why); + } + // the token still works after all those mistakes + assert!(st.mfa_register_device(input(&token, &f), IP).await.is_ok()); +} + +#[tokio::test] +async fn both_keys_must_differ() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let (token, f) = enrollable(&st, &anna).await; + let mut i = input(&token, &f); + i.request_public_key = f.approval_spki.clone(); + assert_eq!( + invalid_reason(st.mfa_register_device(i, IP).await), + "request key and approval key must differ" + ); +} + +#[tokio::test] +async fn device_name_is_cleaned() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let (token, f) = enrollable(&st, &anna).await; + let mut i = input(&token, &f); + i.device_name = "My\nPhone\u{7}".into(); + let done = st.mfa_register_device(i, IP).await.expect("register"); + let row = st + .inner + .db + .mfa_device_get(&done.device_id) + .await + .expect("get") + .expect("row"); + assert_eq!(row.name, "MyPhone"); +} + +#[tokio::test] +async fn registration_is_rate_limited_per_address_after_failures() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let (token, f) = enrollable(&st, &anna).await; + for _ in 0..5 { + let r = st.mfa_register_device(input("guess", &f), IP).await; + assert!(matches!(r, Err(MfaError::InvalidRequest(_)))); + } + let blocked = st.mfa_register_device(input(&token, &f), IP).await; + assert!( + matches!(blocked, Err(MfaError::RateLimited { retry_after_secs }) if retry_after_secs > 0) + ); + // another address is not affected + let other = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7)); + assert!( + st.mfa_register_device(input(&token, &f), other) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn token_creation_is_rate_limited_per_user() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let bob = add_user(&st, "bob").await; + for _ in 0..5 { + st.mfa_create_enroll_token(&anna).await.expect("token"); + } + assert!(matches!( + st.mfa_create_enroll_token(&anna).await, + Err(MfaError::RateLimited { .. }) + )); + assert!(st.mfa_create_enroll_token(&bob).await.is_ok()); +} + +#[tokio::test] +async fn only_a_signed_in_account_can_create_tokens() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let app = Principal { + via: Via::AppPassword(1), + ..anna.clone() + }; + let link = Principal { + via: Via::Link(1), + ..anna.clone() + }; + let scoped = Principal { + scope: Scope::Link { + share_id: 1, + node_id: 1, + perms: 1, + }, + ..anna.clone() + }; + for p in [app, link, scoped] { + assert!(matches!( + st.mfa_create_enroll_token(&p).await, + Err(MfaError::Unauthenticated) + )); + } +} + +#[tokio::test] +async fn a_disabled_user_cannot_enroll() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let (token, f) = enrollable(&st, &anna).await; + st.inner + .db + .set_user_disabled(anna.user_id, true) + .await + .expect("disable"); + let r = st.mfa_register_device(input(&token, &f), IP).await; + assert_eq!(invalid_reason(r), "enrollment token is invalid or expired"); +} + +#[tokio::test] +async fn audit_records_token_and_device_without_secrets() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let (token, f) = enrollable(&st, &anna).await; + let done = st + .mfa_register_device(input(&token, &f), IP) + .await + .expect("register"); + let events = st + .audit_events(&crate::Actor::Cli, 0) + .await + .expect("events"); + let actions: Vec<&str> = events.iter().map(|e| e.action.as_str()).collect(); + assert!(actions.contains(&"mfa.enroll_token_created"), "{actions:?}"); + let reg = events + .iter() + .find(|e| e.action == "mfa.device_registered") + .expect("registered"); + assert_eq!( + (reg.actor.as_str(), reg.target.as_str()), + ("anna", done.device_id.as_str()) + ); + let all: String = events + .iter() + .map(|e| format!("{} {} {}", e.target, e.details, e.actor)) + .collect(); + for secret in [token.as_str(), f.approval_spki.as_str(), "fcm-token"] { + assert!(!all.contains(secret), "audit contains {secret}"); + } +} diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs index 40dde9a..1eb5715 100644 --- a/crates/rostdav-app/src/push_mfa/mod.rs +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -1,10 +1,16 @@ //! Push sign-in approval with the RustMFA app (ADR 0014). mod attestation; +mod enroll; +#[cfg(test)] +mod enroll_tests; mod error; mod google_roots; +#[cfg(test)] +pub(crate) mod testkit; pub use attestation::{AttestationResult, verify_attestation}; +pub use enroll::{EnrollToken, RegisterInput, Registered}; pub use error::MfaError; pub use google_roots::google_roots; diff --git a/crates/rostdav-app/src/push_mfa/testkit.rs b/crates/rostdav-app/src/push_mfa/testkit.rs new file mode 100644 index 0000000..a2a42c0 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/testkit.rs @@ -0,0 +1,237 @@ +//! Test helpers: synthetic Android attestation chains signed with throwaway keys. + +use base64::{Engine, engine::general_purpose::STANDARD}; +use der::asn1::{BitString, ObjectIdentifier, OctetString, UtcTime}; +use der::{Any, Encode}; +use ring::rand::SystemRandom; +use ring::signature::{ECDSA_P256_SHA256_ASN1_SIGNING, EcdsaKeyPair, KeyPair}; +use std::str::FromStr; +use std::time::Duration; +use x509_cert::Certificate; +use x509_cert::TbsCertificate; +use x509_cert::ext::Extension; +use x509_cert::name::Name; +use x509_cert::serial_number::SerialNumber; +use x509_cert::spki::{AlgorithmIdentifierOwned, SubjectPublicKeyInfoOwned}; +use x509_cert::time::{Time, Validity}; + +use crate::{AppState, Principal, Scope, Via}; +use rostdav_meta::Db; +use rostdav_storage::LocalStore; + +pub(crate) const NOW: i64 = 1_800_000_000; +pub(crate) const EC_PUBLIC_KEY: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); +pub(crate) const P256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7"); +pub(crate) const ECDSA_SHA256: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2"); +pub(crate) const KEY_DESCRIPTION: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.3.6.1.4.1.11129.2.1.17"); + +pub(crate) fn key() -> EcdsaKeyPair { + let rng = SystemRandom::new(); + let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).expect("key"); + EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng).expect("pair") +} + +pub(crate) fn spki(kp: &EcdsaKeyPair) -> SubjectPublicKeyInfoOwned { + SubjectPublicKeyInfoOwned { + algorithm: AlgorithmIdentifierOwned { + oid: EC_PUBLIC_KEY, + parameters: Some(Any::encode_from(&P256).expect("param")), + }, + subject_public_key: BitString::from_bytes(kp.public_key().as_ref()).expect("bits"), + } +} + +pub(crate) fn spki_b64(kp: &EcdsaKeyPair) -> String { + STANDARD.encode(spki(kp).to_der().expect("der")) +} + +pub(crate) fn time(secs: i64) -> Time { + let d = Duration::from_secs(u64::try_from(secs).expect("positive")); + Time::UtcTime(UtcTime::from_unix_duration(d).expect("utc time")) +} + +pub(crate) fn tlv(tag: u8, content: &[u8]) -> Vec { + let mut out = vec![tag]; + match content.len() { + n if n < 128 => out.push(u8::try_from(n).expect("short")), + n => { + out.push(0x82); + out.extend(u16::try_from(n).expect("len").to_be_bytes()); + } + } + out.extend(content); + out +} + +/// The value of the Android key description extension (only the fields the verifier reads). +pub(crate) fn key_description( + attestation_level: u8, + keymaster_level: u8, + challenge: &[u8], +) -> Vec { + let mut seq = Vec::new(); + seq.extend(tlv(0x02, &[4])); // attestationVersion + seq.extend(tlv(0x0a, &[attestation_level])); + seq.extend(tlv(0x02, &[4])); // keymasterVersion + seq.extend(tlv(0x0a, &[keymaster_level])); + seq.extend(tlv(0x04, challenge)); // attestationChallenge + seq.extend(tlv(0x04, &[])); // uniqueId + seq.extend(tlv(0x30, &[])); // softwareEnforced + seq.extend(tlv(0x30, &[])); // teeEnforced + tlv(0x30, &seq) +} + +pub(crate) fn issue( + subject: &str, + subject_kp: &EcdsaKeyPair, + issuer: &str, + issuer_kp: &EcdsaKeyPair, + validity: (i64, i64), + extension: Option>, +) -> Vec { + let alg = AlgorithmIdentifierOwned { + oid: ECDSA_SHA256, + parameters: None, + }; + let tbs = TbsCertificate { + version: x509_cert::certificate::Version::V3, + serial_number: SerialNumber::new(&[1]).expect("serial"), + signature: alg.clone(), + issuer: Name::from_str(issuer).expect("issuer"), + validity: Validity { + not_before: time(validity.0), + not_after: time(validity.1), + }, + subject: Name::from_str(subject).expect("subject"), + subject_public_key_info: spki(subject_kp), + issuer_unique_id: None, + subject_unique_id: None, + extensions: extension.map(|v| { + vec![Extension { + extn_id: KEY_DESCRIPTION, + critical: false, + extn_value: OctetString::new(v).expect("octets"), + }] + }), + }; + let sig = issuer_kp + .sign(&SystemRandom::new(), &tbs.to_der().expect("tbs")) + .expect("sign"); + Certificate { + tbs_certificate: tbs, + signature_algorithm: alg, + signature: BitString::from_bytes(sig.as_ref()).expect("sig"), + } + .to_der() + .expect("cert") +} + +pub(crate) struct Opts { + pub(crate) attestation_level: u8, + pub(crate) keymaster_level: u8, + pub(crate) challenge: Vec, + pub(crate) leaf_validity: (i64, i64), + pub(crate) with_extension: bool, + pub(crate) include_root: bool, + /// The time the root and intermediate certificates are valid around. + pub(crate) base: i64, + pub(crate) leaf_issuer: &'static str, +} + +impl Default for Opts { + fn default() -> Self { + Self { + attestation_level: 1, + keymaster_level: 1, + challenge: b"challenge-bytes".to_vec(), + leaf_validity: (NOW - 1000, NOW + 1000), + with_extension: true, + include_root: true, + base: NOW, + leaf_issuer: "CN=Test Intermediate", + } + } +} + +pub(crate) struct Fixture { + pub(crate) chain: Vec, + pub(crate) approval_spki: String, + pub(crate) root: Vec, + pub(crate) leaf_der: Vec, + pub(crate) intermediate_der: Vec, + #[allow(dead_code)] // signs the challenge answers in the challenge tests + pub(crate) approval_kp: EcdsaKeyPair, +} + +pub(crate) fn fixture(o: Opts) -> Fixture { + let (root_kp, inter_kp, leaf_kp) = (key(), key(), key()); + let wide = (o.base - 100_000, o.base + 100_000); + let root = issue( + "CN=Test Root", + &root_kp, + "CN=Test Root", + &root_kp, + wide, + None, + ); + let inter = issue( + "CN=Test Intermediate", + &inter_kp, + "CN=Test Root", + &root_kp, + wide, + None, + ); + let ext = o + .with_extension + .then(|| key_description(o.attestation_level, o.keymaster_level, &o.challenge)); + let leaf = issue( + "CN=Test Leaf", + &leaf_kp, + o.leaf_issuer, + &inter_kp, + o.leaf_validity, + ext, + ); + let mut chain = vec![STANDARD.encode(&leaf), STANDARD.encode(&inter)]; + if o.include_root { + chain.push(STANDARD.encode(&root)); + } + Fixture { + chain, + approval_spki: spki_b64(&leaf_kp), + root, + leaf_der: leaf, + intermediate_der: inter, + approval_kp: leaf_kp, + } +} + +/// A fresh application state in a temporary directory. +pub(crate) async fn state() -> (tempfile::TempDir, AppState) { + let dir = tempfile::tempdir().expect("tempdir"); + let store = LocalStore::open(dir.path().join("data")) + .await + .expect("store"); + let db = Db::open(&Db::default_url(store.root())).await.expect("db"); + db.migrate().await.expect("migrate"); + (dir, AppState::new(db, store).await.expect("state")) +} + +/// A user who signed in with the account password, as the web interface sees them. +pub(crate) async fn add_user(st: &AppState, name: &str) -> Principal { + let u = st + .add_user(name, "correct-horse-battery", false) + .await + .expect("user"); + Principal { + user_id: u.id, + name: name.to_owned(), + is_admin: false, + via: Via::AccountPassword, + scope: Scope::Full, + } +} diff --git a/docs/admin.md b/docs/admin.md index e9779b6..1bd23bc 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -142,6 +142,8 @@ Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa A value outside its range stops the server at start-up with a message naming the key. +**Limits.** A user can create five enrollment tokens in 15 minutes. A client address that sends five wrong or unusable enrollment tokens, or five attestation chains that fail, is blocked from registering for 15 minutes (these blocks are not listed on the lockouts page yet). Malformed requests do not count. Device names are shortened to 128 characters and stripped of control characters. + ## Version check Off by default. Administration, Server, "Updates" turns it on: the server then asks the GitHub releases API once a day (and on "Check now") which version is the newest, and shows a newer one with its release notes. The request carries only `User-Agent: rostdav/`. While it is off, the server makes no outbound connection. After an error the server tries again in an hour; the page shows the reason. The switch is kept in the database, so a restart keeps it. Events: `admin.update_check_on`, `admin.update_check_off`, `admin.update_installed`. The About page (every user) shows the release notes of the running build and the versions before it; they are compiled in and need no network. diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index 1fdf6ce..5574af3 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -49,6 +49,7 @@ Migration `0013_push_mfa.sql` with four `STRICT` tables, a plain index on every ### Dependencies (justification) - `rustmfa-contract` (own crate, `rostdav-mfa` repository): signing formats and verification shared with the app and its test vectors. **Decision:** git dependency pinned by revision. Until that repository is published, development uses a local `[patch]` in an uncommitted `.cargo/config.toml`; the dependency is committed only once the repository exists. + **Amendment 2026-11-08:** the owner chose a temporary path dependency on the sibling checkout (`../rostdav-mfa/contract/rustmfa-contract`, marked in `Cargo.toml`) for the development branch. It must become a git dependency pinned by revision before the branch is merged into `main`; until then the branch builds only next to that checkout. - `x509-cert` and `der` (RustCrypto, MIT/Apache): parse the attestation chain and the Android key description. - `ring` (already in the tree through the update check, ISC-style licence the policy allows): verifies the certificate signatures (RSA and ECDSA P-256/P-384). This avoids adding separate RSA and ECDSA crates, and `rcgen` is not needed: the tests build synthetic chains with `x509-cert` and sign them with `ring`. - Android attestation roots are pinned in `push_mfa/google_roots.rs`. Source and check on 2026-11-08: the list at `https://android.googleapis.com/attestation/root` against the certificates on Google's key-attestation documentation page. Pinned are the two that both list: diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index f1d7e4b..54ee7cf 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -336,6 +336,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -713,6 +719,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -819,6 +831,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -936,6 +960,30 @@ dependencies = [ "thiserror", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.9" @@ -952,6 +1000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -963,7 +1012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -985,12 +1034,46 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.42" @@ -1120,12 +1203,28 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.10" @@ -1263,6 +1362,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1322,6 +1422,17 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -1368,6 +1479,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" @@ -2315,6 +2435,18 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "pack1" version = "1.1.0" @@ -2396,7 +2528,7 @@ dependencies = [ "regex", "serde", "serde_json", - "sha2", + "sha2 0.11.1", "smallvec", "stringprep", "subsetter", @@ -2410,6 +2542,15 @@ dependencies = [ "weezl 0.2.1", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2465,6 +2606,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2590,6 +2741,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2839,6 +2999,16 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2871,21 +3041,25 @@ dependencies = [ "base64 0.23.1", "blake3", "chacha20poly1305", + "der", "encoding_rs", - "hmac", + "hmac 0.13.0", "minisign-verify", "office_oxide", "pdf_oxide", "qrcode", "quick-xml", + "ring", "rostdav-core", "rostdav-meta", "rostdav-storage", + "rustmfa-contract", "serde_json", "sha1", "tokio", "tracing", "ureq", + "x509-cert", "zip", ] @@ -3051,6 +3225,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustmfa-contract" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "p256", + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3075,6 +3260,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -3164,6 +3363,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.1" @@ -3226,6 +3436,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3315,6 +3535,16 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4577,6 +4807,17 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", +] + [[package]] name = "yoke" version = "0.8.4" From 66bfdc36ae8e12dcf4a29b3dd1b5e08148fd6acf Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Mon, 9 Nov 2026 21:04:04 +0100 Subject: [PATCH 07/24] feat(mfa): device request authentication Verify the signed Authorization header of a RustMFA phone (ADR 0014): strict header grammar, 60 second timestamp skew, ES256 signature by the request key, nonce used once per device in a 5 minute window, phone and account active. The nonce is recorded after the signature verifies, and a revoked phone is named as such only to a caller who holds its key. Five failures per device in 15 minutes block it for 15 minutes. --- CHANGELOG.md | 1 + Cargo.lock | 1 + crates/rostdav-app/Cargo.toml | 1 + crates/rostdav-app/src/lib.rs | 4 +- crates/rostdav-app/src/push_mfa/devauth.rs | 172 ++++++ .../rostdav-app/src/push_mfa/devauth_tests.rs | 504 ++++++++++++++++++ crates/rostdav-app/src/push_mfa/mod.rs | 4 + docs/admin.md | 2 + docs/adr/0014-push-mfa.md | 2 +- 9 files changed, 688 insertions(+), 3 deletions(-) create mode 100644 crates/rostdav-app/src/push_mfa/devauth.rs create mode 100644 crates/rostdav-app/src/push_mfa/devauth_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index efb6a2e..dc548c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https - **Database tables for push sign-in approval** (migration 0013: phones, enrollment tokens, challenges, request nonces). Nothing uses them yet. - **Key attestation check for push sign-in approval phones**: verifies that a phone's key lives in secure hardware (certificate chain to a pinned Google root, hardware security level, challenge, key match). Nothing calls it yet. - **Enrollment service for the RustMFA app**: a signed-in user creates a single-use token (QR code), the phone registers its two public keys with the token and an attestation. Wrong tokens and failed attestations are rate limited per address. Not reachable over HTTP yet. +- **Request authentication for the RustMFA app**: checks the signed `Authorization: RustMFA-Sig` header of a phone (timestamp within 60 seconds, signature by the request key, nonce used once in 5 minutes, phone and account still active). Five failures for one phone in 15 minutes block it for 15 minutes. Not reachable over HTTP yet. ## [0.10.0] - 2026-10-25 diff --git a/Cargo.lock b/Cargo.lock index 59a20cc..f1057e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3160,6 +3160,7 @@ dependencies = [ "tempfile", "tokio", "tracing", + "tracing-subscriber", "ureq", "x509-cert", "zip", diff --git a/crates/rostdav-app/Cargo.toml b/crates/rostdav-app/Cargo.toml index fcb3825..d4a29ad 100644 --- a/crates/rostdav-app/Cargo.toml +++ b/crates/rostdav-app/Cargo.toml @@ -34,6 +34,7 @@ quick-xml.workspace = true minisign-verify.workspace = true [dev-dependencies] +tracing-subscriber.workspace = true tempfile.workspace = true serde_json.workspace = true diff --git a/crates/rostdav-app/src/lib.rs b/crates/rostdav-app/src/lib.rs index 4683492..4789cb7 100644 --- a/crates/rostdav-app/src/lib.rs +++ b/crates/rostdav-app/src/lib.rs @@ -57,8 +57,8 @@ pub use login_flow::FlowStep; pub use login_flow::{Granted, StartedFlow}; pub use password::{MIN_PASSWORD_CHARS, hash_password, verify_password}; pub use push_mfa::{ - AttestationPolicy, AttestationResult, EnrollToken, MfaConfig, MfaError, RegisterInput, - Registered, google_roots, verify_attestation, + AttestationPolicy, AttestationResult, DevicePrincipal, EnrollToken, MfaConfig, MfaError, + ParsedAuth, RegisterInput, Registered, google_roots, parse_authorization, verify_attestation, }; pub use search::{KindFilter, MAX_VISITED, SearchHit, SearchPage, SearchQuery, SnippetPart}; pub use shares::{ diff --git a/crates/rostdav-app/src/push_mfa/devauth.rs b/crates/rostdav-app/src/push_mfa/devauth.rs new file mode 100644 index 0000000..5c8100b --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/devauth.rs @@ -0,0 +1,172 @@ +//! Authenticating a phone's requests (ADR 0014). +//! +//! Every request carries `Authorization: RustMFA-Sig device_id="…",ts="…",nonce="…",sig="…"`. +//! The signature is made with the device's non-biometric request key over the request signing +//! string of the contract (method, path and query, timestamp, nonce, body hash). + +use super::MfaError; +use crate::AppState; + +/// How far a request timestamp may differ from the server clock. +const MAX_SKEW_SECS: i64 = 60; +/// How long a used nonce is remembered. It must exceed twice the skew. +const NONCE_WINDOW_SECS: i64 = 300; +const SCHEME: &str = "RustMFA-Sig "; +const MAX_VALUE: usize = 128; + +/// The phone and account behind a verified request. +#[derive(Debug, PartialEq, Eq)] +pub struct DevicePrincipal { + pub device_id: String, + pub user_id: i64, +} + +/// The parts of an `Authorization: RustMFA-Sig …` header. No `Debug`: it holds a signature. +pub struct ParsedAuth { + pub device_id: String, + pub ts: i64, + pub nonce: String, + pub sig: String, +} + +/// The value of `name="…"`: one to 128 characters of `[A-Za-z0-9_-]`. +fn field<'a>(part: &'a str, name: &str) -> Option<&'a str> { + let value = part + .strip_prefix(name)? + .strip_prefix("=\"")? + .strip_suffix('"')?; + let ok = !value.is_empty() + && value.len() <= MAX_VALUE + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); + ok.then_some(value) +} + +/// Strict grammar, fixed order, no spaces after the commas. Anything else is `Unauthenticated`. +pub fn parse_authorization(header: &str) -> Result { + let parts: Vec<&str> = header + .strip_prefix(SCHEME) + .ok_or(MfaError::Unauthenticated)? + .split(',') + .collect(); + let [device_id, ts, nonce, sig] = parts.as_slice() else { + return Err(MfaError::Unauthenticated); + }; + let device_id = field(device_id, "device_id").ok_or(MfaError::Unauthenticated)?; + let ts = field(ts, "ts").ok_or(MfaError::Unauthenticated)?; + let nonce = field(nonce, "nonce").ok_or(MfaError::Unauthenticated)?; + let sig = field(sig, "sig").ok_or(MfaError::Unauthenticated)?; + if !ts.bytes().all(|b| b.is_ascii_digit()) { + return Err(MfaError::Unauthenticated); + } + Ok(ParsedAuth { + device_id: device_id.to_owned(), + ts: ts.parse().map_err(|_| MfaError::Unauthenticated)?, + nonce: nonce.to_owned(), + sig: sig.to_owned(), + }) +} + +impl AppState { + /// Verifies a request of a phone. Five failures for one device in 15 minutes block it. + pub async fn mfa_authenticate_device( + &self, + header: &str, + method: &str, + path_and_query: &str, + body: &[u8], + ) -> Result { + self.mfa_authenticate_device_at(header, method, path_and_query, body, crate::now_secs()) + .await + } + + /// Order: parse, rate limit, skew, device lookup, signature, revoked and account checks, + /// nonce. The nonce is recorded last, so a bad signature cannot burn one. A revoked device is + /// only named as such to someone who proved they hold its key. + pub(crate) async fn mfa_authenticate_device_at( + &self, + header: &str, + method: &str, + path_and_query: &str, + body: &[u8], + now: i64, + ) -> Result { + let auth = parse_authorization(header)?; + let key = format!("mfa-auth:{}", auth.device_id); + self.inner + .mfa_limiter + .check(&key) + .map_err(|retry_after_secs| MfaError::RateLimited { retry_after_secs })?; + let result = self + .verify_device_request(&auth, method, path_and_query, body, now) + .await; + match &result { + Ok(_) => self.inner.mfa_limiter.clear(&key), + Err(MfaError::Unauthenticated) => { + self.inner.mfa_limiter.record_failure(&key); + } + Err(_) => {} + } + result + } + + async fn verify_device_request( + &self, + auth: &ParsedAuth, + method: &str, + path_and_query: &str, + body: &[u8], + now: i64, + ) -> Result { + let reject = |reason: &'static str| { + tracing::debug!(device_id = %auth.device_id, reason, "device request rejected"); + MfaError::Unauthenticated + }; + if auth.ts.abs_diff(now) > MAX_SKEW_SECS.unsigned_abs() { + return Err(reject("timestamp outside the allowed skew")); + } + let device = self + .inner + .db + .mfa_device_get(&auth.device_id) + .await? + .ok_or_else(|| reject("unknown device"))?; + let message = rustmfa_contract::request_signing_string( + method, + path_and_query, + auth.ts, + &auth.nonce, + body, + ) + .map_err(|_| reject("request cannot be signed"))?; + rustmfa_contract::verify_es256_raw(&device.request_pub, message.as_bytes(), &auth.sig) + .map_err(|_| reject("signature does not verify"))?; + if device.status != "active" { + return Err(MfaError::DeviceRevoked); + } + let user_active = self + .inner + .db + .find_user_by_id(device.user_id) + .await? + .is_some_and(|u| !u.disabled); + if !user_active { + return Err(reject("account is disabled")); + } + self.inner.db.mfa_nonces_purge(now).await?; + if !self + .inner + .db + .mfa_nonce_insert(&auth.device_id, &auth.nonce, now + NONCE_WINDOW_SECS) + .await? + { + return Err(reject("nonce was already used")); + } + self.inner.db.mfa_device_touch(&auth.device_id, now).await?; + Ok(DevicePrincipal { + device_id: device.id, + user_id: device.user_id, + }) + } +} diff --git a/crates/rostdav-app/src/push_mfa/devauth_tests.rs b/crates/rostdav-app/src/push_mfa/devauth_tests.rs new file mode 100644 index 0000000..81e1e6e --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/devauth_tests.rs @@ -0,0 +1,504 @@ +use std::sync::{Arc, Mutex}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ring::rand::SystemRandom; +use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair}; +use rostdav_meta::NewDevice; + +use super::devauth::parse_authorization; +use crate::AppState; +use crate::push_mfa::MfaError; +use crate::push_mfa::testkit::{NOW, add_user, spki_b64, state}; + +const PATH: &str = "/mfa/v1/challenges?status=pending"; + +/// A key whose signatures are raw R||S, as the phone's are. +fn fixed_key() -> EcdsaKeyPair { + let rng = SystemRandom::new(); + let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).expect("key"); + EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).expect("pair") +} + +struct Phone { + id: String, + request: EcdsaKeyPair, + approval: EcdsaKeyPair, + user_id: i64, +} + +async fn enrolled(st: &AppState, user: &str, id: &str) -> Phone { + let p = add_user(st, user).await; + let (request, approval) = (fixed_key(), fixed_key()); + st.inner + .db + .mfa_device_insert(&NewDevice { + id, + user_id: p.user_id, + name: "Pixel", + request_pub: &spki_b64(&request), + approval_pub: &spki_b64(&approval), + push_channel: "fcm", + push_token: "t", + hardware_backed: true, + strongbox: false, + }) + .await + .expect("insert"); + Phone { + id: id.to_owned(), + request, + approval, + user_id: p.user_id, + } +} + +fn sign_with( + key: &EcdsaKeyPair, + method: &str, + path: &str, + ts: i64, + nonce: &str, + body: &[u8], +) -> String { + let msg = rustmfa_contract::request_signing_string(method, path, ts, nonce, body).expect("msg"); + let sig = key.sign(&SystemRandom::new(), msg.as_bytes()).expect("sig"); + URL_SAFE_NO_PAD.encode(sig.as_ref()) +} + +fn header(device: &str, ts: i64, nonce: &str, sig: &str) -> String { + format!(r#"RustMFA-Sig device_id="{device}",ts="{ts}",nonce="{nonce}",sig="{sig}""#) +} + +/// A correctly signed GET of `PATH`. +fn good(p: &Phone, ts: i64, nonce: &str) -> String { + header( + &p.id, + ts, + nonce, + &sign_with(&p.request, "GET", PATH, ts, nonce, b""), + ) +} + +async fn auth(st: &AppState, h: &str, now: i64) -> Result { + st.mfa_authenticate_device_at(h, "GET", PATH, b"", now) + .await +} + +fn is_unauth(r: Result) -> bool { + matches!(r, Err(MfaError::Unauthenticated)) +} + +#[tokio::test] +async fn valid_signed_request_authenticates() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let got = auth(&st, &good(&p, NOW, "n1"), NOW).await.expect("auth"); + assert_eq!((got.device_id.as_str(), got.user_id), ("dev_a", p.user_id)); +} + +#[tokio::test] +async fn skew_over_60_seconds_is_rejected() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + assert!(is_unauth(auth(&st, &good(&p, NOW - 61, "n1"), NOW).await)); + assert!(is_unauth(auth(&st, &good(&p, NOW + 61, "n2"), NOW).await)); + // the edges are inside + assert!(auth(&st, &good(&p, NOW - 60, "n3"), NOW).await.is_ok()); + assert!(auth(&st, &good(&p, NOW + 60, "n4"), NOW).await.is_ok()); +} + +#[tokio::test] +async fn replayed_nonce_is_rejected() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let h = good(&p, NOW, "n1"); + assert!(auth(&st, &h, NOW).await.is_ok()); + assert!(is_unauth(auth(&st, &h, NOW + 1).await)); +} + +#[tokio::test] +async fn the_same_nonce_of_two_devices_does_not_collide() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let b = enrolled(&st, "ben", "dev_b").await; + assert!(auth(&st, &good(&a, NOW, "same"), NOW).await.is_ok()); + assert!(auth(&st, &good(&b, NOW, "same"), NOW).await.is_ok()); +} + +#[tokio::test] +async fn nonce_after_window_is_accepted_again_and_old_nonces_purged() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + assert!(auth(&st, &good(&p, NOW, "n1"), NOW).await.is_ok()); + // still inside the window at 299 s + assert!(is_unauth( + auth(&st, &good(&p, NOW + 299, "n1"), NOW + 299).await + )); + // 301 s later the old row is purged and the nonce works again + assert!( + auth(&st, &good(&p, NOW + 301, "n1"), NOW + 301) + .await + .is_ok() + ); + assert!( + auth(&st, &good(&p, NOW + 302, "n2"), NOW + 302) + .await + .is_ok() + ); + // only the nonces of the last window are left: n1 (new) and n2 + assert_eq!( + st.inner + .db + .mfa_nonces_purge(NOW + 10_000) + .await + .expect("purge"), + 2 + ); +} + +#[tokio::test] +async fn revoked_device_gives_device_revoked() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + assert!( + st.inner + .db + .mfa_device_revoke("dev_a", p.user_id, NOW) + .await + .expect("revoke") + ); + let r = auth(&st, &good(&p, NOW, "n1"), NOW).await; + assert!(matches!(r, Err(MfaError::DeviceRevoked)), "{r:?}"); +} + +#[tokio::test] +async fn a_forged_request_for_a_revoked_device_does_not_reveal_the_revocation() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + st.inner + .db + .mfa_device_revoke("dev_a", p.user_id, NOW) + .await + .expect("revoke"); + let forged = header( + "dev_a", + NOW, + "n1", + &sign_with(&fixed_key(), "GET", PATH, NOW, "n1", b""), + ); + assert!(is_unauth(auth(&st, &forged, NOW).await)); +} + +#[tokio::test] +async fn a_disabled_account_cannot_authenticate_a_device() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + st.inner + .db + .set_user_disabled(p.user_id, true) + .await + .expect("disable"); + assert!(is_unauth(auth(&st, &good(&p, NOW, "n1"), NOW).await)); +} + +#[tokio::test] +async fn unknown_device_gives_unauthenticated() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let ghost = Phone { + id: "dev_ghost".into(), + request: fixed_key(), + approval: fixed_key(), + user_id: p.user_id, + }; + assert!(is_unauth(auth(&st, &good(&ghost, NOW, "n1"), NOW).await)); +} + +#[tokio::test] +async fn tampered_body_or_query_is_rejected() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let sig = sign_with(&p.request, "POST", "/mfa/v1/x?a=1", NOW, "n1", b"{\"a\":1}"); + let h = header("dev_a", NOW, "n1", &sig); + let ok = st + .mfa_authenticate_device_at(&h, "POST", "/mfa/v1/x?a=1", b"{\"a\":1}", NOW) + .await; + assert!(ok.is_ok()); + for (m, path, body) in [ + ("POST", "/mfa/v1/x?a=1", &b"{\"a\":2}"[..]), + ("POST", "/mfa/v1/x?a=2", &b"{\"a\":1}"[..]), + ("PUT", "/mfa/v1/x?a=1", &b"{\"a\":1}"[..]), + ] { + // a fresh nonce for each try so only the signature can fail + let nonce = format!("t-{m}-{path}-{}", body.len()); + let sig = sign_with( + &p.request, + "POST", + "/mfa/v1/x?a=1", + NOW, + &nonce, + b"{\"a\":1}", + ); + let r = st + .mfa_authenticate_device_at(&header("dev_a", NOW, &nonce, &sig), m, path, body, NOW) + .await; + assert!(is_unauth(r), "{m} {path} {body:?}"); + } +} + +#[tokio::test] +async fn signature_by_approval_key_is_rejected() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let sig = sign_with(&p.approval, "GET", PATH, NOW, "n1", b""); + assert!(is_unauth( + auth(&st, &header("dev_a", NOW, "n1", &sig), NOW).await + )); +} + +#[tokio::test] +async fn a_bad_signature_does_not_burn_the_nonce() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let bad = sign_with(&p.approval, "GET", PATH, NOW, "n1", b""); + assert!(is_unauth( + auth(&st, &header("dev_a", NOW, "n1", &bad), NOW).await + )); + assert!(auth(&st, &good(&p, NOW, "n1"), NOW).await.is_ok()); +} + +#[tokio::test] +async fn der_signatures_are_rejected() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let rng = SystemRandom::new(); + let pkcs8 = + EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING, &rng) + .expect("key"); + let asn1 = EcdsaKeyPair::from_pkcs8( + &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING, + pkcs8.as_ref(), + &rng, + ) + .expect("pair"); + // even with the right message, DER is not accepted + let msg = rustmfa_contract::request_signing_string("GET", PATH, NOW, "n1", b"").expect("m"); + let der = asn1.sign(&rng, msg.as_bytes()).expect("sig"); + let h = header("dev_a", NOW, "n1", &URL_SAFE_NO_PAD.encode(der.as_ref())); + assert!(is_unauth(auth(&st, &h, NOW).await)); + let _ = p; +} + +#[test] +fn grammar_accepts_the_documented_form() { + let a = parse_authorization( + r#"RustMFA-Sig device_id="dev_a-1",ts="1800000000",nonce="n_1",sig="abc-_""#, + ) + .expect("parse"); + assert_eq!( + (a.device_id.as_str(), a.ts, a.nonce.as_str(), a.sig.as_str()), + ("dev_a-1", 1_800_000_000, "n_1", "abc-_") + ); +} + +#[test] +fn grammar_rejects_quotes_commas_newlines_and_oversize() { + let ok = |d: &str, t: &str, n: &str, s: &str| { + format!(r#"RustMFA-Sig device_id="{d}",ts="{t}",nonce="{n}",sig="{s}""#) + }; + let long = "a".repeat(129); + let edge = "a".repeat(128); + assert!(parse_authorization(&ok("d", "1", &edge, "s")).is_ok()); + let bad = [ + ok("d", "1", &long, "s"), + ok(&long, "1", "n", "s"), + ok("d", "1", "n", &long), + ok("d\"x", "1", "n", "s"), + ok("d,x", "1", "n", "s"), + ok("d\nx", "1", "n", "s"), + ok("d\r", "1", "n", "s"), + ok("d x", "1", "n", "s"), + ok("d", "-1", "n", "s"), + ok("d", "+1", "n", "s"), + ok("d", "1.5", "n", "s"), + ok("d", "", "n", "s"), + ok("d", "99999999999999999999", "n", "s"), + ok("", "1", "n", "s"), + ok("d", "1", "", "s"), + ok("d", "1", "n", ""), + ok("dé", "1", "n", "s"), + // wrong scheme, case, order, spacing, extra or repeated fields + "Bearer abc".to_owned(), + r#"rustmfa-sig device_id="d",ts="1",nonce="n",sig="s""#.to_owned(), + r#"RustMFA-Sig ts="1",device_id="d",nonce="n",sig="s""#.to_owned(), + r#"RustMFA-Sig device_id="d", ts="1", nonce="n", sig="s""#.to_owned(), + r#"RustMFA-Sig device_id="d",ts="1",nonce="n",sig="s""#.to_owned(), + r#"RustMFA-Sig device_id="d",ts="1",nonce="n",sig="s",x="y""#.to_owned(), + r#"RustMFA-Sig device_id="d",device_id="e",ts="1",nonce="n",sig="s""#.to_owned(), + r#"RustMFA-Sig device_id="d",ts="1",nonce="n""#.to_owned(), + format!("{} ", ok("d", "1", "n", "s")), + String::new(), + ]; + for h in bad { + assert!( + matches!(parse_authorization(&h), Err(MfaError::Unauthenticated)), + "accepted: {h:?}" + ); + } +} + +#[tokio::test] +async fn failed_auth_is_rate_limited_per_device() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let b = enrolled(&st, "ben", "dev_b").await; + let forged = |p: &Phone, n: &str| { + header( + &p.id, + NOW, + n, + &sign_with(&fixed_key(), "GET", PATH, NOW, n, b""), + ) + }; + for i in 0..5 { + assert!(is_unauth( + auth(&st, &forged(&a, &format!("f{i}")), NOW).await + )); + } + // blocked: even a correct request is refused + let r = auth(&st, &good(&a, NOW, "ok1"), NOW).await; + assert!(matches!(r, Err(MfaError::RateLimited { .. })), "{r:?}"); + // another device is not affected + assert!(auth(&st, &good(&b, NOW, "ok1"), NOW).await.is_ok()); +} + +#[tokio::test] +async fn four_failures_do_not_block_and_a_success_resets_the_count() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let forged = |n: &str| { + header( + "dev_a", + NOW, + n, + &sign_with(&fixed_key(), "GET", PATH, NOW, n, b""), + ) + }; + for i in 0..4 { + assert!(is_unauth(auth(&st, &forged(&format!("f{i}")), NOW).await)); + } + assert!(auth(&st, &good(&a, NOW, "ok1"), NOW).await.is_ok()); + for i in 4..8 { + assert!(is_unauth(auth(&st, &forged(&format!("f{i}")), NOW).await)); + } + assert!(auth(&st, &good(&a, NOW, "ok2"), NOW).await.is_ok()); +} + +#[tokio::test] +async fn last_seen_is_updated_on_success_only() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let seen = || async { + st.inner + .db + .mfa_device_get("dev_a") + .await + .expect("get") + .expect("row") + .last_seen_at + }; + assert_eq!(seen().await, None); + let bad = sign_with(&p.approval, "GET", PATH, NOW, "n0", b""); + let _ = auth(&st, &header("dev_a", NOW, "n0", &bad), NOW).await; + assert_eq!(seen().await, None); + auth(&st, &good(&p, NOW, "n1"), NOW).await.expect("auth"); + assert_eq!(seen().await, Some(NOW)); +} + +#[derive(Clone, Default)] +struct Capture(Arc>>); + +impl std::io::Write for Capture { + fn write(&mut self, b: &[u8]) -> std::io::Result { + if let Ok(mut g) = self.0.lock() { + g.extend_from_slice(b); + } + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture { + type Writer = Capture; + fn make_writer(&'a self) -> Capture { + self.clone() + } +} + +/// One capturing subscriber for the whole test binary. A per-test subscriber is not reliable: +/// tracing caches whether a call site is enabled, and a parallel test without a subscriber can +/// switch it off. +fn captured() -> &'static Capture { + static CAPTURE: std::sync::OnceLock = std::sync::OnceLock::new(); + CAPTURE.get_or_init(|| { + let cap = Capture::default(); + let sub = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_ansi(false) + .with_writer(cap.clone()) + .finish(); + // the first test to get here wins; no other test sets a global subscriber + let _ = tracing::subscriber::set_global_default(sub); + cap + }) +} + +#[tokio::test] +async fn no_secret_or_signature_in_logs() { + let cap = captured(); + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_logtest").await; + let nonce = "secretnonce-0123"; + let sig = sign_with(&p.approval, "GET", PATH, NOW, nonce, b""); + // bad signature, replay, skew and unknown device + let _ = auth(&st, &header("dev_logtest", NOW, nonce, &sig), NOW).await; + let h = good(&p, NOW, nonce); + let _ = auth(&st, &h, NOW).await; + let _ = auth(&st, &h, NOW).await; + let _ = auth(&st, &good(&p, NOW - 500, "late"), NOW).await; + let ghost = header("dev_logtest_ghost", NOW, nonce, &sig); + let _ = auth(&st, &ghost, NOW).await; + let good_sig = sign_with(&p.request, "GET", PATH, NOW, nonce, b""); + let out = String::from_utf8(cap.0.lock().expect("lock").clone()).expect("utf8"); + let ours: String = out + .lines() + .filter(|l| l.contains("dev_logtest")) + .collect::>() + .join("\n"); + for reason in [ + "signature does not verify", + "nonce was already used", + "timestamp outside the allowed skew", + "unknown device", + ] { + assert!(ours.contains(reason), "missing log reason {reason}"); + } + for secret in [sig.as_str(), good_sig.as_str(), nonce, "Authorization"] { + assert!(!out.contains(secret), "log leaks {secret}"); + } + assert!(!out.contains(&spki_b64(&p.request))); +} + +#[tokio::test] +async fn a_nonce_is_free_again_exactly_300_seconds_later() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + assert!(auth(&st, &good(&p, NOW, "n1"), NOW).await.is_ok()); + assert!( + auth(&st, &good(&p, NOW + 300, "n1"), NOW + 300) + .await + .is_ok() + ); +} diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs index 1eb5715..630c973 100644 --- a/crates/rostdav-app/src/push_mfa/mod.rs +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -1,6 +1,9 @@ //! Push sign-in approval with the RustMFA app (ADR 0014). mod attestation; +mod devauth; +#[cfg(test)] +mod devauth_tests; mod enroll; #[cfg(test)] mod enroll_tests; @@ -10,6 +13,7 @@ mod google_roots; pub(crate) mod testkit; pub use attestation::{AttestationResult, verify_attestation}; +pub use devauth::{DevicePrincipal, ParsedAuth, parse_authorization}; pub use enroll::{EnrollToken, RegisterInput, Registered}; pub use error::MfaError; pub use google_roots::google_roots; diff --git a/docs/admin.md b/docs/admin.md index 1bd23bc..e793d25 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -144,6 +144,8 @@ A value outside its range stops the server at start-up with a message naming the **Limits.** A user can create five enrollment tokens in 15 minutes. A client address that sends five wrong or unusable enrollment tokens, or five attestation chains that fail, is blocked from registering for 15 minutes (these blocks are not listed on the lockouts page yet). Malformed requests do not count. Device names are shortened to 128 characters and stripped of control characters. +A phone whose signed request fails five times in 15 minutes (wrong signature, unknown device, replayed nonce, bad timestamp) is blocked for 15 minutes; this block is not listed on the lockouts page yet. Requests of a revoked phone, or of a disabled account, are refused. + ## Version check Off by default. Administration, Server, "Updates" turns it on: the server then asks the GitHub releases API once a day (and on "Check now") which version is the newest, and shows a newer one with its release notes. The request carries only `User-Agent: rostdav/`. While it is off, the server makes no outbound connection. After an error the server tries again in an hour; the page shows the reason. The switch is kept in the database, so a restart keeps it. Events: `admin.update_check_on`, `admin.update_check_off`, `admin.update_installed`. The About page (every user) shows the release notes of the running build and the versions before it; they are compiled in and need no network. diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index 5574af3..a10abe9 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -22,7 +22,7 @@ This ADR covers the server side: devices, enrollment, challenges and the HTTP AP - **Device keys.** Each phone holds two ES256 (P-256) keys: a `request_key` that signs every API request, and a biometric-gated `approval_key` that signs challenge answers. The server stores only the public keys (base64 of the X.509 SubjectPublicKeyInfo DER). - **Enrollment.** Enroll token: 256 random bits, stored only as a hash (`secrets::hash_token`), single use, 5 minutes, bound to the creating user. Registration uses the token and a **key attestation** chain for the approval key. The attestation challenge is the SHA-256 of the enroll token. By default the chain must verify to a pinned Google attestation root and show a hardware-backed key (TEE or StrongBox); software-only keys are rejected. `[mfa] attestation = "off"` allows custom ROMs and is documented with its trade-off. A token is consumed before the chain is checked, so a failed attempt burns the token. -- **Request authentication.** Header `Authorization: RustMFA-Sig device_id="…",ts="…",nonce="…",sig="…"`. Values are limited to `[A-Za-z0-9_-]` and 128 bytes. `sig` is a raw 64-byte R‖S signature, base64url, over `METHOD \n path-with-query \n ts \n nonce \n base64url(sha256(body))`. The server rejects a timestamp more than 60 seconds off and any nonce seen within 5 minutes for that device. A nonce is recorded only after the signature verified. Five failures per device in 15 minutes lock that device's requests for 15 minutes (existing limiter style). +- **Request authentication.** Header `Authorization: RustMFA-Sig device_id="…",ts="…",nonce="…",sig="…"`. Values are limited to `[A-Za-z0-9_-]` and 128 bytes. `sig` is a raw 64-byte R‖S signature, base64url, over `METHOD \n path-with-query \n ts \n nonce \n base64url(sha256(body))`. The server rejects a timestamp more than 60 seconds off and any nonce seen within 5 minutes for that device. A nonce is recorded only after the signature verified. Five failures per device in 15 minutes lock that device's requests for 15 minutes (existing limiter style). A success resets the count. A revoked phone gets `DEVICE_REVOKED` only after its signature verified, so someone without the key cannot learn that a device id was revoked; a disabled account is `UNAUTHENTICATED`. - **Challenge.** Created in process by the sign-in code for one explicit device (the most recently seen active one). 90 seconds by default (`[mfa] challenge_ttl_seconds`, 10 to 300). Contains a 128-bit nonce, a two-digit number (10 to 99) and two decoys. The device receives the three candidates but never a marker of the right one. - **Answer.** The approve and deny requests carry a raw R‖S signature by the `approval_key` over the canonical message `v1 \n challenge_id \n nonce \n login_id \n device_id \n expires_at \n audience` (exact format and test vectors in the contract). The server checks: device active and owner of the challenge; challenge pending and not expired; number matches (a mismatch resolves the challenge to `denied`); signature valid. Only then one conditional `UPDATE … WHERE status = 'pending' AND expires_at > now` consumes the challenge, so two simultaneous answers cannot both win. A bad signature never consumes a challenge. - **No existence leaks.** Another user's challenge or device is `404`; another device of the same user is `403`. From 3341cd0a9e51e12e6bb316f5e88da3a5e8a07a58 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Mon, 9 Nov 2026 21:50:40 +0100 Subject: [PATCH 08/24] feat(mfa): challenges with number matching Add sign-in challenges for RustMFA phones (ADR 0014): create one for an explicit phone (two-digit number, two decoys at a random place, 128-bit nonce), list pending ones, and approve or deny with a signature by the biometric-gated approval key over the canonical message. The signature is checked before the number, so only the approval key can end a challenge; a wrong number denies it. One conditional update decides between concurrent answers. Expiry is recorded and audited once. Five challenges per user in 15 minutes. Push delivery goes through a channel trait with a no-op default; a failure is logged without the token. Migration 0013 gains login_device (the branch is not merged yet). --- CHANGELOG.md | 1 + crates/rostdav-app/src/lib.rs | 14 +- crates/rostdav-app/src/push_mfa/challenges.rs | 510 +++++++++ .../src/push_mfa/challenges_tests.rs | 969 ++++++++++++++++++ .../rostdav-app/src/push_mfa/devauth_tests.rs | 87 +- crates/rostdav-app/src/push_mfa/mod.rs | 6 + crates/rostdav-app/src/push_mfa/push.rs | 36 + crates/rostdav-app/src/push_mfa/testkit.rs | 83 ++ .../rostdav-meta/migrations/0013_push_mfa.sql | 3 +- crates/rostdav-meta/src/push_mfa.rs | 76 +- docs/admin.md | 2 + docs/adr/0014-push-mfa.md | 2 +- docs/architecture.md | 2 +- 13 files changed, 1698 insertions(+), 93 deletions(-) create mode 100644 crates/rostdav-app/src/push_mfa/challenges.rs create mode 100644 crates/rostdav-app/src/push_mfa/challenges_tests.rs create mode 100644 crates/rostdav-app/src/push_mfa/push.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dc548c2..d21122d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https - **Key attestation check for push sign-in approval phones**: verifies that a phone's key lives in secure hardware (certificate chain to a pinned Google root, hardware security level, challenge, key match). Nothing calls it yet. - **Enrollment service for the RustMFA app**: a signed-in user creates a single-use token (QR code), the phone registers its two public keys with the token and an attestation. Wrong tokens and failed attestations are rate limited per address. Not reachable over HTTP yet. - **Request authentication for the RustMFA app**: checks the signed `Authorization: RustMFA-Sig` header of a phone (timestamp within 60 seconds, signature by the request key, nonce used once in 5 minutes, phone and account still active). Five failures for one phone in 15 minutes block it for 15 minutes. Not reachable over HTTP yet. +- **Sign-in challenges for the RustMFA app**: the server can start an approval for one phone (a two-digit number, two decoys, 90 seconds by default), list pending ones, and accept an approve or deny signed with the phone's biometric key. A wrong number denies the challenge. Only one answer can win. Five challenges per user in 15 minutes. Push delivery is a stub until the sign-in page work; not reachable over HTTP yet. ## [0.10.0] - 2026-10-25 diff --git a/crates/rostdav-app/src/lib.rs b/crates/rostdav-app/src/lib.rs index 4789cb7..2ec47aa 100644 --- a/crates/rostdav-app/src/lib.rs +++ b/crates/rostdav-app/src/lib.rs @@ -57,8 +57,10 @@ pub use login_flow::FlowStep; pub use login_flow::{Granted, StartedFlow}; pub use password::{MIN_PASSWORD_CHARS, hash_password, verify_password}; pub use push_mfa::{ - AttestationPolicy, AttestationResult, DevicePrincipal, EnrollToken, MfaConfig, MfaError, - ParsedAuth, RegisterInput, Registered, google_roots, parse_authorization, verify_attestation, + AttestationPolicy, AttestationResult, ChallengeDetails, ChallengeStatus, DevicePrincipal, + EnrollToken, MfaConfig, MfaError, NewChallenge as MfaNewChallenge, NoopPush, ParsedAuth, + PushChannel, PushFuture, RegisterInput, Registered, google_roots, parse_authorization, + verify_attestation, }; pub use search::{KindFilter, MAX_VISITED, SearchHit, SearchPage, SearchQuery, SnippetPart}; pub use shares::{ @@ -100,6 +102,8 @@ struct Inner { mfa_limiter: ratelimit::RateLimiter, /// Trusted attestation roots that replace Google's in tests. mfa_roots: std::sync::Mutex>>>, + /// How phones are woken for a new challenge (ADR 0014). + mfa_push: std::sync::RwLock>, /// Instance ID used in Nextcloud-style file IDs (`oc:id`), e.g. `oc` + 10 characters. instance_id: String, /// 32-byte key for stateless tickets and CSRF values (stored in `settings`). @@ -231,6 +235,7 @@ impl AppState { std::time::Duration::from_secs(15 * 60), ), mfa_roots: std::sync::Mutex::new(None), + mfa_push: std::sync::RwLock::new(Arc::new(push_mfa::NoopPush)), instance_id, server_key, maintenance: maintenance.into(), @@ -273,6 +278,11 @@ impl AppState { std::time::Duration::from_secs(15 * 60), ), mfa_roots: std::sync::Mutex::new(None), + mfa_push: std::sync::RwLock::new( + old.mfa_push + .read() + .map_or_else(|e| e.into_inner().clone(), |g| g.clone()), + ), instance_id: old.instance_id.clone(), server_key: old.server_key, maintenance: old diff --git a/crates/rostdav-app/src/push_mfa/challenges.rs b/crates/rostdav-app/src/push_mfa/challenges.rs new file mode 100644 index 0000000..01e541c --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/challenges.rs @@ -0,0 +1,510 @@ +//! Sign-in challenges with number matching (ADR 0014). + +use std::net::IpAddr; +use std::sync::Arc; + +use rostdav_core::Error; +use rostdav_core::time::rfc3339; +use rostdav_meta::{ChallengeRow, DeviceRow}; +use serde_json::json; + +use super::{DevicePrincipal, MfaError, PushChannel}; +use crate::audit::sanitize_text; +use crate::secrets::{random_bytes, random_token}; +use crate::{AppState, now_secs}; + +const SERVICE_NAME: &str = "Rostdav"; +const MAX_LOGIN_REF: usize = 128; +const NONCE_LEN: usize = 32; + +/// Where a challenge stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChallengeStatus { + Pending, + Approved, + Denied, + Expired, +} + +impl ChallengeStatus { + fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Approved => "approved", + Self::Denied => "denied", + Self::Expired => "expired", + } + } + + fn from_db(s: &str) -> Result { + match s { + "pending" => Ok(Self::Pending), + "approved" => Ok(Self::Approved), + "denied" => Ok(Self::Denied), + "expired" => Ok(Self::Expired), + _ => Err(MfaError::Internal(Error::BadRequest( + "unknown challenge status", + ))), + } + } +} + +/// A challenge that was just created, for the sign-in page. +#[derive(Debug)] +pub struct NewChallenge { + pub challenge_id: String, + /// The number the sign-in page shows and the user must pick on the phone. + pub number: i64, + pub expires_in_seconds: i64, +} + +/// What a phone may see of a challenge. Nothing here says which candidate is right. +#[derive(Debug)] +pub struct ChallengeDetails { + pub challenge_id: String, + pub service_name: String, + pub login_device: String, + pub approximate_location: Option, + pub created_at: String, + pub expires_at: String, + pub status: ChallengeStatus, + pub candidate_numbers: [i64; 3], + pub nonce: String, + pub login_id: String, + pub audience: String, +} + +/// A uniform number in `10..=99` (rejection sampling: 180 = 2 * 90, so no bias). +fn random_number() -> i64 { + loop { + let [b] = random_bytes::<1>(); + if b < 180 { + return 10 + i64::from(b % 90); + } + } +} + +/// A uniform index in `0..3` (255 = 3 * 85). +fn random_index() -> usize { + loop { + let [b] = random_bytes::<1>(); + if b < 255 { + return usize::from(b % 3); + } + } +} + +/// The right number and two different decoys, the right one at a random place. +fn candidates(number: i64) -> [i64; 3] { + let mut decoys = Vec::with_capacity(2); + while decoys.len() < 2 { + let n = random_number(); + if n != number && !decoys.contains(&n) { + decoys.push(n); + } + } + let at = random_index(); + let mut out = [0; 3]; + let mut next = decoys.into_iter(); + for (i, slot) in out.iter_mut().enumerate() { + *slot = if i == at { + number + } else { + next.next().unwrap_or(number) + }; + } + out +} + +fn valid_login_ref(s: &str) -> bool { + !s.is_empty() + && s.len() <= MAX_LOGIN_REF + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +fn details_of(row: &ChallengeRow, status: ChallengeStatus) -> Result { + let numbers: Vec = serde_json::from_str(&row.candidates) + .map_err(|_| MfaError::Internal(Error::BadRequest("stored candidates are unreadable")))?; + let candidate_numbers: [i64; 3] = numbers + .try_into() + .map_err(|_| MfaError::Internal(Error::BadRequest("stored candidates are unreadable")))?; + Ok(ChallengeDetails { + challenge_id: row.id.clone(), + service_name: SERVICE_NAME.to_owned(), + login_device: row.login_device.clone(), + approximate_location: None, + created_at: rfc3339(row.created_at), + expires_at: rfc3339(row.expires_at), + status, + candidate_numbers, + nonce: row.nonce.clone(), + login_id: row.login_ref.clone(), + audience: row.audience.clone(), + }) +} + +impl AppState { + /// Starts an approval for one phone of `user_id`. Five per user in 15 minutes, so a stolen + /// password cannot flood the phone. `ip` is only audited. + pub async fn mfa_create_challenge( + &self, + user_id: i64, + device_id: &str, + login_ref: &str, + login_device: &str, + ip: IpAddr, + ) -> Result { + self.mfa_create_challenge_at(user_id, device_id, login_ref, login_device, ip, now_secs()) + .await + } + + pub(crate) async fn mfa_create_challenge_at( + &self, + user_id: i64, + device_id: &str, + login_ref: &str, + login_device: &str, + ip: IpAddr, + now: i64, + ) -> Result { + if !valid_login_ref(login_ref) { + return Err(MfaError::InvalidRequest("login reference is not valid")); + } + let key = format!("mfa-challenge:{user_id}"); + self.inner + .mfa_limiter + .check(&key) + .map_err(|retry_after_secs| MfaError::RateLimited { retry_after_secs })?; + let device = self + .inner + .db + .mfa_device_get(device_id) + .await? + .filter(|d| d.user_id == user_id && d.status == "active") + .ok_or(MfaError::NotFound)?; + let user = self + .inner + .db + .find_user_by_id(user_id) + .await? + .ok_or(MfaError::NotFound)?; + let cfg = self.mfa_cfg(); + let number = random_number(); + let shown = candidates(number); + let id = format!("ch_{}", random_token(22)); + let nonce = random_token(NONCE_LEN); + let login_device = match sanitize_text(login_device).trim() { + "" => "Unknown device".to_owned(), + s => s.to_owned(), + }; + let candidates_json = json!(shown).to_string(); + self.inner + .db + .mfa_challenge_insert(&rostdav_meta::NewChallenge { + id: &id, + user_id, + device_id, + login_ref, + nonce: &nonce, + number, + candidates: &candidates_json, + audience: &cfg.audience, + login_device: &login_device, + expires_at: now + cfg.challenge_ttl_secs, + }) + .await?; + // every challenge counts, not only failures + self.inner.mfa_limiter.record_failure(&key); + self.audit( + &user.name, + "mfa.challenge_created", + &id, + Some(ip), + json!({ "device_id": device_id }), + ) + .await; + self.push_in_background(&device, &id); + Ok(NewChallenge { + challenge_id: id, + number, + expires_in_seconds: cfg.challenge_ttl_secs, + }) + } + + /// Wakes the phone after the challenge is stored. A failure is only logged, and the log has + /// the challenge id but never the push token. + fn push_in_background(&self, device: &DeviceRow, challenge_id: &str) { + let channel = self.push_channel(); + let (kind, token, id) = ( + device.push_channel.clone(), + device.push_token.clone(), + challenge_id.to_owned(), + ); + tokio::spawn(async move { + if let Err(e) = channel.notify(&kind, &token, &id).await { + tracing::warn!(challenge_id = %id, error = %e, "push notification failed"); + } + }); + } + + fn push_channel(&self) -> Arc { + self.inner + .mfa_push + .read() + .map_or_else(|e| e.into_inner().clone(), |g| g.clone()) + } + + /// For the sign-in page: where the challenge stands. A pending one past its time becomes + /// `Expired`. + pub async fn mfa_challenge_status(&self, id: &str) -> Result { + self.mfa_challenge_status_at(id, now_secs()).await + } + + pub(crate) async fn mfa_challenge_status_at( + &self, + id: &str, + now: i64, + ) -> Result { + let row = self + .inner + .db + .mfa_challenge_get(id) + .await? + .ok_or(MfaError::NotFound)?; + self.current_status(&row, now).await + } + + /// The status of `row` now. The first caller to see a pending challenge past its time marks + /// it expired and audits that once. + async fn current_status( + &self, + row: &ChallengeRow, + now: i64, + ) -> Result { + let status = ChallengeStatus::from_db(&row.status)?; + if status == ChallengeStatus::Pending && row.expires_at <= now { + if self.inner.db.mfa_challenge_expire(&row.id, now).await? { + self.audit_challenge(row, "mfa.challenge_expired").await; + } + return Ok(ChallengeStatus::Expired); + } + Ok(status) + } + + async fn audit_challenge(&self, row: &ChallengeRow, action: &str) { + let actor = match self.inner.db.find_user_by_id(row.user_id).await { + Ok(Some(u)) => u.name, + _ => String::new(), + }; + self.audit( + &actor, + action, + &row.id, + None, + json!({ "device_id": row.device_id }), + ) + .await; + } + + /// The challenge of this phone. Another user's challenge is `NotFound`, the same as one that + /// does not exist; another phone of the same user is `DeviceNotAuthorized`. + async fn owned_challenge( + &self, + d: &DevicePrincipal, + id: &str, + ) -> Result { + let row = self + .inner + .db + .mfa_challenge_get(id) + .await? + .filter(|r| r.user_id == d.user_id) + .ok_or(MfaError::NotFound)?; + if row.device_id != d.device_id { + return Err(MfaError::DeviceNotAuthorized); + } + Ok(row) + } + + pub async fn mfa_get_challenge( + &self, + d: &DevicePrincipal, + id: &str, + ) -> Result { + self.mfa_get_challenge_at(d, id, now_secs()).await + } + + pub(crate) async fn mfa_get_challenge_at( + &self, + d: &DevicePrincipal, + id: &str, + now: i64, + ) -> Result { + let row = self.owned_challenge(d, id).await?; + match self.current_status(&row, now).await? { + ChallengeStatus::Expired => Err(MfaError::Expired), + status => details_of(&row, status), + } + } + + pub async fn mfa_pending_challenges( + &self, + d: &DevicePrincipal, + ) -> Result, MfaError> { + self.mfa_pending_challenges_at(d, now_secs()).await + } + + pub(crate) async fn mfa_pending_challenges_at( + &self, + d: &DevicePrincipal, + now: i64, + ) -> Result, MfaError> { + self.inner + .db + .mfa_challenges_pending(&d.device_id, now) + .await? + .iter() + .filter(|r| r.user_id == d.user_id) + .map(|r| details_of(r, ChallengeStatus::Pending)) + .collect() + } + + /// Approves with the number the user picked and the approval key's signature. A wrong number + /// denies the challenge (`NumberMismatch`). Both need a valid signature first, so the request + /// key alone, which is not biometric-gated, cannot end a challenge. + pub async fn mfa_approve( + &self, + d: &DevicePrincipal, + id: &str, + selected_number: i64, + signature: &str, + ) -> Result { + self.mfa_approve_at(d, id, selected_number, signature, now_secs()) + .await + } + + pub(crate) async fn mfa_approve_at( + &self, + d: &DevicePrincipal, + id: &str, + selected_number: i64, + signature: &str, + now: i64, + ) -> Result { + self.answer(d, id, Some(selected_number), signature, now) + .await + } + + pub async fn mfa_deny( + &self, + d: &DevicePrincipal, + id: &str, + signature: &str, + ) -> Result { + self.mfa_deny_at(d, id, signature, now_secs()).await + } + + pub(crate) async fn mfa_deny_at( + &self, + d: &DevicePrincipal, + id: &str, + signature: &str, + now: i64, + ) -> Result { + self.answer(d, id, None, signature, now).await + } + + /// Order: ownership, phone still active, challenge open, signature, number, then one + /// conditional update that only one caller can win. A bad signature never consumes. + async fn answer( + &self, + d: &DevicePrincipal, + id: &str, + selected: Option, + signature: &str, + now: i64, + ) -> Result { + let row = self.owned_challenge(d, id).await?; + let device = self + .inner + .db + .mfa_device_get(&d.device_id) + .await? + .filter(|x| x.user_id == d.user_id) + .ok_or(MfaError::NotFound)?; + if device.status != "active" { + return Err(MfaError::DeviceRevoked); + } + Self::open_or_err(self.current_status(&row, now).await?)?; + let expires_at = rfc3339(row.expires_at); + let message = rustmfa_contract::canonical_message(&rustmfa_contract::ChallengeMessage { + challenge_id: &row.id, + nonce: &row.nonce, + login_id: &row.login_ref, + device_id: &row.device_id, + expires_at: &expires_at, + audience: &row.audience, + }) + .map_err(|_| MfaError::Internal(Error::BadRequest("challenge cannot be signed")))?; + rustmfa_contract::verify_es256_raw(&device.approval_pub, message.as_bytes(), signature) + .map_err(|e| match e { + rustmfa_contract::ContractError::VerifyFailed => MfaError::DeviceNotAuthorized, + _ => MfaError::InvalidRequest("signature is not a raw ES256 signature"), + })?; + let (to, action) = match selected { + Some(n) if n == row.number => (ChallengeStatus::Approved, "mfa.challenge_approved"), + Some(_) => (ChallengeStatus::Denied, "mfa.number_mismatch"), + None => (ChallengeStatus::Denied, "mfa.challenge_denied"), + }; + self.finish(&row, to, action, now).await?; + if action == "mfa.number_mismatch" { + return Err(MfaError::NumberMismatch); + } + Ok(to) + } + + /// The one conditional update. `row` was read earlier, so someone may have answered since, + /// or the time may have run out: then the fresh row says which error applies. + pub(crate) async fn finish( + &self, + row: &ChallengeRow, + to: ChallengeStatus, + action: &str, + now: i64, + ) -> Result<(), MfaError> { + if !self + .inner + .db + .mfa_challenge_resolve(&row.id, to.as_str(), now) + .await? + { + let fresh = self + .inner + .db + .mfa_challenge_get(&row.id) + .await? + .ok_or(MfaError::NotFound)?; + Self::open_or_err(self.current_status(&fresh, now).await?)?; + return Err(MfaError::AlreadyConsumed); + } + self.audit_challenge(row, action).await; + Ok(()) + } + + /// `Ok` only for a challenge that can still be answered. + fn open_or_err(status: ChallengeStatus) -> Result<(), MfaError> { + match status { + ChallengeStatus::Pending => Ok(()), + ChallengeStatus::Expired => Err(MfaError::Expired), + ChallengeStatus::Approved | ChallengeStatus::Denied => Err(MfaError::AlreadyConsumed), + } + } + + #[cfg(test)] + pub(crate) fn set_push_channel_for_tests(&self, p: Arc) { + if let Ok(mut g) = self.inner.mfa_push.write() { + *g = p; + } + } +} diff --git a/crates/rostdav-app/src/push_mfa/challenges_tests.rs b/crates/rostdav-app/src/push_mfa/challenges_tests.rs new file mode 100644 index 0000000..330998a --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/challenges_tests.rs @@ -0,0 +1,969 @@ +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ring::rand::SystemRandom; +use rostdav_meta::NewDevice; + +use super::push::PushFuture; +use super::{ChallengeDetails, ChallengeStatus, DevicePrincipal, MfaError, PushChannel}; +use crate::AppState; +use crate::push_mfa::testkit::{Phone, add_user, captured, enrolled, fixed_key, spki_b64, state}; + +const IP: IpAddr = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)); + +fn now() -> i64 { + crate::now_secs() +} + +fn dp(p: &Phone) -> DevicePrincipal { + DevicePrincipal { + device_id: p.id.clone(), + user_id: p.user_id, + } +} + +/// Every challenge counts against the user's limit; tests that make many lift it. +fn unlimit(st: &AppState, user_id: i64) { + st.inner + .mfa_limiter + .clear(&format!("mfa-challenge:{user_id}")); +} + +async fn create(st: &AppState, p: &Phone, login: &str) -> super::NewChallenge { + st.mfa_create_challenge(p.user_id, &p.id, login, "Firefox on Linux", IP) + .await + .expect("create") +} + +/// The signature the app makes: the approval key over the canonical message of `d`. +fn sign_message( + key: &ring::signature::EcdsaKeyPair, + m: &rustmfa_contract::ChallengeMessage, +) -> String { + let msg = rustmfa_contract::canonical_message(m).expect("message"); + let sig = key.sign(&SystemRandom::new(), msg.as_bytes()).expect("sig"); + URL_SAFE_NO_PAD.encode(sig.as_ref()) +} + +fn answer(p: &Phone, d: &ChallengeDetails) -> String { + sign_message( + &p.approval, + &rustmfa_contract::ChallengeMessage { + challenge_id: &d.challenge_id, + nonce: &d.nonce, + login_id: &d.login_id, + device_id: &p.id, + expires_at: &d.expires_at, + audience: &d.audience, + }, + ) +} + +async fn details(st: &AppState, p: &Phone, id: &str) -> ChallengeDetails { + st.mfa_get_challenge(&dp(p), id).await.expect("details") +} + +async fn row(st: &AppState, id: &str) -> rostdav_meta::ChallengeRow { + st.inner + .db + .mfa_challenge_get(id) + .await + .expect("get") + .expect("row") +} + +async fn actions(st: &AppState) -> Vec { + st.audit_events(&crate::Actor::Cli, 0) + .await + .expect("events") + .into_iter() + .map(|e| e.action) + .collect() +} + +/// A second phone of the same account. +async fn second_phone(st: &AppState, of: &Phone, id: &str) -> Phone { + let (request, approval) = (fixed_key(), fixed_key()); + st.inner + .db + .mfa_device_insert(&NewDevice { + id, + user_id: of.user_id, + name: "Tablet", + request_pub: &spki_b64(&request), + approval_pub: &spki_b64(&approval), + push_channel: "fcm", + push_token: "t2", + hardware_backed: true, + strongbox: false, + }) + .await + .expect("insert"); + Phone { + id: id.to_owned(), + request, + approval, + user_id: of.user_id, + } +} + +#[tokio::test] +async fn create_makes_distinct_two_digit_number_and_two_decoys() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let mut seen_at = [0usize; 3]; + for i in 0..200 { + unlimit(&st, p.user_id); + let c = create(&st, &p, &format!("login-{i}")).await; + assert!((10..=99).contains(&c.number)); + let d = details(&st, &p, &c.challenge_id).await; + let n = d.candidate_numbers; + assert!(n.iter().all(|x| (10..=99).contains(x)), "{n:?}"); + assert!(n[0] != n[1] && n[0] != n[2] && n[1] != n[2], "{n:?}"); + let at = n + .iter() + .position(|x| *x == c.number) + .expect("right one is shown"); + seen_at[at] += 1; + assert_eq!(row(&st, &c.challenge_id).await.number, c.number); + } + assert!( + seen_at.iter().all(|c| *c > 20), + "position is not random: {seen_at:?}" + ); +} + +#[tokio::test] +async fn create_returns_the_configured_lifetime_and_a_fresh_id_and_nonce() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let a = create(&st, &p, "l1").await; + let b = create(&st, &p, "l2").await; + assert_eq!(a.expires_in_seconds, 90); + assert!(a.challenge_id.starts_with("ch_") && a.challenge_id != b.challenge_id); + let (ra, rb) = ( + row(&st, &a.challenge_id).await, + row(&st, &b.challenge_id).await, + ); + assert!( + ra.nonce.len() >= 22 && ra.nonce != rb.nonce, + "128 bit or more, never reused" + ); + assert_eq!(ra.audience, "rostdav"); + assert_eq!(ra.login_ref, "l1"); + assert_eq!(ra.login_device, "Firefox on Linux"); + assert_eq!(ra.expires_at - ra.created_at, 90); +} + +#[tokio::test] +async fn create_refuses_a_device_that_is_not_the_users_or_not_active() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let b = enrolled(&st, "ben", "dev_b").await; + // anna's login cannot target ben's phone + let r = st + .mfa_create_challenge(a.user_id, &b.id, "l", "x", IP) + .await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); + let r = st + .mfa_create_challenge(a.user_id, "dev_nope", "l", "x", IP) + .await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); + st.inner + .db + .mfa_device_revoke("dev_a", a.user_id, now()) + .await + .expect("revoke"); + let r = st + .mfa_create_challenge(a.user_id, "dev_a", "l", "x", IP) + .await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); +} + +#[tokio::test] +async fn create_rejects_a_login_ref_that_cannot_be_signed() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + for bad in ["", "a\nb", &"x".repeat(129)] { + let r = st + .mfa_create_challenge(p.user_id, &p.id, bad, "x", IP) + .await; + assert!( + matches!(r, Err(MfaError::InvalidRequest(_))), + "{bad:?}: {r:?}" + ); + } +} + +#[tokio::test] +async fn the_login_device_is_cleaned() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = st + .mfa_create_challenge(p.user_id, &p.id, "l", "Fire\nfox\u{7}", IP) + .await + .expect("create"); + assert_eq!( + details(&st, &p, &c.challenge_id).await.login_device, + "Firefox" + ); + let c = st + .mfa_create_challenge(p.user_id, &p.id, "l2", "\n\t", IP) + .await + .expect("create"); + assert_eq!( + details(&st, &p, &c.challenge_id).await.login_device, + "Unknown device" + ); +} + +#[tokio::test] +async fn get_hides_the_correct_number() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let d = details(&st, &p, &c.challenge_id).await; + // every field is named: a new field that could mark the right number breaks this test + let ChallengeDetails { + challenge_id, + service_name, + login_device, + approximate_location, + created_at, + expires_at, + status, + candidate_numbers, + nonce, + login_id, + audience, + } = d; + assert_eq!(challenge_id, c.challenge_id); + assert_eq!(service_name, "Rostdav"); + assert_eq!(login_device, "Firefox on Linux"); + assert_eq!(approximate_location, None); + assert_eq!(status, ChallengeStatus::Pending); + assert_eq!((login_id.as_str(), audience.as_str()), ("l", "rostdav")); + assert_eq!(candidate_numbers.len(), 3); + // dates have the exact signed form + for t in [&created_at, &expires_at] { + assert_eq!(t.len(), 20); + assert!(t.ends_with('Z') && t.as_bytes()[10] == b'T'); + } + assert!(!nonce.is_empty()); +} + +#[tokio::test] +async fn approve_with_valid_signature_consumes_once() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let d = details(&st, &p, &c.challenge_id).await; + let sig = answer(&p, &d); + let s = st + .mfa_approve(&dp(&p), &c.challenge_id, c.number, &sig) + .await + .expect("approve"); + assert_eq!(s, ChallengeStatus::Approved); + let r = row(&st, &c.challenge_id).await; + assert_eq!(r.status, "approved"); + assert!(r.consumed_at.is_some()); + assert_eq!( + st.mfa_challenge_status(&c.challenge_id) + .await + .expect("status"), + ChallengeStatus::Approved + ); +} + +#[tokio::test] +async fn approve_twice_gives_already_consumed() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let sig = answer(&p, &details(&st, &p, &c.challenge_id).await); + st.mfa_approve(&dp(&p), &c.challenge_id, c.number, &sig) + .await + .expect("first"); + let r = st + .mfa_approve(&dp(&p), &c.challenge_id, c.number, &sig) + .await; + assert!(matches!(r, Err(MfaError::AlreadyConsumed)), "{r:?}"); + let r = st.mfa_deny(&dp(&p), &c.challenge_id, &sig).await; + assert!(matches!(r, Err(MfaError::AlreadyConsumed)), "{r:?}"); +} + +#[tokio::test] +async fn concurrent_approves_one_wins() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + for i in 0..10 { + unlimit(&st, p.user_id); + let c = create(&st, &p, &format!("l{i}")).await; + let sig = answer(&p, &details(&st, &p, &c.challenge_id).await); + let d = dp(&p); + let (a, b) = tokio::join!( + st.mfa_approve(&d, &c.challenge_id, c.number, &sig), + st.mfa_approve(&d, &c.challenge_id, c.number, &sig) + ); + let oks = [&a, &b].iter().filter(|r| r.is_ok()).count(); + assert_eq!(oks, 1, "{a:?} {b:?}"); + let lost = if a.is_ok() { b } else { a }; + assert!(matches!(lost, Err(MfaError::AlreadyConsumed)), "{lost:?}"); + let events = actions(&st).await; + let approved = events + .iter() + .filter(|a| *a == "mfa.challenge_approved") + .count(); + assert_eq!(approved, i + 1, "one audit event per won approval"); + } +} + +#[tokio::test] +async fn approve_wrong_number_denies_and_audits() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let d = details(&st, &p, &c.challenge_id).await; + let wrong = d + .candidate_numbers + .iter() + .copied() + .find(|n| *n != c.number) + .expect("decoy"); + let r = st + .mfa_approve(&dp(&p), &c.challenge_id, wrong, &answer(&p, &d)) + .await; + assert!(matches!(r, Err(MfaError::NumberMismatch)), "{r:?}"); + assert_eq!(row(&st, &c.challenge_id).await.status, "denied"); + assert!( + actions(&st) + .await + .contains(&"mfa.number_mismatch".to_owned()) + ); + // the right number afterwards is too late + let r = st + .mfa_approve(&dp(&p), &c.challenge_id, c.number, &answer(&p, &d)) + .await; + assert!(matches!(r, Err(MfaError::AlreadyConsumed)), "{r:?}"); +} + +#[tokio::test] +async fn a_wrong_number_with_a_bad_signature_does_not_deny() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let d = details(&st, &p, &c.challenge_id).await; + let wrong = d + .candidate_numbers + .iter() + .copied() + .find(|n| *n != c.number) + .expect("decoy"); + // signed by the request key, which is not biometric-gated + let bad = sign_message( + &p.request, + &rustmfa_contract::ChallengeMessage { + challenge_id: &d.challenge_id, + nonce: &d.nonce, + login_id: &d.login_id, + device_id: &p.id, + expires_at: &d.expires_at, + audience: &d.audience, + }, + ); + let r = st.mfa_approve(&dp(&p), &c.challenge_id, wrong, &bad).await; + assert!(matches!(r, Err(MfaError::DeviceNotAuthorized)), "{r:?}"); + assert_eq!(row(&st, &c.challenge_id).await.status, "pending"); + // and the right answer still works + let ok = st + .mfa_approve(&dp(&p), &c.challenge_id, c.number, &answer(&p, &d)) + .await; + assert_eq!(ok.expect("approve"), ChallengeStatus::Approved); +} + +#[tokio::test] +async fn approve_after_expiry_gives_expired() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let sig = answer(&p, &details(&st, &p, &c.challenge_id).await); + let late = now() + 91; + let r = st + .mfa_approve_at(&dp(&p), &c.challenge_id, c.number, &sig, late) + .await; + assert!(matches!(r, Err(MfaError::Expired)), "{r:?}"); + assert_eq!(row(&st, &c.challenge_id).await.status, "expired"); + assert!( + actions(&st) + .await + .contains(&"mfa.challenge_expired".to_owned()) + ); + // the last second is still inside, the boundary second is not + let c2 = create(&st, &p, "l2").await; + let sig2 = answer(&p, &details(&st, &p, &c2.challenge_id).await); + let exp = row(&st, &c2.challenge_id).await.expires_at; + let r = st + .mfa_approve_at(&dp(&p), &c2.challenge_id, c2.number, &sig2, exp) + .await; + assert!(matches!(r, Err(MfaError::Expired)), "{r:?}"); + let c3 = create(&st, &p, "l3").await; + let sig3 = answer(&p, &details(&st, &p, &c3.challenge_id).await); + let exp3 = row(&st, &c3.challenge_id).await.expires_at; + let ok = st + .mfa_approve_at(&dp(&p), &c3.challenge_id, c3.number, &sig3, exp3 - 1) + .await; + assert!(ok.is_ok(), "{ok:?}"); +} + +#[tokio::test] +async fn expiry_is_audited_once_and_status_maps_lazily() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + assert_eq!( + st.mfa_challenge_status_at(&c.challenge_id, now()) + .await + .expect("s"), + ChallengeStatus::Pending + ); + let late = now() + 100; + for _ in 0..3 { + assert_eq!( + st.mfa_challenge_status_at(&c.challenge_id, late) + .await + .expect("s"), + ChallengeStatus::Expired + ); + } + let n = actions(&st) + .await + .iter() + .filter(|a| *a == "mfa.challenge_expired") + .count(); + assert_eq!(n, 1); + let r = st.mfa_challenge_status("ch_unknown").await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); +} + +#[tokio::test] +async fn get_of_an_expired_challenge_gives_expired() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let r = st + .mfa_get_challenge_at(&dp(&p), &c.challenge_id, now() + 100) + .await; + assert!(matches!(r, Err(MfaError::Expired)), "{r:?}"); +} + +#[tokio::test] +async fn approve_by_other_device_of_same_user_gives_not_authorized() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let b = second_phone(&st, &a, "dev_b").await; + let c = create(&st, &a, "l").await; + let d = details(&st, &a, &c.challenge_id).await; + // b even signs with its own approval key over a message naming itself + let sig = answer(&b, &d); + let r = st + .mfa_approve(&dp(&b), &c.challenge_id, c.number, &sig) + .await; + assert!(matches!(r, Err(MfaError::DeviceNotAuthorized)), "{r:?}"); + let r = st.mfa_deny(&dp(&b), &c.challenge_id, &sig).await; + assert!(matches!(r, Err(MfaError::DeviceNotAuthorized)), "{r:?}"); + let r = st.mfa_get_challenge(&dp(&b), &c.challenge_id).await; + assert!(matches!(r, Err(MfaError::DeviceNotAuthorized)), "{r:?}"); + assert_eq!(row(&st, &c.challenge_id).await.status, "pending"); +} + +#[tokio::test] +async fn challenge_of_other_user_gives_not_found() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let b = enrolled(&st, "ben", "dev_b").await; + let c = create(&st, &a, "l").await; + let sig = answer(&a, &details(&st, &a, &c.challenge_id).await); + let r = st + .mfa_approve(&dp(&b), &c.challenge_id, c.number, &sig) + .await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); + let r = st.mfa_deny(&dp(&b), &c.challenge_id, &sig).await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); + let r = st.mfa_get_challenge(&dp(&b), &c.challenge_id).await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); + // the same answer as for a challenge that does not exist + let r = st.mfa_get_challenge(&dp(&b), "ch_unknown").await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); + assert_eq!(row(&st, &c.challenge_id).await.status, "pending"); +} + +#[tokio::test] +async fn revoked_device_cannot_approve_and_challenge_stays_pending() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let sig = answer(&p, &details(&st, &p, &c.challenge_id).await); + st.inner + .db + .mfa_device_revoke("dev_a", p.user_id, now()) + .await + .expect("revoke"); + let r = st + .mfa_approve(&dp(&p), &c.challenge_id, c.number, &sig) + .await; + assert!(matches!(r, Err(MfaError::DeviceRevoked)), "{r:?}"); + let r = st.mfa_deny(&dp(&p), &c.challenge_id, &sig).await; + assert!(matches!(r, Err(MfaError::DeviceRevoked)), "{r:?}"); + assert_eq!(row(&st, &c.challenge_id).await.status, "pending"); +} + +#[tokio::test] +async fn signature_over_wrong_login_or_device_or_nonce_is_rejected() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "login-1").await; + let d = details(&st, &p, &c.challenge_id).await; + let sign = |f: &dyn Fn(&mut [String; 6])| { + let mut v = [ + d.challenge_id.clone(), + d.nonce.clone(), + d.login_id.clone(), + p.id.clone(), + d.expires_at.clone(), + d.audience.clone(), + ]; + f(&mut v); + sign_message( + &p.approval, + &rustmfa_contract::ChallengeMessage { + challenge_id: &v[0], + nonce: &v[1], + login_id: &v[2], + device_id: &v[3], + expires_at: &v[4], + audience: &v[5], + }, + ) + }; + let altered = [ + ("challenge", sign(&|v| v[0] = "ch_other".into())), + ("nonce", sign(&|v| v[1] = "othernonce".into())), + ("login", sign(&|v| v[2] = "login-2".into())), + ("device", sign(&|v| v[3] = "dev_x".into())), + ("expiry", sign(&|v| v[4] = "2099-01-01T00:00:00Z".into())), + ("audience", sign(&|v| v[5] = "elsewhere".into())), + ]; + for (name, sig) in altered { + let r = st + .mfa_approve(&dp(&p), &c.challenge_id, c.number, &sig) + .await; + assert!( + matches!(r, Err(MfaError::DeviceNotAuthorized)), + "{name}: {r:?}" + ); + let r = st.mfa_deny(&dp(&p), &c.challenge_id, &sig).await; + assert!( + matches!(r, Err(MfaError::DeviceNotAuthorized)), + "{name}: {r:?}" + ); + assert_eq!(row(&st, &c.challenge_id).await.status, "pending", "{name}"); + } +} + +#[tokio::test] +async fn der_and_garbage_signatures_are_rejected_and_do_not_consume() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let d = details(&st, &p, &c.challenge_id).await; + let rng = SystemRandom::new(); + let alg = &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING; + let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(alg, &rng).expect("key"); + let asn1 = ring::signature::EcdsaKeyPair::from_pkcs8(alg, pkcs8.as_ref(), &rng).expect("p"); + let msg = rustmfa_contract::canonical_message(&rustmfa_contract::ChallengeMessage { + challenge_id: &d.challenge_id, + nonce: &d.nonce, + login_id: &d.login_id, + device_id: &p.id, + expires_at: &d.expires_at, + audience: &d.audience, + }) + .expect("m"); + let der = URL_SAFE_NO_PAD.encode(asn1.sign(&rng, msg.as_bytes()).expect("s").as_ref()); + for bad in [der.as_str(), "", "!!!", "AAAA"] { + let r = st + .mfa_approve(&dp(&p), &c.challenge_id, c.number, bad) + .await; + assert!( + matches!(r, Err(MfaError::InvalidRequest(_))), + "{bad:?}: {r:?}" + ); + let r = st.mfa_deny(&dp(&p), &c.challenge_id, bad).await; + assert!( + matches!(r, Err(MfaError::InvalidRequest(_))), + "{bad:?}: {r:?}" + ); + } + assert_eq!(row(&st, &c.challenge_id).await.status, "pending"); +} + +#[tokio::test] +async fn deny_requires_valid_signature_and_never_approves() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let d = details(&st, &p, &c.challenge_id).await; + let bad = sign_message( + &p.request, + &rustmfa_contract::ChallengeMessage { + challenge_id: &d.challenge_id, + nonce: &d.nonce, + login_id: &d.login_id, + device_id: &p.id, + expires_at: &d.expires_at, + audience: &d.audience, + }, + ); + let r = st.mfa_deny(&dp(&p), &c.challenge_id, &bad).await; + assert!(matches!(r, Err(MfaError::DeviceNotAuthorized)), "{r:?}"); + assert_eq!(row(&st, &c.challenge_id).await.status, "pending"); + let s = st + .mfa_deny(&dp(&p), &c.challenge_id, &answer(&p, &d)) + .await + .expect("deny"); + assert_eq!(s, ChallengeStatus::Denied); + assert_eq!(row(&st, &c.challenge_id).await.status, "denied"); + assert!( + actions(&st) + .await + .contains(&"mfa.challenge_denied".to_owned()) + ); + // a denied challenge cannot be approved afterwards + let r = st + .mfa_approve(&dp(&p), &c.challenge_id, c.number, &answer(&p, &d)) + .await; + assert!(matches!(r, Err(MfaError::AlreadyConsumed)), "{r:?}"); +} + +#[tokio::test] +async fn deny_after_expiry_gives_expired() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let sig = answer(&p, &details(&st, &p, &c.challenge_id).await); + let r = st + .mfa_deny_at(&dp(&p), &c.challenge_id, &sig, now() + 100) + .await; + assert!(matches!(r, Err(MfaError::Expired)), "{r:?}"); +} + +#[tokio::test] +async fn pending_list_excludes_resolved_and_expired() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let other = second_phone(&st, &p, "dev_b").await; + let open = create(&st, &p, "l1").await; + let done = create(&st, &p, "l2").await; + let _elsewhere = create(&st, &other, "l3").await; + let sig = answer(&p, &details(&st, &p, &done.challenge_id).await); + st.mfa_deny(&dp(&p), &done.challenge_id, &sig) + .await + .expect("deny"); + let list = st.mfa_pending_challenges(&dp(&p)).await.expect("list"); + let ids: Vec<&str> = list.iter().map(|d| d.challenge_id.as_str()).collect(); + assert_eq!(ids, [open.challenge_id.as_str()]); + assert_eq!(list[0].status, ChallengeStatus::Pending); + let later = st + .mfa_pending_challenges_at(&dp(&p), now() + 100) + .await + .expect("list"); + assert!(later.is_empty()); +} + +struct Recording { + calls: AtomicUsize, + fail: bool, +} + +impl PushChannel for Recording { + fn notify<'a>(&'a self, channel: &'a str, token: &'a str, id: &'a str) -> PushFuture<'a> { + Box::pin(async move { + assert_eq!((channel, token), ("fcm", "fcm-secret-token")); + assert!(id.starts_with("ch_")); + self.calls.fetch_add(1, Ordering::SeqCst); + if self.fail { + Err("gateway said no".into()) + } else { + Ok(()) + } + }) + } +} + +async fn wait_for(mut cond: impl FnMut() -> bool) { + for _ in 0..200 { + if cond() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("condition not reached"); +} + +#[tokio::test] +async fn push_is_sent_after_creation_with_the_challenge_id() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + st.inner + .db + .mfa_device_set_push("dev_a", "fcm", "fcm-secret-token") + .await + .expect("set"); + let rec = Arc::new(Recording { + calls: AtomicUsize::new(0), + fail: false, + }); + st.set_push_channel_for_tests(rec.clone()); + create(&st, &p, "l").await; + wait_for(|| rec.calls.load(Ordering::SeqCst) == 1).await; +} + +#[tokio::test] +async fn push_failure_does_not_fail_creation_and_is_logged_without_token() { + let cap = captured(); + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_pushfail").await; + st.inner + .db + .mfa_device_set_push("dev_pushfail", "fcm", "fcm-secret-token") + .await + .expect("set"); + let rec = Arc::new(Recording { + calls: AtomicUsize::new(0), + fail: true, + }); + st.set_push_channel_for_tests(rec.clone()); + let c = create(&st, &p, "l").await; + assert_eq!(row(&st, &c.challenge_id).await.status, "pending"); + let logged = || { + let out = String::from_utf8(cap.0.lock().expect("lock").clone()).expect("utf8"); + out.lines() + .any(|l| l.contains(&c.challenge_id) && l.contains("gateway said no")) + }; + wait_for(logged).await; + let out = String::from_utf8(cap.0.lock().expect("lock").clone()).expect("utf8"); + assert!(!out.contains("fcm-secret-token")); +} + +#[tokio::test] +async fn create_is_rate_limited_per_user() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let b = enrolled(&st, "ben", "dev_b").await; + for i in 0..5 { + create(&st, &a, &format!("l{i}")).await; + } + let r = st + .mfa_create_challenge(a.user_id, &a.id, "l9", "x", IP) + .await; + assert!(matches!(r, Err(MfaError::RateLimited { .. })), "{r:?}"); + create(&st, &b, "l1").await; +} + +#[tokio::test] +async fn audit_events_written_for_create_approve_deny_mismatch_expire() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let ok = create(&st, &p, "l1").await; + let no = create(&st, &p, "l2").await; + let mis = create(&st, &p, "l3").await; + let old = create(&st, &p, "l4").await; + let sig = |id: &str| { + let id = id.to_owned(); + let st = &st; + let p = &p; + async move { answer(p, &details(st, p, &id).await) } + }; + st.mfa_approve( + &dp(&p), + &ok.challenge_id, + ok.number, + &sig(&ok.challenge_id).await, + ) + .await + .expect("approve"); + st.mfa_deny(&dp(&p), &no.challenge_id, &sig(&no.challenge_id).await) + .await + .expect("deny"); + let d = details(&st, &p, &mis.challenge_id).await; + let wrong = d + .candidate_numbers + .iter() + .copied() + .find(|n| *n != mis.number) + .expect("w"); + let _ = st + .mfa_approve( + &dp(&p), + &mis.challenge_id, + wrong, + &sig(&mis.challenge_id).await, + ) + .await; + let _ = st + .mfa_challenge_status_at(&old.challenge_id, now() + 100) + .await; + let events = st + .audit_events(&crate::Actor::Cli, 0) + .await + .expect("events"); + let has = |a: &str, target: &str| events.iter().any(|e| e.action == a && e.target == target); + assert!(has("mfa.challenge_created", &ok.challenge_id)); + assert!(has("mfa.challenge_approved", &ok.challenge_id)); + assert!(has("mfa.challenge_denied", &no.challenge_id)); + assert!(has("mfa.number_mismatch", &mis.challenge_id)); + assert!(has("mfa.challenge_expired", &old.challenge_id)); + let actors: Vec<&str> = events + .iter() + .filter(|e| e.action.starts_with("mfa.challenge") || e.action == "mfa.number_mismatch") + .map(|e| e.actor.as_str()) + .collect(); + assert!(actors.iter().all(|a| *a == "anna"), "{actors:?}"); + let all: String = events + .iter() + .map(|e| format!("{} {} {}", e.target, e.details, e.actor)) + .collect(); + let d = details(&st, &p, &ok.challenge_id).await; + for secret in [d.nonce.as_str(), "fcm-token", &answer(&p, &d)] { + assert!(!all.contains(secret), "audit contains {secret}"); + } + let _ = add_user; +} + +#[tokio::test] +async fn the_lifetime_follows_the_configuration() { + let (_d, st) = state().await; + let st = st.with_mfa(crate::MfaConfig { + challenge_ttl_secs: 30, + ..crate::MfaConfig::default() + }); + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + assert_eq!(c.expires_in_seconds, 30); + let r = row(&st, &c.challenge_id).await; + assert_eq!(r.expires_at - r.created_at, 30); +} + +#[tokio::test] +async fn pending_list_never_shows_a_challenge_of_another_user() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let b = enrolled(&st, "ben", "dev_b").await; + // a row that breaks the rule "a challenge belongs to its device's user" + st.inner + .db + .mfa_challenge_insert(&rostdav_meta::NewChallenge { + id: "ch_stray", + user_id: b.user_id, + device_id: &a.id, + login_ref: "l", + nonce: "n", + number: 42, + candidates: "[42,11,12]", + audience: "rostdav", + login_device: "x", + expires_at: now() + 90, + }) + .await + .expect("insert"); + let list = st.mfa_pending_challenges(&dp(&a)).await.expect("list"); + assert!(list.is_empty(), "{list:?}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn many_concurrent_approves_still_give_exactly_one_winner() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + for round in 0..5 { + unlimit(&st, p.user_id); + let c = create(&st, &p, &format!("l{round}")).await; + let sig = answer(&p, &details(&st, &p, &c.challenge_id).await); + let tasks: Vec<_> = (0..16) + .map(|_| { + let (st, d, id, sig) = (st.clone(), dp(&p), c.challenge_id.clone(), sig.clone()); + tokio::spawn(async move { st.mfa_approve(&d, &id, c.number, &sig).await }) + }) + .collect(); + let mut oks = 0; + for t in tasks { + match t.await.expect("join") { + Ok(_) => oks += 1, + Err(MfaError::AlreadyConsumed) => {} + Err(e) => panic!("unexpected {e:?}"), + } + } + assert_eq!(oks, 1, "round {round}"); + } +} + +#[tokio::test] +async fn a_stale_row_loses_to_an_answer_that_came_first() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let stale = row(&st, &c.challenge_id).await; + assert!( + st.inner + .db + .mfa_challenge_resolve(&c.challenge_id, "approved", now()) + .await + .expect("resolve") + ); + let r = st + .finish( + &stale, + ChallengeStatus::Approved, + "mfa.challenge_approved", + now(), + ) + .await; + assert!(matches!(r, Err(MfaError::AlreadyConsumed)), "{r:?}"); + // lost to a denial the same way + let r = st + .finish( + &stale, + ChallengeStatus::Denied, + "mfa.challenge_denied", + now(), + ) + .await; + assert!(matches!(r, Err(MfaError::AlreadyConsumed)), "{r:?}"); + assert_eq!( + actions(&st) + .await + .iter() + .filter(|a| *a == "mfa.challenge_approved") + .count(), + 0 + ); +} + +#[tokio::test] +async fn a_stale_row_whose_time_ran_out_gives_expired() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + let stale = row(&st, &c.challenge_id).await; + let r = st + .finish( + &stale, + ChallengeStatus::Approved, + "mfa.challenge_approved", + now() + 100, + ) + .await; + assert!(matches!(r, Err(MfaError::Expired)), "{r:?}"); + assert_eq!(row(&st, &c.challenge_id).await.status, "expired"); +} diff --git a/crates/rostdav-app/src/push_mfa/devauth_tests.rs b/crates/rostdav-app/src/push_mfa/devauth_tests.rs index 81e1e6e..6ec8a07 100644 --- a/crates/rostdav-app/src/push_mfa/devauth_tests.rs +++ b/crates/rostdav-app/src/push_mfa/devauth_tests.rs @@ -1,57 +1,14 @@ -use std::sync::{Arc, Mutex}; - use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use ring::rand::SystemRandom; -use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair}; -use rostdav_meta::NewDevice; +use ring::signature::EcdsaKeyPair; use super::devauth::parse_authorization; use crate::AppState; use crate::push_mfa::MfaError; -use crate::push_mfa::testkit::{NOW, add_user, spki_b64, state}; +use crate::push_mfa::testkit::{NOW, Phone, captured, enrolled, fixed_key, spki_b64, state}; const PATH: &str = "/mfa/v1/challenges?status=pending"; -/// A key whose signatures are raw R||S, as the phone's are. -fn fixed_key() -> EcdsaKeyPair { - let rng = SystemRandom::new(); - let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).expect("key"); - EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).expect("pair") -} - -struct Phone { - id: String, - request: EcdsaKeyPair, - approval: EcdsaKeyPair, - user_id: i64, -} - -async fn enrolled(st: &AppState, user: &str, id: &str) -> Phone { - let p = add_user(st, user).await; - let (request, approval) = (fixed_key(), fixed_key()); - st.inner - .db - .mfa_device_insert(&NewDevice { - id, - user_id: p.user_id, - name: "Pixel", - request_pub: &spki_b64(&request), - approval_pub: &spki_b64(&approval), - push_channel: "fcm", - push_token: "t", - hardware_backed: true, - strongbox: false, - }) - .await - .expect("insert"); - Phone { - id: id.to_owned(), - request, - approval, - user_id: p.user_id, - } -} - fn sign_with( key: &EcdsaKeyPair, method: &str, @@ -415,46 +372,6 @@ async fn last_seen_is_updated_on_success_only() { assert_eq!(seen().await, Some(NOW)); } -#[derive(Clone, Default)] -struct Capture(Arc>>); - -impl std::io::Write for Capture { - fn write(&mut self, b: &[u8]) -> std::io::Result { - if let Ok(mut g) = self.0.lock() { - g.extend_from_slice(b); - } - Ok(b.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture { - type Writer = Capture; - fn make_writer(&'a self) -> Capture { - self.clone() - } -} - -/// One capturing subscriber for the whole test binary. A per-test subscriber is not reliable: -/// tracing caches whether a call site is enabled, and a parallel test without a subscriber can -/// switch it off. -fn captured() -> &'static Capture { - static CAPTURE: std::sync::OnceLock = std::sync::OnceLock::new(); - CAPTURE.get_or_init(|| { - let cap = Capture::default(); - let sub = tracing_subscriber::fmt() - .with_max_level(tracing::Level::TRACE) - .with_ansi(false) - .with_writer(cap.clone()) - .finish(); - // the first test to get here wins; no other test sets a global subscriber - let _ = tracing::subscriber::set_global_default(sub); - cap - }) -} - #[tokio::test] async fn no_secret_or_signature_in_logs() { let cap = captured(); diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs index 630c973..f6990ca 100644 --- a/crates/rostdav-app/src/push_mfa/mod.rs +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -1,6 +1,9 @@ //! Push sign-in approval with the RustMFA app (ADR 0014). mod attestation; +mod challenges; +#[cfg(test)] +mod challenges_tests; mod devauth; #[cfg(test)] mod devauth_tests; @@ -9,14 +12,17 @@ mod enroll; mod enroll_tests; mod error; mod google_roots; +mod push; #[cfg(test)] pub(crate) mod testkit; pub use attestation::{AttestationResult, verify_attestation}; +pub use challenges::{ChallengeDetails, ChallengeStatus, NewChallenge}; pub use devauth::{DevicePrincipal, ParsedAuth, parse_authorization}; pub use enroll::{EnrollToken, RegisterInput, Registered}; pub use error::MfaError; pub use google_roots::google_roots; +pub use push::{NoopPush, PushChannel, PushFuture}; use crate::AppState; diff --git a/crates/rostdav-app/src/push_mfa/push.rs b/crates/rostdav-app/src/push_mfa/push.rs new file mode 100644 index 0000000..6491bc3 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/push.rs @@ -0,0 +1,36 @@ +//! Waking a phone for a new challenge. The real channels (FCM, UnifiedPush) arrive with the +//! sign-in page work; until then the default does nothing but log the challenge id. + +use std::future::Future; +use std::pin::Pin; + +/// What a push channel returns. The error text is logged, so it must not hold the token. +pub type PushFuture<'a> = Pin> + Send + 'a>>; + +/// Sends the wake-up message for one challenge. The message carries the challenge id only, never +/// the number or the candidates. +pub trait PushChannel: Send + Sync { + fn notify<'a>( + &'a self, + channel: &'a str, + token: &'a str, + challenge_id: &'a str, + ) -> PushFuture<'a>; +} + +/// The default: no push, the challenge is found when the app polls. +pub struct NoopPush; + +impl PushChannel for NoopPush { + fn notify<'a>( + &'a self, + _channel: &'a str, + _token: &'a str, + challenge_id: &'a str, + ) -> PushFuture<'a> { + Box::pin(async move { + tracing::debug!(challenge_id, "push is not configured; nothing sent"); + Ok(()) + }) + } +} diff --git a/crates/rostdav-app/src/push_mfa/testkit.rs b/crates/rostdav-app/src/push_mfa/testkit.rs index a2a42c0..8f8c14c 100644 --- a/crates/rostdav-app/src/push_mfa/testkit.rs +++ b/crates/rostdav-app/src/push_mfa/testkit.rs @@ -16,8 +16,11 @@ use x509_cert::spki::{AlgorithmIdentifierOwned, SubjectPublicKeyInfoOwned}; use x509_cert::time::{Time, Validity}; use crate::{AppState, Principal, Scope, Via}; +use ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING; use rostdav_meta::Db; +use rostdav_meta::NewDevice; use rostdav_storage::LocalStore; +use std::sync::{Arc, Mutex}; pub(crate) const NOW: i64 = 1_800_000_000; pub(crate) const EC_PUBLIC_KEY: ObjectIdentifier = @@ -235,3 +238,83 @@ pub(crate) async fn add_user(st: &AppState, name: &str) -> Principal { scope: Scope::Full, } } + +/// A key whose signatures are raw R||S, as the phone's are. +pub(crate) fn fixed_key() -> EcdsaKeyPair { + let rng = SystemRandom::new(); + let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).expect("key"); + EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).expect("pair") +} + +pub(crate) struct Phone { + pub(crate) id: String, + pub(crate) request: EcdsaKeyPair, + pub(crate) approval: EcdsaKeyPair, + pub(crate) user_id: i64, +} + +pub(crate) async fn enrolled(st: &AppState, user: &str, id: &str) -> Phone { + let p = add_user(st, user).await; + let (request, approval) = (fixed_key(), fixed_key()); + st.inner + .db + .mfa_device_insert(&NewDevice { + id, + user_id: p.user_id, + name: "Pixel", + request_pub: &spki_b64(&request), + approval_pub: &spki_b64(&approval), + push_channel: "fcm", + push_token: "t", + hardware_backed: true, + strongbox: false, + }) + .await + .expect("insert"); + Phone { + id: id.to_owned(), + request, + approval, + user_id: p.user_id, + } +} + +#[derive(Clone, Default)] +pub(crate) struct Capture(pub(crate) Arc>>); + +impl std::io::Write for Capture { + fn write(&mut self, b: &[u8]) -> std::io::Result { + if let Ok(mut g) = self.0.lock() { + g.extend_from_slice(b); + } + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture { + type Writer = Capture; + fn make_writer(&'a self) -> Capture { + self.clone() + } +} + +/// One capturing subscriber for the whole test binary. A per-test subscriber is not reliable: +/// tracing caches whether a call site is enabled, and a parallel test without a subscriber can +/// switch it off. +pub(crate) fn captured() -> &'static Capture { + static CAPTURE: std::sync::OnceLock = std::sync::OnceLock::new(); + CAPTURE.get_or_init(|| { + let cap = Capture::default(); + let sub = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_ansi(false) + .with_writer(cap.clone()) + .finish(); + // the first test to get here wins; no other test sets a global subscriber + let _ = tracing::subscriber::set_global_default(sub); + cap + }) +} diff --git a/crates/rostdav-meta/migrations/0013_push_mfa.sql b/crates/rostdav-meta/migrations/0013_push_mfa.sql index bb9d3da..c634bb3 100644 --- a/crates/rostdav-meta/migrations/0013_push_mfa.sql +++ b/crates/rostdav-meta/migrations/0013_push_mfa.sql @@ -45,7 +45,8 @@ CREATE TABLE mfa_challenges ( expires_at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied', 'expired')), - consumed_at INTEGER + consumed_at INTEGER, + login_device TEXT NOT NULL DEFAULT '' ) STRICT; CREATE INDEX mfa_challenges_user ON mfa_challenges(user_id); CREATE INDEX mfa_challenges_device ON mfa_challenges(device_id, status); diff --git a/crates/rostdav-meta/src/push_mfa.rs b/crates/rostdav-meta/src/push_mfa.rs index 971d26c..3959ff0 100644 --- a/crates/rostdav-meta/src/push_mfa.rs +++ b/crates/rostdav-meta/src/push_mfa.rs @@ -93,6 +93,8 @@ pub struct NewChallenge<'a> { /// JSON array of the three numbers shown on the phone. pub candidates: &'a str, pub audience: &'a str, + /// What the user is signing in with, shown on the phone (for example "Firefox on Linux"). + pub login_device: &'a str, pub expires_at: i64, } @@ -111,10 +113,11 @@ pub struct ChallengeRow { /// `pending`, `approved`, `denied` or `expired`. pub status: String, pub consumed_at: Option, + pub login_device: String, } const CHALLENGE_COLUMNS: &str = "id, user_id, device_id, login_ref, nonce, number, candidates, \ - audience, created_at, expires_at, status, consumed_at"; + audience, created_at, expires_at, status, consumed_at, login_device"; type ChallengeTuple = ( String, @@ -129,6 +132,7 @@ type ChallengeTuple = ( i64, String, Option, + String, ); impl From for ChallengeRow { @@ -146,6 +150,7 @@ impl From for ChallengeRow { expires_at: t.9, status: t.10, consumed_at: t.11, + login_device: t.12, } } } @@ -323,8 +328,8 @@ impl Db { pub async fn mfa_challenge_insert(&self, c: &NewChallenge<'_>) -> Result<()> { sql::query( "INSERT INTO mfa_challenges (id, user_id, device_id, login_ref, nonce, number, - candidates, audience, created_at, expires_at, status, consumed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL)", + candidates, audience, created_at, expires_at, status, consumed_at, login_device) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, ?)", ) .bind(c.id) .bind(c.user_id) @@ -336,6 +341,7 @@ impl Db { .bind(c.audience) .bind(now_secs()) .bind(c.expires_at) + .bind(c.login_device) .execute(self.pool()) .await .map_err(err)?; @@ -387,6 +393,22 @@ impl Db { .map_err(err)?; Ok(r.rows_affected() > 0) } + + /// Marks a pending challenge whose time has run out as `expired`. True for the one caller + /// that changed it, so the expiry is recorded once. + pub async fn mfa_challenge_expire(&self, id: &str, now: i64) -> Result { + let r = sql::query( + "UPDATE mfa_challenges SET status = 'expired', consumed_at = ? + WHERE id = ? AND status = 'pending' AND expires_at <= ?", + ) + .bind(now) + .bind(id) + .bind(now) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(r.rows_affected() > 0) + } } #[cfg(test)] @@ -436,10 +458,58 @@ mod tests { number: 47, candidates: "[47,12,83]", audience: "rostdav", + login_device: "Firefox on Linux", expires_at, } } + #[tokio::test] + async fn challenge_keeps_the_login_device() { + let (db, uid) = db_with_user().await; + db.mfa_device_insert(&device("d1", uid)).await.expect("dev"); + db.mfa_challenge_insert(&challenge("c1", "d1", uid, 2_000_000_000)) + .await + .expect("insert"); + let row = db.mfa_challenge_get("c1").await.expect("get").expect("row"); + assert_eq!(row.login_device, "Firefox on Linux"); + } + + #[tokio::test] + async fn expire_marks_only_pending_challenges_that_are_past_due() { + let (db, uid) = db_with_user().await; + db.mfa_device_insert(&device("d1", uid)).await.expect("dev"); + for id in ["c1", "c2", "c3"] { + db.mfa_challenge_insert(&challenge(id, "d1", uid, 1_000)) + .await + .expect("insert"); + } + // not due yet + assert!(!db.mfa_challenge_expire("c1", 999).await.expect("e")); + // c2 was answered before it ran out + assert!( + db.mfa_challenge_resolve("c2", "denied", 500) + .await + .expect("r") + ); + assert!(db.mfa_challenge_expire("c1", 1_000).await.expect("e")); + // once only, and an answered challenge keeps its status + assert!(!db.mfa_challenge_expire("c1", 1_001).await.expect("e")); + assert!(!db.mfa_challenge_expire("c2", 1_001).await.expect("e")); + let status = |id: &'static str| { + let db = db.clone(); + async move { + db.mfa_challenge_get(id) + .await + .expect("g") + .expect("row") + .status + } + }; + assert_eq!(status("c1").await, "expired"); + assert_eq!(status("c2").await, "denied"); + assert_eq!(status("c3").await, "pending"); + } + #[tokio::test] async fn enroll_token_is_single_use_and_expires() { let (db, uid) = db_with_user().await; diff --git a/docs/admin.md b/docs/admin.md index e793d25..6498974 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -146,6 +146,8 @@ A value outside its range stops the server at start-up with a message naming the A phone whose signed request fails five times in 15 minutes (wrong signature, unknown device, replayed nonce, bad timestamp) is blocked for 15 minutes; this block is not listed on the lockouts page yet. Requests of a revoked phone, or of a disabled account, are refused. +A user can have five sign-in approvals started in 15 minutes (this keeps a stolen password from flooding a phone with prompts); further attempts are refused until the window ends. The approval screen shows the sign-in device name but no location: Rostdav has no IP geolocation. + ## Version check Off by default. Administration, Server, "Updates" turns it on: the server then asks the GitHub releases API once a day (and on "Check now") which version is the newest, and shows a newer one with its release notes. The request carries only `User-Agent: rostdav/`. While it is off, the server makes no outbound connection. After an error the server tries again in an hour; the page shows the reason. The switch is kept in the database, so a restart keeps it. Events: `admin.update_check_on`, `admin.update_check_off`, `admin.update_installed`. The About page (every user) shows the release notes of the running build and the versions before it; they are compiled in and need no network. diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index a10abe9..c6e3baa 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -24,7 +24,7 @@ This ADR covers the server side: devices, enrollment, challenges and the HTTP AP - **Enrollment.** Enroll token: 256 random bits, stored only as a hash (`secrets::hash_token`), single use, 5 minutes, bound to the creating user. Registration uses the token and a **key attestation** chain for the approval key. The attestation challenge is the SHA-256 of the enroll token. By default the chain must verify to a pinned Google attestation root and show a hardware-backed key (TEE or StrongBox); software-only keys are rejected. `[mfa] attestation = "off"` allows custom ROMs and is documented with its trade-off. A token is consumed before the chain is checked, so a failed attempt burns the token. - **Request authentication.** Header `Authorization: RustMFA-Sig device_id="…",ts="…",nonce="…",sig="…"`. Values are limited to `[A-Za-z0-9_-]` and 128 bytes. `sig` is a raw 64-byte R‖S signature, base64url, over `METHOD \n path-with-query \n ts \n nonce \n base64url(sha256(body))`. The server rejects a timestamp more than 60 seconds off and any nonce seen within 5 minutes for that device. A nonce is recorded only after the signature verified. Five failures per device in 15 minutes lock that device's requests for 15 minutes (existing limiter style). A success resets the count. A revoked phone gets `DEVICE_REVOKED` only after its signature verified, so someone without the key cannot learn that a device id was revoked; a disabled account is `UNAUTHENTICATED`. - **Challenge.** Created in process by the sign-in code for one explicit device (the most recently seen active one). 90 seconds by default (`[mfa] challenge_ttl_seconds`, 10 to 300). Contains a 128-bit nonce, a two-digit number (10 to 99) and two decoys. The device receives the three candidates but never a marker of the right one. -- **Answer.** The approve and deny requests carry a raw R‖S signature by the `approval_key` over the canonical message `v1 \n challenge_id \n nonce \n login_id \n device_id \n expires_at \n audience` (exact format and test vectors in the contract). The server checks: device active and owner of the challenge; challenge pending and not expired; number matches (a mismatch resolves the challenge to `denied`); signature valid. Only then one conditional `UPDATE … WHERE status = 'pending' AND expires_at > now` consumes the challenge, so two simultaneous answers cannot both win. A bad signature never consumes a challenge. +- **Answer.** The approve and deny requests carry a raw R‖S signature by the `approval_key` over the canonical message `v1 \n challenge_id \n nonce \n login_id \n device_id \n expires_at \n audience` (exact format and test vectors in the contract). The server checks: device active and owner of the challenge; challenge pending and not expired; number matches (a mismatch resolves the challenge to `denied`); signature valid. Only then one conditional `UPDATE … WHERE status = 'pending' AND expires_at > now` consumes the challenge, so two simultaneous answers cannot both win. A bad signature never consumes a challenge. **Amendment 2026-11-09:** the signature is verified before the number is compared (the spec listed the number first). A wrong number ends the challenge, and that must need the biometric-gated key; otherwise the non-biometric request key alone could kill pending sign-ins. An invalid signature is `DEVICE_NOT_AUTHORIZED`, a malformed or DER one `INVALID_REQUEST`. Challenges also store `login_device` (the sign-in client's name, cleaned) for the approval screen; the approximate location is always empty because there is no geolocation. Challenge creation is limited to five per user in 15 minutes. - **No existence leaks.** Another user's challenge or device is `404`; another device of the same user is `403`. - **Errors.** One format `{"error":{"code","message"}}`, codes as in the contract. No secrets, signatures, push tokens or keys in logs or errors. - **Audit.** `mfa.device_registered`, `mfa.device_revoked`, `mfa.key_rotated`, `mfa.challenge_created`, `mfa.challenge_approved`, `mfa.challenge_denied`, `mfa.number_mismatch`, `mfa.challenge_expired`. diff --git a/docs/architecture.md b/docs/architecture.md index 9667a8f..4b92181 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,7 +105,7 @@ A central error type in `rostdav-core` (`NotFound`, `Forbidden`, `Conflict`, `Pr | `recovery_codes` | id, user_id, code_hash, used_at | | `mfa_devices` | id, user_id, name, status (active/revoked), request_pub, approval_pub, push_channel, push_token, hardware_backed, strongbox, created_at, last_seen_at, revoked_at ([ADR 0014](adr/0014-push-mfa.md)) | | `mfa_enroll_tokens` | token_hash, user_id, expires_at, used_at | -| `mfa_challenges` | id, user_id, device_id, login_ref, nonce, number, candidates, audience, created_at, expires_at, status, consumed_at | +| `mfa_challenges` | id, user_id, device_id, login_ref, nonce, number, candidates, audience, created_at, expires_at, status, consumed_at, login_device | | `mfa_nonces` | device_id, nonce, expires_at | | `nodes` | see ADR 0002 | | `blobs` | id, size, checksum, created_at | From e0addc619b8c5c5ad67715b30127237edb4daf9b Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Mon, 9 Nov 2026 22:16:59 +0100 Subject: [PATCH 09/24] feat(mfa): device management and audit view Add the phone management service for RustMFA (ADR 0014): list a user's phones, revoke one (which ends its open challenges), change a phone's push token, rotate both keys, and read the user's own mfa events. A challenge can now only be resolved while its phone is active, inside the single conditional update, so a revoke that lands between the checks and the update wins. Key rotation needs a fresh attestation under the default policy, bound to the device id and the new request key. Migration 0013 gains an events(actor, action) index for the own-events view. --- CHANGELOG.md | 1 + crates/rostdav-app/src/lib.rs | 8 +- crates/rostdav-app/src/push_mfa/challenges.rs | 13 +- .../src/push_mfa/challenges_tests.rs | 30 + crates/rostdav-app/src/push_mfa/devices.rs | 286 +++++++ .../rostdav-app/src/push_mfa/devices_tests.rs | 696 ++++++++++++++++++ crates/rostdav-app/src/push_mfa/enroll.rs | 25 +- crates/rostdav-app/src/push_mfa/mod.rs | 4 + crates/rostdav-app/src/push_mfa/testkit.rs | 10 + .../rostdav-meta/migrations/0013_push_mfa.sql | 3 + crates/rostdav-meta/src/push_mfa.rs | 166 ++++- docs/admin.md | 2 + docs/adr/0014-push-mfa.md | 1 + docs/architecture.md | 2 + 14 files changed, 1226 insertions(+), 21 deletions(-) create mode 100644 crates/rostdav-app/src/push_mfa/devices.rs create mode 100644 crates/rostdav-app/src/push_mfa/devices_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d21122d..98731c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https - **Enrollment service for the RustMFA app**: a signed-in user creates a single-use token (QR code), the phone registers its two public keys with the token and an attestation. Wrong tokens and failed attestations are rate limited per address. Not reachable over HTTP yet. - **Request authentication for the RustMFA app**: checks the signed `Authorization: RustMFA-Sig` header of a phone (timestamp within 60 seconds, signature by the request key, nonce used once in 5 minutes, phone and account still active). Five failures for one phone in 15 minutes block it for 15 minutes. Not reachable over HTTP yet. - **Sign-in challenges for the RustMFA app**: the server can start an approval for one phone (a two-digit number, two decoys, 90 seconds by default), list pending ones, and accept an approve or deny signed with the phone's biometric key. A wrong number denies the challenge. Only one answer can win. Five challenges per user in 15 minutes. Push delivery is a stub until the sign-in page work; not reachable over HTTP yet. +- **Phone management for the RustMFA app**: list, revoke (this also ends the phone's open sign-in approvals), change the push token, rotate both keys (the new approval key needs a fresh attestation), and a view of the user's own security events. A revoked phone can no longer approve, even an answer that was already in flight. Not reachable over HTTP yet. ## [0.10.0] - 2026-10-25 diff --git a/crates/rostdav-app/src/lib.rs b/crates/rostdav-app/src/lib.rs index 2ec47aa..27cf79a 100644 --- a/crates/rostdav-app/src/lib.rs +++ b/crates/rostdav-app/src/lib.rs @@ -57,10 +57,10 @@ pub use login_flow::FlowStep; pub use login_flow::{Granted, StartedFlow}; pub use password::{MIN_PASSWORD_CHARS, hash_password, verify_password}; pub use push_mfa::{ - AttestationPolicy, AttestationResult, ChallengeDetails, ChallengeStatus, DevicePrincipal, - EnrollToken, MfaConfig, MfaError, NewChallenge as MfaNewChallenge, NoopPush, ParsedAuth, - PushChannel, PushFuture, RegisterInput, Registered, google_roots, parse_authorization, - verify_attestation, + AttestationPolicy, AttestationResult, ChallengeDetails, ChallengeStatus, DeviceInfo, + DevicePrincipal, EnrollToken, MfaAuditEvent, MfaConfig, MfaError, + NewChallenge as MfaNewChallenge, NoopPush, ParsedAuth, PushChannel, PushFuture, RegisterInput, + Registered, google_roots, parse_authorization, verify_attestation, }; pub use search::{KindFilter, MAX_VISITED, SearchHit, SearchPage, SearchQuery, SnippetPart}; pub use shares::{ diff --git a/crates/rostdav-app/src/push_mfa/challenges.rs b/crates/rostdav-app/src/push_mfa/challenges.rs index 01e541c..702cca8 100644 --- a/crates/rostdav-app/src/push_mfa/challenges.rs +++ b/crates/rostdav-app/src/push_mfa/challenges.rs @@ -486,7 +486,18 @@ impl AppState { .await? .ok_or(MfaError::NotFound)?; Self::open_or_err(self.current_status(&fresh, now).await?)?; - return Err(MfaError::AlreadyConsumed); + // still open: the update refused because the phone was revoked in between + let revoked = self + .inner + .db + .mfa_device_get(&row.device_id) + .await? + .is_none_or(|d| d.status != "active"); + return Err(if revoked { + MfaError::DeviceRevoked + } else { + MfaError::AlreadyConsumed + }); } self.audit_challenge(row, action).await; Ok(()) diff --git a/crates/rostdav-app/src/push_mfa/challenges_tests.rs b/crates/rostdav-app/src/push_mfa/challenges_tests.rs index 330998a..edaed1a 100644 --- a/crates/rostdav-app/src/push_mfa/challenges_tests.rs +++ b/crates/rostdav-app/src/push_mfa/challenges_tests.rs @@ -967,3 +967,33 @@ async fn a_stale_row_whose_time_ran_out_gives_expired() { assert!(matches!(r, Err(MfaError::Expired)), "{r:?}"); assert_eq!(row(&st, &c.challenge_id).await.status, "expired"); } + +#[tokio::test] +async fn an_answer_that_lost_to_a_revoke_gives_device_revoked_and_never_approves() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = create(&st, &p, "l").await; + // the answer read the challenge and the device while both were fine ... + let stale = row(&st, &c.challenge_id).await; + // ... then the phone was revoked before its update ran + st.inner + .db + .mfa_device_revoke("dev_a", p.user_id, now()) + .await + .expect("revoke"); + let r = st + .finish( + &stale, + ChallengeStatus::Approved, + "mfa.challenge_approved", + now(), + ) + .await; + assert!(matches!(r, Err(MfaError::DeviceRevoked)), "{r:?}"); + assert_eq!(row(&st, &c.challenge_id).await.status, "pending"); + assert!( + !actions(&st) + .await + .contains(&"mfa.challenge_approved".to_owned()) + ); +} diff --git a/crates/rostdav-app/src/push_mfa/devices.rs b/crates/rostdav-app/src/push_mfa/devices.rs new file mode 100644 index 0000000..0946d7f --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/devices.rs @@ -0,0 +1,286 @@ +//! A user's phones: list, revoke, push token, key rotation, own audit events (ADR 0014). + +use rostdav_core::time::rfc3339; +use rostdav_meta::DeviceRow; +use serde_json::json; + +use super::attestation::{AttestationResult, verify_attestation}; +use super::enroll::{check_push, sha256}; +use super::{AttestationPolicy, DevicePrincipal, MfaError}; +use crate::{AppState, now_secs}; + +/// How many events the own-events view shows. +const AUDIT_LIMIT: i64 = 200; +/// The events of the contract (`AuditEvent.event`). Other `mfa.*` actions stay in the server log. +const VIEWED: [&str; 8] = [ + "device_registered", + "device_revoked", + "key_rotated", + "challenge_created", + "challenge_approved", + "challenge_denied", + "challenge_expired", + "number_mismatch", +]; + +/// What the app and the web page show of a phone. No keys, no push token. +#[derive(Debug)] +pub struct DeviceInfo { + pub device_id: String, + pub device_name: String, + pub status: String, + pub push_channel: String, + pub hardware_backed: bool, + pub strongbox: bool, + pub created_at: String, + pub last_seen_at: Option, +} + +/// One entry of a user's own security history. +#[derive(Debug)] +pub struct MfaAuditEvent { + pub event: String, + pub at: String, + pub device_id: Option, + pub detail: Option, +} + +fn info(d: DeviceRow) -> DeviceInfo { + DeviceInfo { + device_id: d.id, + device_name: d.name, + status: d.status, + push_channel: d.push_channel, + hardware_backed: d.hardware_backed, + strongbox: d.strongbox, + created_at: rfc3339(d.created_at), + last_seen_at: d.last_seen_at.map(rfc3339), + } +} + +impl AppState { + /// All phones of the user, revoked ones included and marked, oldest first. + pub async fn mfa_list_devices(&self, user_id: i64) -> Result, MfaError> { + Ok(self + .inner + .db + .mfa_devices_of(user_id) + .await? + .into_iter() + .map(info) + .collect()) + } + + /// Revokes an active phone of `user_id` and ends its open challenges. Unknown, foreign and + /// already revoked phones are all `NotFound`. The phone is blocked first, so an answer that + /// is in flight cannot approve after this returns. + pub async fn mfa_revoke_device( + &self, + user_id: i64, + device_id: &str, + actor: &str, + ) -> Result<(), MfaError> { + let now = now_secs(); + if !self + .inner + .db + .mfa_device_revoke(device_id, user_id, now) + .await? + { + return Err(MfaError::NotFound); + } + for c in self.inner.db.mfa_challenges_pending(device_id, now).await? { + if self.inner.db.mfa_challenge_cancel(&c.id, now).await? { + self.audit( + actor, + "mfa.challenge_expired", + &c.id, + None, + json!({ "device_id": device_id }), + ) + .await; + } + } + self.audit(actor, "mfa.device_revoked", device_id, None, json!({})) + .await; + Ok(()) + } + + /// A phone revokes a phone of its own account (itself or another one). + pub async fn mfa_revoke_device_as( + &self, + d: &DevicePrincipal, + device_id: &str, + ) -> Result<(), MfaError> { + let name = self.mfa_user_name(d.user_id).await?; + self.mfa_revoke_device(d.user_id, device_id, &name).await + } + + async fn mfa_user_name(&self, user_id: i64) -> Result { + Ok(self + .inner + .db + .find_user_by_id(user_id) + .await? + .ok_or(MfaError::NotFound)? + .name) + } + + /// The calling phone's own, still active row. + async fn own_active_device( + &self, + d: &DevicePrincipal, + device_id: &str, + ) -> Result { + if device_id != d.device_id { + return Err(MfaError::DeviceNotAuthorized); + } + let row = self + .inner + .db + .mfa_device_get(device_id) + .await? + .filter(|r| r.user_id == d.user_id) + .ok_or(MfaError::NotFound)?; + if row.status != "active" { + return Err(MfaError::DeviceRevoked); + } + Ok(row) + } + + /// A phone changes its own push channel or token. + pub async fn mfa_update_push_token( + &self, + d: &DevicePrincipal, + device_id: &str, + channel: &str, + token: &str, + ) -> Result<(), MfaError> { + self.own_active_device(d, device_id).await?; + check_push(channel, token)?; + if !self + .inner + .db + .mfa_device_set_push(device_id, channel, token) + .await? + { + return Err(MfaError::DeviceRevoked); + } + let name = self.mfa_user_name(d.user_id).await?; + self.audit( + &name, + "mfa.push_token_updated", + device_id, + None, + json!({ "push_channel": channel }), + ) + .await; + Ok(()) + } + + /// A phone replaces both of its keys. Under the default policy the new approval key must be + /// attested for the string `rotate::`, which ties the attestation to + /// this device and to the new request key. (The spec left rotation unattested.) The request + /// itself is signed with the old request key. + pub async fn mfa_rotate_keys( + &self, + d: &DevicePrincipal, + device_id: &str, + request_pub: &str, + approval_pub: &str, + chain: &[String], + ) -> Result<(), MfaError> { + self.own_active_device(d, device_id).await?; + if rustmfa_contract::check_spki(request_pub).is_err() { + return Err(MfaError::InvalidRequest( + "request public key is not a P-256 key", + )); + } + if rustmfa_contract::check_spki(approval_pub).is_err() { + return Err(MfaError::InvalidRequest( + "approval public key is not a P-256 key", + )); + } + if request_pub == approval_pub { + return Err(MfaError::InvalidRequest( + "request key and approval key must differ", + )); + } + let attested = match self.mfa_cfg().attestation { + AttestationPolicy::Required => verify_attestation( + chain, + approval_pub, + &sha256(format!("rotate:{device_id}:{request_pub}").as_bytes()), + &self.attestation_roots(), + now_secs(), + )?, + AttestationPolicy::Off => AttestationResult { + hardware_backed: false, + strongbox: false, + }, + }; + if !self + .inner + .db + .mfa_device_set_keys( + device_id, + request_pub, + approval_pub, + attested.hardware_backed, + attested.strongbox, + ) + .await? + { + return Err(MfaError::DeviceRevoked); + } + let name = self.mfa_user_name(d.user_id).await?; + self.audit( + &name, + "mfa.key_rotated", + device_id, + None, + json!({ + "hardware_backed": attested.hardware_backed, + "strongbox": attested.strongbox, + }), + ) + .await; + Ok(()) + } + + /// The user's own security history: the newest events first, only their own, only the kinds + /// of the contract, no ids of challenges and no secrets. + pub async fn mfa_audit_events(&self, user_id: i64) -> Result, MfaError> { + let Some(user) = self.inner.db.find_user_by_id(user_id).await? else { + return Ok(Vec::new()); + }; + let rows = self + .inner + .db + .events_of_actor_with_prefix(&user.name, "mfa.", AUDIT_LIMIT) + .await?; + Ok(rows + .into_iter() + .filter_map(|e| { + let event = e + .action + .strip_prefix("mfa.") + .filter(|n| VIEWED.contains(n))?; + // device events name the device as target, challenge events in their details + let device_id = if event.starts_with("device_") || event == "key_rotated" { + Some(e.target.clone()) + } else { + serde_json::from_str::(&e.details) + .ok() + .and_then(|v| v["device_id"].as_str().map(str::to_owned)) + }; + Some(MfaAuditEvent { + event: event.to_owned(), + at: rfc3339(e.ts), + device_id, + detail: None, + }) + }) + .collect()) + } +} diff --git a/crates/rostdav-app/src/push_mfa/devices_tests.rs b/crates/rostdav-app/src/push_mfa/devices_tests.rs new file mode 100644 index 0000000..8c14ad3 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/devices_tests.rs @@ -0,0 +1,696 @@ +use std::net::{IpAddr, Ipv4Addr}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ring::rand::SystemRandom; + +use super::{DevicePrincipal, MfaError}; +use crate::AppState; +use crate::push_mfa::testkit::{ + Opts, Phone, enrolled, fixed_key, fixture, live_opts, spki_b64, state, +}; +use crate::push_mfa::{AttestationPolicy, MfaConfig}; + +const IP: IpAddr = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 9)); + +fn now() -> i64 { + crate::now_secs() +} + +fn dp(p: &Phone) -> DevicePrincipal { + DevicePrincipal { + device_id: p.id.clone(), + user_id: p.user_id, + } +} + +fn sha256(data: &[u8]) -> Vec { + ring::digest::digest(&ring::digest::SHA256, data) + .as_ref() + .to_vec() +} + +async fn device(st: &AppState, id: &str) -> rostdav_meta::DeviceRow { + st.inner + .db + .mfa_device_get(id) + .await + .expect("get") + .expect("row") +} + +#[tokio::test] +async fn list_shows_only_own_devices() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let _b = enrolled(&st, "ben", "dev_b").await; + let list = st.mfa_list_devices(a.user_id).await.expect("list"); + assert_eq!(list.len(), 1); + let d = &list[0]; + assert_eq!( + ( + d.device_id.as_str(), + d.device_name.as_str(), + d.status.as_str() + ), + ("dev_a", "Pixel", "active") + ); + assert_eq!( + (d.push_channel.as_str(), d.hardware_backed, d.strongbox), + ("fcm", true, false) + ); + assert_eq!(d.created_at.len(), 20); + assert_eq!(d.last_seen_at, None); + // a revoked phone stays in the list, marked as such + st.mfa_revoke_device(a.user_id, "dev_a", "anna") + .await + .expect("revoke"); + let list = st.mfa_list_devices(a.user_id).await.expect("list"); + assert_eq!(list[0].status, "revoked"); + assert!(st.mfa_list_devices(9999).await.expect("none").is_empty()); +} + +#[tokio::test] +async fn list_has_no_secret_fields() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + // every field is named: a new field that could carry a key or token breaks this test + let super::DeviceInfo { + device_id: _, + device_name: _, + status: _, + push_channel: _, + hardware_backed: _, + strongbox: _, + created_at: _, + last_seen_at: _, + } = st + .mfa_list_devices(a.user_id) + .await + .expect("list") + .remove(0); +} + +#[tokio::test] +async fn revoke_marks_revoked_and_pending_challenges_expire() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let open = st + .mfa_create_challenge(a.user_id, "dev_a", "l1", "x", IP) + .await + .expect("c1"); + let other = st + .mfa_create_challenge(a.user_id, "dev_a", "l2", "x", IP) + .await + .expect("c2"); + st.mfa_revoke_device(a.user_id, "dev_a", "anna") + .await + .expect("revoke"); + let d = device(&st, "dev_a").await; + assert_eq!(d.status, "revoked"); + assert!(d.revoked_at.is_some()); + for c in [&open, &other] { + assert_eq!( + st.mfa_challenge_status(&c.challenge_id).await.expect("s"), + crate::ChallengeStatus::Expired + ); + } + let events = st + .audit_events(&crate::Actor::Cli, 0) + .await + .expect("events"); + let revoked: Vec<_> = events + .iter() + .filter(|e| e.action == "mfa.device_revoked") + .collect(); + assert_eq!(revoked.len(), 1); + assert_eq!( + (revoked[0].actor.as_str(), revoked[0].target.as_str()), + ("anna", "dev_a") + ); + let expired = events + .iter() + .filter(|e| e.action == "mfa.challenge_expired") + .count(); + assert_eq!(expired, 2); + // a second revoke finds no active device + let r = st.mfa_revoke_device(a.user_id, "dev_a", "anna").await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); +} + +#[tokio::test] +async fn a_challenge_answered_before_the_revoke_keeps_its_status() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let c = st + .mfa_create_challenge(a.user_id, "dev_a", "l1", "x", IP) + .await + .expect("c"); + assert!( + st.inner + .db + .mfa_challenge_resolve(&c.challenge_id, "approved", now()) + .await + .expect("r") + ); + st.mfa_revoke_device(a.user_id, "dev_a", "anna") + .await + .expect("revoke"); + assert_eq!( + st.mfa_challenge_status(&c.challenge_id).await.expect("s"), + crate::ChallengeStatus::Approved + ); +} + +#[tokio::test] +async fn revoke_other_users_device_gives_not_found() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let b = enrolled(&st, "ben", "dev_b").await; + let r = st.mfa_revoke_device(a.user_id, "dev_b", "anna").await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); + let r = st.mfa_revoke_device(a.user_id, "dev_nope", "anna").await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); + assert_eq!(device(&st, "dev_b").await.status, "active"); + let _ = b; +} + +#[tokio::test] +async fn revoked_device_fails_authentication() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + st.mfa_revoke_device(a.user_id, "dev_a", "anna") + .await + .expect("revoke"); + let ts = now(); + let msg = rustmfa_contract::request_signing_string("GET", "/x", ts, "n1", b"").expect("m"); + let sig = a + .request + .sign(&SystemRandom::new(), msg.as_bytes()) + .expect("sig"); + let h = format!( + r#"RustMFA-Sig device_id="dev_a",ts="{ts}",nonce="n1",sig="{}""#, + URL_SAFE_NO_PAD.encode(sig.as_ref()) + ); + let r = st.mfa_authenticate_device(&h, "GET", "/x", b"").await; + assert!(matches!(r, Err(MfaError::DeviceRevoked)), "{r:?}"); +} + +#[tokio::test] +async fn device_may_revoke_only_devices_of_its_own_user() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let b = enrolled(&st, "ben", "dev_b").await; + // a second phone of anna + let a2 = Phone { + id: "dev_a2".into(), + request: fixed_key(), + approval: fixed_key(), + user_id: a.user_id, + }; + st.inner + .db + .mfa_device_insert(&rostdav_meta::NewDevice { + id: "dev_a2", + user_id: a.user_id, + name: "Tablet", + request_pub: &spki_b64(&a2.request), + approval_pub: &spki_b64(&a2.approval), + push_channel: "fcm", + push_token: "t", + hardware_backed: true, + strongbox: false, + }) + .await + .expect("insert"); + let r = st.mfa_revoke_device_as(&dp(&a), "dev_b").await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); + assert_eq!(device(&st, "dev_b").await.status, "active"); + // anna's phone may revoke her other phone, and itself + st.mfa_revoke_device_as(&dp(&a), "dev_a2") + .await + .expect("other own"); + assert_eq!(device(&st, "dev_a2").await.status, "revoked"); + st.mfa_revoke_device_as(&dp(&a), "dev_a") + .await + .expect("self"); + // the audit actor is the owner's name + let events = st + .audit_events(&crate::Actor::Cli, 0) + .await + .expect("events"); + assert!( + events + .iter() + .filter(|e| e.action == "mfa.device_revoked") + .all(|e| e.actor == "anna") + ); + let _ = b; +} + +#[tokio::test] +async fn update_push_token_changes_the_channel_and_audits_without_the_token() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + st.mfa_update_push_token( + &dp(&a), + "dev_a", + "unifiedpush", + "https://up.example/secret-endpoint", + ) + .await + .expect("update"); + let d = device(&st, "dev_a").await; + assert_eq!( + (d.push_channel.as_str(), d.push_token.as_str()), + ("unifiedpush", "https://up.example/secret-endpoint") + ); + let events = st + .audit_events(&crate::Actor::Cli, 0) + .await + .expect("events"); + let e = events + .iter() + .find(|e| e.action == "mfa.push_token_updated") + .expect("audited"); + assert_eq!((e.actor.as_str(), e.target.as_str()), ("anna", "dev_a")); + let all: String = events + .iter() + .map(|e| format!("{} {} {}", e.target, e.details, e.actor)) + .collect(); + assert!(!all.contains("secret-endpoint")); +} + +#[tokio::test] +async fn update_push_token_validates_channel_and_token() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let long = "x".repeat(4097); + for (ch, tok) in [ + ("sms", "t"), + ("fcm", ""), + ("fcm", long.as_str()), + ("fcm", "a\nb"), + ] { + let r = st.mfa_update_push_token(&dp(&a), "dev_a", ch, tok).await; + assert!( + matches!(r, Err(MfaError::InvalidRequest(_))), + "{ch} {tok:?}: {r:?}" + ); + } + assert_eq!(device(&st, "dev_a").await.push_token, "t"); +} + +#[tokio::test] +async fn update_push_token_for_other_device_is_forbidden() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let _b = enrolled(&st, "ben", "dev_b").await; + // even a device of the same user may only change its own token + let r = st + .mfa_update_push_token(&dp(&a), "dev_b", "fcm", "evil") + .await; + assert!(matches!(r, Err(MfaError::DeviceNotAuthorized)), "{r:?}"); + assert_eq!(device(&st, "dev_b").await.push_token, "t"); +} + +#[tokio::test] +async fn update_push_token_of_a_revoked_device_is_refused() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + st.mfa_revoke_device(a.user_id, "dev_a", "anna") + .await + .expect("revoke"); + let r = st + .mfa_update_push_token(&dp(&a), "dev_a", "fcm", "new") + .await; + assert!(r.is_err()); + assert_eq!(device(&st, "dev_a").await.push_token, "t"); +} + +struct Rotation { + request: ring::signature::EcdsaKeyPair, + request_pub: String, + chain: Vec, + approval_pub: String, + root: Vec, +} + +/// New keys with a chain that attests the new approval key for `"rotate::"`. +fn rotation(st: &AppState, id: &str, strongbox: bool) -> Rotation { + let request = fixed_key(); + let request_pub = spki_b64(&request); + let f = fixture(Opts { + challenge: sha256(format!("rotate:{id}:{request_pub}").as_bytes()), + attestation_level: if strongbox { 2 } else { 1 }, + keymaster_level: if strongbox { 2 } else { 1 }, + ..live_opts() + }); + st.set_attestation_roots_for_tests(vec![f.root.clone()]); + Rotation { + request, + request_pub, + chain: f.chain.clone(), + approval_pub: f.approval_spki.clone(), + root: f.root.clone(), + } +} + +#[tokio::test] +async fn rotate_keys_replaces_both_keys_and_old_signatures_stop_working() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let r = rotation(&st, "dev_a", true); + st.mfa_rotate_keys(&dp(&a), "dev_a", &r.request_pub, &r.approval_pub, &r.chain) + .await + .expect("rotate"); + let d = device(&st, "dev_a").await; + assert_eq!( + (d.request_pub.as_str(), d.approval_pub.as_str()), + (r.request_pub.as_str(), r.approval_pub.as_str()) + ); + assert_eq!((d.hardware_backed, d.strongbox), (true, true)); + let ts = now(); + let header = |key: &ring::signature::EcdsaKeyPair, nonce: &str| { + let msg = rustmfa_contract::request_signing_string("GET", "/x", ts, nonce, b"").expect("m"); + let sig = key.sign(&SystemRandom::new(), msg.as_bytes()).expect("sig"); + format!( + r#"RustMFA-Sig device_id="dev_a",ts="{ts}",nonce="{nonce}",sig="{}""#, + URL_SAFE_NO_PAD.encode(sig.as_ref()) + ) + }; + let old = st + .mfa_authenticate_device(&header(&a.request, "o1"), "GET", "/x", b"") + .await; + assert!(matches!(old, Err(MfaError::Unauthenticated)), "{old:?}"); + st.mfa_authenticate_device(&header(&r.request, "n1"), "GET", "/x", b"") + .await + .expect("new request key works"); + // the old approval key no longer answers challenges + let c = st + .mfa_create_challenge(a.user_id, "dev_a", "l", "x", IP) + .await + .expect("c"); + let det = st + .mfa_get_challenge(&dp(&a), &c.challenge_id) + .await + .expect("d"); + let msg = rustmfa_contract::canonical_message(&rustmfa_contract::ChallengeMessage { + challenge_id: &det.challenge_id, + nonce: &det.nonce, + login_id: &det.login_id, + device_id: "dev_a", + expires_at: &det.expires_at, + audience: &det.audience, + }) + .expect("m"); + let sig = a + .approval + .sign(&SystemRandom::new(), msg.as_bytes()) + .expect("sig"); + let out = st + .mfa_approve( + &dp(&a), + &c.challenge_id, + c.number, + &URL_SAFE_NO_PAD.encode(sig.as_ref()), + ) + .await; + assert!(matches!(out, Err(MfaError::DeviceNotAuthorized)), "{out:?}"); + let events = st + .audit_events(&crate::Actor::Cli, 0) + .await + .expect("events"); + assert!( + events + .iter() + .any(|e| e.action == "mfa.key_rotated" && e.target == "dev_a" && e.actor == "anna") + ); +} + +#[tokio::test] +async fn rotate_requires_valid_attestation_when_policy_required() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let good = rotation(&st, "dev_a", false); + // an attestation made for another device id + let other = rotation(&st, "dev_zzz", false); + // both chains share one test root only if it is trusted: trust both + st.set_attestation_roots_for_tests(vec![good.root.clone(), other.root.clone()]); + let wrong = st + .mfa_rotate_keys( + &dp(&a), + "dev_a", + &good.request_pub, + &other.approval_pub, + &other.chain, + ) + .await; + assert!( + matches!(wrong, Err(MfaError::InvalidRequest(_))), + "{wrong:?}" + ); + // an attestation that belongs to a different request key + let swapped = st + .mfa_rotate_keys( + &dp(&a), + "dev_a", + &spki_b64(&fixed_key()), + &good.approval_pub, + &good.chain, + ) + .await; + assert!( + matches!(swapped, Err(MfaError::InvalidRequest(_))), + "{swapped:?}" + ); + // no chain + let none = st + .mfa_rotate_keys(&dp(&a), "dev_a", &good.request_pub, &good.approval_pub, &[]) + .await; + assert!(matches!(none, Err(MfaError::InvalidRequest(_))), "{none:?}"); + // a chain that no trusted root signed + st.set_attestation_roots_for_tests(vec![other.root.clone()]); + let untrusted = st + .mfa_rotate_keys( + &dp(&a), + "dev_a", + &good.request_pub, + &good.approval_pub, + &good.chain, + ) + .await; + assert!( + matches!(untrusted, Err(MfaError::InvalidRequest(_))), + "{untrusted:?}" + ); + let d = device(&st, "dev_a").await; + assert_ne!(d.request_pub, good.request_pub); + assert_ne!(d.approval_pub, good.approval_pub); + // and the right one works + st.set_attestation_roots_for_tests(vec![good.root.clone()]); + st.mfa_rotate_keys( + &dp(&a), + "dev_a", + &good.request_pub, + &good.approval_pub, + &good.chain, + ) + .await + .expect("rotate"); +} + +#[tokio::test] +async fn rotate_without_attestation_is_allowed_when_the_policy_is_off() { + let (_d, st) = state().await; + let st = st.with_mfa(MfaConfig { + attestation: AttestationPolicy::Off, + ..MfaConfig::default() + }); + let a = enrolled(&st, "anna", "dev_a").await; + let (req, app) = (spki_b64(&fixed_key()), spki_b64(&fixed_key())); + st.mfa_rotate_keys(&dp(&a), "dev_a", &req, &app, &[]) + .await + .expect("rotate"); + let d = device(&st, "dev_a").await; + assert_eq!((d.request_pub, d.approval_pub), (req, app)); + assert_eq!((d.hardware_backed, d.strongbox), (false, false)); +} + +#[tokio::test] +async fn rotate_validates_keys_and_the_device() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let _b = enrolled(&st, "ben", "dev_b").await; + let r = rotation(&st, "dev_a", false); + let k = spki_b64(&fixed_key()); + for (req, app) in [ + ("not a key", r.approval_pub.as_str()), + (r.request_pub.as_str(), "not a key"), + (r.request_pub.as_str(), r.request_pub.as_str()), + ] { + let out = st + .mfa_rotate_keys(&dp(&a), "dev_a", req, app, &r.chain) + .await; + assert!( + matches!(out, Err(MfaError::InvalidRequest(_))), + "{req} {app}: {out:?}" + ); + } + // another device is not the caller's to rotate + let out = st + .mfa_rotate_keys(&dp(&a), "dev_b", &r.request_pub, &r.approval_pub, &r.chain) + .await; + assert!(matches!(out, Err(MfaError::DeviceNotAuthorized)), "{out:?}"); + assert_ne!(device(&st, "dev_b").await.request_pub, r.request_pub); + let _ = k; +} + +#[tokio::test] +async fn audit_view_contains_only_own_mfa_events_and_no_secrets() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let b = enrolled(&st, "ben", "dev_b").await; + // registration events come from enrollment; simulate its audit rows the same way + st.audit( + "anna", + "mfa.device_registered", + "dev_a", + None, + serde_json::json!({}), + ) + .await; + st.audit( + "ben", + "mfa.device_registered", + "dev_b", + None, + serde_json::json!({}), + ) + .await; + st.audit("anna", "auth.login", "x", None, serde_json::json!({})) + .await; + st.audit( + "anna", + "mfa.enroll_token_created", + "", + None, + serde_json::json!({}), + ) + .await; + st.audit( + "anna", + "mfa.push_token_updated", + "dev_a", + None, + serde_json::json!({}), + ) + .await; + let c = st + .mfa_create_challenge(a.user_id, "dev_a", "l1", "x", IP) + .await + .expect("c"); + st.mfa_create_challenge(b.user_id, "dev_b", "l2", "x", IP) + .await + .expect("c"); + st.mfa_revoke_device(a.user_id, "dev_a", "anna") + .await + .expect("revoke"); + let view = st.mfa_audit_events(a.user_id).await.expect("view"); + let names: Vec<&str> = view.iter().map(|e| e.event.as_str()).collect(); + // newest first; challenge expiry from the revoke, then the revoke itself + assert_eq!( + names, + [ + "device_revoked", + "challenge_expired", + "challenge_created", + "device_registered" + ] + ); + let created = view + .iter() + .find(|e| e.event == "challenge_created") + .expect("created"); + assert_eq!(created.device_id.as_deref(), Some("dev_a")); + let revoked = view + .iter() + .find(|e| e.event == "device_revoked") + .expect("revoked"); + assert_eq!(revoked.device_id.as_deref(), Some("dev_a")); + assert!(view.iter().all(|e| e.at.len() == 20 && e.at.ends_with('Z'))); + let all = format!("{view:?}"); + for secret in [c.challenge_id.as_str(), "fcm", "l1"] { + assert!(!all.contains(secret), "view contains {secret}"); + } + // ben sees his own and nothing of anna's + let bens = st.mfa_audit_events(b.user_id).await.expect("view"); + assert!(bens.iter().all(|e| e.device_id.as_deref() == Some("dev_b"))); + assert_eq!(bens.len(), 2); + assert!(st.mfa_audit_events(9999).await.expect("none").is_empty()); +} + +#[tokio::test] +async fn audit_view_is_capped_to_the_newest_events() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + for i in 0..250 { + st.inner + .db + .record_event_at( + 1_000 + i, + "anna", + "mfa.challenge_created", + "ch_x", + "", + r#"{"device_id":"dev_a"}"#, + ) + .await + .expect("event"); + } + let view = st.mfa_audit_events(a.user_id).await.expect("view"); + assert_eq!(view.len(), 200); +} + +#[tokio::test] +async fn the_owner_can_revoke_whoever_the_owner_is() { + let (_d, st) = state().await; + let _a = enrolled(&st, "anna", "dev_a").await; + let b = enrolled(&st, "ben", "dev_b").await; + assert_ne!( + b.user_id, 1, + "the test needs an owner who is not the first user" + ); + st.mfa_revoke_device(b.user_id, "dev_b", "ben") + .await + .expect("revoke"); + assert_eq!(device(&st, "dev_b").await.status, "revoked"); + assert_eq!(device(&st, "dev_a").await.status, "active"); +} + +#[tokio::test] +async fn rotate_checks_the_keys_even_when_attestation_is_off() { + let (_d, st) = state().await; + let st = st.with_mfa(MfaConfig { + attestation: AttestationPolicy::Off, + ..MfaConfig::default() + }); + let a = enrolled(&st, "anna", "dev_a").await; + let before = device(&st, "dev_a").await; + let k = spki_b64(&fixed_key()); + for (req, app) in [ + ("not a key", k.as_str()), + (k.as_str(), "not a key"), + (k.as_str(), k.as_str()), + ] { + let out = st.mfa_rotate_keys(&dp(&a), "dev_a", req, app, &[]).await; + assert!( + matches!(out, Err(MfaError::InvalidRequest(_))), + "{req} {app}: {out:?}" + ); + } + let after = device(&st, "dev_a").await; + assert_eq!( + (after.request_pub, after.approval_pub), + (before.request_pub, before.approval_pub) + ); +} diff --git a/crates/rostdav-app/src/push_mfa/enroll.rs b/crates/rostdav-app/src/push_mfa/enroll.rs index d3e8318..90652e1 100644 --- a/crates/rostdav-app/src/push_mfa/enroll.rs +++ b/crates/rostdav-app/src/push_mfa/enroll.rs @@ -50,12 +50,23 @@ fn invalid(reason: &'static str) -> MfaError { MfaError::InvalidRequest(reason) } -fn sha256(data: &[u8]) -> Vec { +pub(super) fn sha256(data: &[u8]) -> Vec { ring::digest::digest(&ring::digest::SHA256, data) .as_ref() .to_vec() } +/// The push channel and token a phone gives, at registration or later. +pub(super) fn check_push(channel: &str, token: &str) -> Result<(), MfaError> { + if !matches!(channel, "fcm" | "unifiedpush") { + return Err(invalid("push channel must be fcm or unifiedpush")); + } + if token.is_empty() || token.len() > MAX_PUSH_TOKEN || token.chars().any(char::is_control) { + return Err(invalid("push token is missing or too long")); + } + Ok(()) +} + /// Checks the input without touching the token; returns the cleaned device name. fn validate(i: &RegisterInput) -> Result { if i.enroll_token.is_empty() || i.enroll_token.len() > MAX_TOKEN_INPUT { @@ -73,15 +84,7 @@ fn validate(i: &RegisterInput) -> Result { if i.request_public_key == i.approval_public_key { return Err(invalid("request key and approval key must differ")); } - if !matches!(i.push_channel.as_str(), "fcm" | "unifiedpush") { - return Err(invalid("push channel must be fcm or unifiedpush")); - } - if i.push_token.is_empty() - || i.push_token.len() > MAX_PUSH_TOKEN - || i.push_token.chars().any(char::is_control) - { - return Err(invalid("push token is missing or too long")); - } + check_push(&i.push_channel, &i.push_token)?; let name = sanitize_text(&i.device_name).trim().to_owned(); if name.is_empty() { return Err(invalid("device name is missing")); @@ -205,7 +208,7 @@ impl AppState { } /// Google's roots, unless a test set its own. - fn attestation_roots(&self) -> Vec> { + pub(super) fn attestation_roots(&self) -> Vec> { self.inner .mfa_roots .lock() diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs index f6990ca..dfc97f9 100644 --- a/crates/rostdav-app/src/push_mfa/mod.rs +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -7,6 +7,9 @@ mod challenges_tests; mod devauth; #[cfg(test)] mod devauth_tests; +mod devices; +#[cfg(test)] +mod devices_tests; mod enroll; #[cfg(test)] mod enroll_tests; @@ -19,6 +22,7 @@ pub(crate) mod testkit; pub use attestation::{AttestationResult, verify_attestation}; pub use challenges::{ChallengeDetails, ChallengeStatus, NewChallenge}; pub use devauth::{DevicePrincipal, ParsedAuth, parse_authorization}; +pub use devices::{DeviceInfo, MfaAuditEvent}; pub use enroll::{EnrollToken, RegisterInput, Registered}; pub use error::MfaError; pub use google_roots::google_roots; diff --git a/crates/rostdav-app/src/push_mfa/testkit.rs b/crates/rostdav-app/src/push_mfa/testkit.rs index 8f8c14c..0b02476 100644 --- a/crates/rostdav-app/src/push_mfa/testkit.rs +++ b/crates/rostdav-app/src/push_mfa/testkit.rs @@ -318,3 +318,13 @@ pub(crate) fn captured() -> &'static Capture { cap }) } + +/// Attestation options whose certificates are valid now: the service reads the real clock. +pub(crate) fn live_opts() -> Opts { + let now = crate::now_secs(); + Opts { + base: now, + leaf_validity: (now - 1000, now + 1000), + ..Opts::default() + } +} diff --git a/crates/rostdav-meta/migrations/0013_push_mfa.sql b/crates/rostdav-meta/migrations/0013_push_mfa.sql index c634bb3..b4f9158 100644 --- a/crates/rostdav-meta/migrations/0013_push_mfa.sql +++ b/crates/rostdav-meta/migrations/0013_push_mfa.sql @@ -61,3 +61,6 @@ CREATE TABLE mfa_nonces ( ) STRICT; CREATE INDEX mfa_nonces_device ON mfa_nonces(device_id); CREATE INDEX mfa_nonces_expiry ON mfa_nonces(expires_at); + +-- The own-events view of the app reads the events of one actor by action prefix. +CREATE INDEX events_actor ON events(actor, action); diff --git a/crates/rostdav-meta/src/push_mfa.rs b/crates/rostdav-meta/src/push_mfa.rs index 3959ff0..2ae8a61 100644 --- a/crates/rostdav-meta/src/push_mfa.rs +++ b/crates/rostdav-meta/src/push_mfa.rs @@ -271,13 +271,18 @@ impl Db { id: &str, request_pub: &str, approval_pub: &str, + hardware_backed: bool, + strongbox: bool, ) -> Result { let r = sql::query( - "UPDATE mfa_devices SET request_pub = ?, approval_pub = ? + "UPDATE mfa_devices SET request_pub = ?, approval_pub = ?, hardware_backed = ?, + strongbox = ? WHERE id = ? AND status = 'active'", ) .bind(request_pub) .bind(approval_pub) + .bind(hardware_backed) + .bind(strongbox) .bind(id) .execute(self.pool()) .await @@ -377,12 +382,14 @@ impl Db { Ok(rows.into_iter().map(ChallengeRow::from).collect()) } - /// Moves a pending, unexpired challenge to `to` (`approved`, `denied` or `expired`). - /// One conditional statement: true for exactly one caller. + /// Moves a pending, unexpired challenge of an active device to `to` (`approved`, `denied` or + /// `expired`). One conditional statement: true for exactly one caller, and never after the + /// device was revoked. pub async fn mfa_challenge_resolve(&self, id: &str, to: &str, now: i64) -> Result { let r = sql::query( "UPDATE mfa_challenges SET status = ?, consumed_at = ? - WHERE id = ? AND status = 'pending' AND expires_at > ?", + WHERE id = ? AND status = 'pending' AND expires_at > ? + AND device_id IN (SELECT id FROM mfa_devices WHERE status = 'active')", ) .bind(to) .bind(now) @@ -394,6 +401,63 @@ impl Db { Ok(r.rows_affected() > 0) } + /// Ends a pending challenge as `expired` whatever its time (its phone was revoked). True for + /// the one caller that changed it. + pub async fn mfa_challenge_cancel(&self, id: &str, now: i64) -> Result { + let r = sql::query( + "UPDATE mfa_challenges SET status = 'expired', consumed_at = ? + WHERE id = ? AND status = 'pending'", + ) + .bind(now) + .bind(id) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(r.rows_affected() > 0) + } + + /// The newest `limit` events of `actor` whose action starts with `prefix`, newest first. + /// `prefix` must end in a character that has a successor ASCII character (like the dot). + pub async fn events_of_actor_with_prefix( + &self, + actor: &str, + prefix: &str, + limit: i64, + ) -> Result> { + let mut upper = prefix.to_owned(); + let last = upper + .pop() + .ok_or(rostdav_core::Error::BadRequest("empty prefix"))?; + upper.push(char::from( + u8::try_from(last).map_or(0x7f, |b| b.saturating_add(1)), + )); + let rows: Vec<(i64, i64, String, String, String, String, String)> = sql::query_as( + "SELECT id, ts, actor, action, target, ip, details FROM events + WHERE actor = ? AND action >= ? AND action < ? ORDER BY id DESC LIMIT ?", + ) + .bind(actor) + .bind(prefix) + .bind(upper) + .bind(limit) + .fetch_all(self.pool()) + .await + .map_err(err)?; + Ok(rows + .into_iter() + .map( + |(id, ts, actor, action, target, ip, details)| crate::Event { + id, + ts, + actor, + action, + target, + ip, + details, + }, + ) + .collect()) + } + /// Marks a pending challenge whose time has run out as `expired`. True for the one caller /// that changed it, so the expiry is recorded once. pub async fn mfa_challenge_expire(&self, id: &str, now: i64) -> Result { @@ -463,6 +527,93 @@ mod tests { } } + #[tokio::test] + async fn cancel_ends_a_pending_challenge_once_whatever_its_time() { + let (db, uid) = db_with_user().await; + db.mfa_device_insert(&device("d1", uid)).await.expect("dev"); + for id in ["c1", "c2"] { + db.mfa_challenge_insert(&challenge(id, "d1", uid, 2_000_000_000)) + .await + .expect("insert"); + } + assert!( + db.mfa_challenge_resolve("c2", "approved", 5) + .await + .expect("r") + ); + assert!(db.mfa_challenge_cancel("c1", 10).await.expect("cancel")); + assert!(!db.mfa_challenge_cancel("c1", 11).await.expect("again")); + assert!(!db.mfa_challenge_cancel("c2", 11).await.expect("answered")); + let st = |id: &'static str| { + let db = db.clone(); + async move { + db.mfa_challenge_get(id) + .await + .expect("g") + .expect("row") + .status + } + }; + assert_eq!( + (st("c1").await.as_str(), st("c2").await.as_str()), + ("expired", "approved") + ); + } + + #[tokio::test] + async fn events_of_an_actor_are_limited_to_the_prefix_newest_first() { + let (db, _uid) = db_with_user().await; + for (ts, actor, action) in [ + (1, "anna", "mfa.device_registered"), + (2, "anna", "auth.login"), + (3, "ben", "mfa.device_registered"), + (4, "anna", "mfa.challenge_created"), + (5, "anna", "mfb.other"), + (6, "anna", "mfa"), + (7, "anna", "mfa.challenge_approved"), + ] { + db.record_event_at(ts, actor, action, "t", "", "{}") + .await + .expect("event"); + } + let got: Vec<(i64, String)> = db + .events_of_actor_with_prefix("anna", "mfa.", 2) + .await + .expect("events") + .into_iter() + .map(|e| (e.ts, e.action)) + .collect(); + assert_eq!( + got, + [ + (7, "mfa.challenge_approved".to_owned()), + (4, "mfa.challenge_created".to_owned()) + ] + ); + let all = db + .events_of_actor_with_prefix("anna", "mfa.", 10) + .await + .expect("e"); + assert_eq!(all.len(), 3); + } + + #[tokio::test] + async fn a_revoked_device_cannot_resolve_its_challenge() { + let (db, uid) = db_with_user().await; + db.mfa_device_insert(&device("d1", uid)).await.expect("dev"); + db.mfa_challenge_insert(&challenge("c1", "d1", uid, 2_000_000_000)) + .await + .expect("insert"); + assert!(db.mfa_device_revoke("d1", uid, 5).await.expect("revoke")); + assert!( + !db.mfa_challenge_resolve("c1", "approved", 6) + .await + .expect("resolve") + ); + let row = db.mfa_challenge_get("c1").await.expect("g").expect("row"); + assert_eq!(row.status, "pending"); + } + #[tokio::test] async fn challenge_keeps_the_login_device() { let (db, uid) = db_with_user().await; @@ -555,7 +706,7 @@ mod tests { .expect("push") ); assert!( - db.mfa_device_set_keys("dev_a", "r2", "a2") + db.mfa_device_set_keys("dev_a", "r2", "a2", false, true) .await .expect("keys") ); @@ -573,6 +724,7 @@ mod tests { ), ("r2", "a2", Some(77)) ); + assert_eq!((d.hardware_backed, d.strongbox), (false, true)); assert!( !db.mfa_device_set_push("missing", "fcm", "t") .await @@ -789,6 +941,10 @@ mod tests { "SELECT id FROM mfa_devices WHERE user_id = 1", "mfa_devices_user", ), + ( + "SELECT id FROM events WHERE actor = 'a' AND action >= 'mfa.' AND action < 'mfa/' ORDER BY id DESC LIMIT 5", + "events_actor", + ), ]; for (q, index) in cases { let rows: Vec<(i64, i64, i64, String)> = diff --git a/docs/admin.md b/docs/admin.md index 6498974..19279f1 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -148,6 +148,8 @@ A phone whose signed request fails five times in 15 minutes (wrong signature, un A user can have five sign-in approvals started in 15 minutes (this keeps a stolen password from flooding a phone with prompts); further attempts are refused until the window ends. The approval screen shows the sign-in device name but no location: Rostdav has no IP geolocation. +When a user revokes a phone, its open approvals end at once. Revoking the last phone of an account removes that account's push approval factor; Rostdav does not stop this, because a lost phone must be revocable. The audit log records `mfa.device_registered`, `mfa.device_revoked`, `mfa.key_rotated`, `mfa.push_token_updated` and the challenge events, with the user's name as the actor and never a key, token or signature. + ## Version check Off by default. Administration, Server, "Updates" turns it on: the server then asks the GitHub releases API once a day (and on "Check now") which version is the newest, and shows a newer one with its release notes. The request carries only `User-Agent: rostdav/`. While it is off, the server makes no outbound connection. After an error the server tries again in an hour; the page shows the reason. The switch is kept in the database, so a restart keeps it. Events: `admin.update_check_on`, `admin.update_check_off`, `admin.update_installed`. The About page (every user) shows the release notes of the running build and the versions before it; they are compiled in and need no network. diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index c6e3baa..3cbde92 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -25,6 +25,7 @@ This ADR covers the server side: devices, enrollment, challenges and the HTTP AP - **Request authentication.** Header `Authorization: RustMFA-Sig device_id="…",ts="…",nonce="…",sig="…"`. Values are limited to `[A-Za-z0-9_-]` and 128 bytes. `sig` is a raw 64-byte R‖S signature, base64url, over `METHOD \n path-with-query \n ts \n nonce \n base64url(sha256(body))`. The server rejects a timestamp more than 60 seconds off and any nonce seen within 5 minutes for that device. A nonce is recorded only after the signature verified. Five failures per device in 15 minutes lock that device's requests for 15 minutes (existing limiter style). A success resets the count. A revoked phone gets `DEVICE_REVOKED` only after its signature verified, so someone without the key cannot learn that a device id was revoked; a disabled account is `UNAUTHENTICATED`. - **Challenge.** Created in process by the sign-in code for one explicit device (the most recently seen active one). 90 seconds by default (`[mfa] challenge_ttl_seconds`, 10 to 300). Contains a 128-bit nonce, a two-digit number (10 to 99) and two decoys. The device receives the three candidates but never a marker of the right one. - **Answer.** The approve and deny requests carry a raw R‖S signature by the `approval_key` over the canonical message `v1 \n challenge_id \n nonce \n login_id \n device_id \n expires_at \n audience` (exact format and test vectors in the contract). The server checks: device active and owner of the challenge; challenge pending and not expired; number matches (a mismatch resolves the challenge to `denied`); signature valid. Only then one conditional `UPDATE … WHERE status = 'pending' AND expires_at > now` consumes the challenge, so two simultaneous answers cannot both win. A bad signature never consumes a challenge. **Amendment 2026-11-09:** the signature is verified before the number is compared (the spec listed the number first). A wrong number ends the challenge, and that must need the biometric-gated key; otherwise the non-biometric request key alone could kill pending sign-ins. An invalid signature is `DEVICE_NOT_AUTHORIZED`, a malformed or DER one `INVALID_REQUEST`. Challenges also store `login_device` (the sign-in client's name, cleaned) for the approval screen; the approximate location is always empty because there is no geolocation. Challenge creation is limited to five per user in 15 minutes. + **Amendment 2026-11-09 (device management):** a challenge can be resolved only while its phone is active, enforced inside the one conditional `UPDATE`, so a revoke that lands between the checks and the update wins. Revoking blocks the phone first, then ends its open challenges as `expired`. **Key rotation** is unattested in the spec; here the new approval key must be attested under the default policy, with the attestation challenge `SHA-256("rotate:" + device_id + ":" + request_pub)`, which ties the chain to this phone and to the new request key, and the hardware flags are updated from it. With attestation off the flags become false. A phone may change only its own push token and keys, and it may revoke any phone of its own account. The own-events view reads `mfa.*` events by actor (index `events_actor`), shows only the eight kinds of the contract, newest 200. - **No existence leaks.** Another user's challenge or device is `404`; another device of the same user is `403`. - **Errors.** One format `{"error":{"code","message"}}`, codes as in the contract. No secrets, signatures, push tokens or keys in logs or errors. - **Audit.** `mfa.device_registered`, `mfa.device_revoked`, `mfa.key_rotated`, `mfa.challenge_created`, `mfa.challenge_approved`, `mfa.challenge_denied`, `mfa.number_mismatch`, `mfa.challenge_expired`. diff --git a/docs/architecture.md b/docs/architecture.md index 4b92181..6ae6f68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,6 +107,8 @@ A central error type in `rostdav-core` (`NotFound`, `Forbidden`, `Conflict`, `Pr | `mfa_enroll_tokens` | token_hash, user_id, expires_at, used_at | | `mfa_challenges` | id, user_id, device_id, login_ref, nonce, number, candidates, audience, created_at, expires_at, status, consumed_at, login_device | | `mfa_nonces` | device_id, nonce, expires_at | + +Migration 0013 also adds `events_actor` on `events(actor, action)` for the own-events view of the RustMFA app. | `nodes` | see ADR 0002 | | `blobs` | id, size, checksum, created_at | | `versions` | id, node_id, blob_id, size, mtime, author_app_password_id, created_at | From c97b2c0b7b4c4127bf66084ec75a54e25f596b0d Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Mon, 9 Nov 2026 23:04:02 +0100 Subject: [PATCH 10/24] feat(mfa): device HTTP API Add the rostdav-mfa-api crate with the ten device routes of the RustMFA contract under /api/v1: registration, device list, revoke, push token, key rotation, challenge list and details, approve, deny, and the user's own audit events. Requests are verified by the signed Authorization header in rostdav-app; this crate reads at most 64 KiB, refuses unknown JSON fields, maps errors to the contract's bodies and statuses, and never touches the database. The router is mounted without the cookie and CSRF layers. rostdav-app gains a test-util feature that exposes its test kit to the integration tests; production builds do not enable it. --- CHANGELOG.md | 1 + Cargo.lock | 19 + Cargo.toml | 2 + crates/rostdav-app/Cargo.toml | 6 + crates/rostdav-app/src/lib.rs | 2 + crates/rostdav-app/src/push_mfa/challenges.rs | 7 +- crates/rostdav-app/src/push_mfa/enroll.rs | 4 +- crates/rostdav-app/src/push_mfa/mod.rs | 4 +- crates/rostdav-app/src/push_mfa/testkit.rs | 92 +- crates/rostdav-mfa-api/Cargo.toml | 28 + crates/rostdav-mfa-api/src/error.rs | 84 ++ crates/rostdav-mfa-api/src/lib.rs | 354 +++++ crates/rostdav-mfa-api/tests/flow.rs | 1150 +++++++++++++++++ crates/rostdav/Cargo.toml | 1 + crates/rostdav/src/http.rs | 2 + crates/rostdav/tests/mfa_api.rs | 69 + docs/adr/0014-push-mfa.md | 1 + docs/api.md | 43 + docs/architecture.md | 5 +- 19 files changed, 1818 insertions(+), 56 deletions(-) create mode 100644 crates/rostdav-mfa-api/Cargo.toml create mode 100644 crates/rostdav-mfa-api/src/error.rs create mode 100644 crates/rostdav-mfa-api/src/lib.rs create mode 100644 crates/rostdav-mfa-api/tests/flow.rs create mode 100644 crates/rostdav/tests/mfa_api.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 98731c3..bb62bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https - **Request authentication for the RustMFA app**: checks the signed `Authorization: RustMFA-Sig` header of a phone (timestamp within 60 seconds, signature by the request key, nonce used once in 5 minutes, phone and account still active). Five failures for one phone in 15 minutes block it for 15 minutes. Not reachable over HTTP yet. - **Sign-in challenges for the RustMFA app**: the server can start an approval for one phone (a two-digit number, two decoys, 90 seconds by default), list pending ones, and accept an approve or deny signed with the phone's biometric key. A wrong number denies the challenge. Only one answer can win. Five challenges per user in 15 minutes. Push delivery is a stub until the sign-in page work; not reachable over HTTP yet. - **Phone management for the RustMFA app**: list, revoke (this also ends the phone's open sign-in approvals), change the push token, rotate both keys (the new approval key needs a fresh attestation), and a view of the user's own security events. A revoked phone can no longer approve, even an answer that was already in flight. Not reachable over HTTP yet. +- **HTTP API for the RustMFA app** (`/api/v1/devices/…`, `/api/v1/mfa/challenges/…`, `/api/v1/audit`): phones register and sign their requests; they list and revoke phones, change their push token, rotate keys, fetch and answer sign-in approvals, and read their own security events. Requests are signed (no cookie, no CSRF token), bodies are limited to 64 KiB, and errors follow the contract. Creating an enrollment token and the sign-in page integration follow separately. ## [0.10.0] - 2026-10-25 diff --git a/Cargo.lock b/Cargo.lock index f1057e3..04d221b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3120,6 +3120,7 @@ dependencies = [ "rostdav-core", "rostdav-dav", "rostdav-meta", + "rostdav-mfa-api", "rostdav-storage", "rostdav-web", "serde", @@ -3221,6 +3222,24 @@ dependencies = [ "ulid", ] +[[package]] +name = "rostdav-mfa-api" +version = "0.10.0" +dependencies = [ + "axum", + "base64 0.23.1", + "ring", + "rostdav-app", + "rostdav-core", + "rustmfa-contract", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tower", + "tracing", +] + [[package]] name = "rostdav-storage" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 4098f19..14d82ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/rostdav-dav", "crates/rostdav-compat", "crates/rostdav-web", + "crates/rostdav-mfa-api", "crates/rostdav", ] @@ -30,6 +31,7 @@ rostdav-app = { path = "crates/rostdav-app" } rostdav-dav = { path = "crates/rostdav-dav" } rostdav-compat = { path = "crates/rostdav-compat" } rostdav-web = { path = "crates/rostdav-web" } +rostdav-mfa-api = { path = "crates/rostdav-mfa-api" } anyhow = "1" axum = { version = "0.8", features = ["multipart"] } diff --git a/crates/rostdav-app/Cargo.toml b/crates/rostdav-app/Cargo.toml index d4a29ad..9f74196 100644 --- a/crates/rostdav-app/Cargo.toml +++ b/crates/rostdav-app/Cargo.toml @@ -32,6 +32,12 @@ office_oxide.workspace = true zip.workspace = true quick-xml.workspace = true minisign-verify.workspace = true +tempfile = { workspace = true, optional = true } +tracing-subscriber = { workspace = true, optional = true } + +[features] +# Test helpers (`push_mfa::testkit`) for the integration tests of other crates. Not for production. +test-util = ["dep:tempfile", "dep:tracing-subscriber"] [dev-dependencies] tracing-subscriber.workspace = true diff --git a/crates/rostdav-app/src/lib.rs b/crates/rostdav-app/src/lib.rs index 27cf79a..4100750 100644 --- a/crates/rostdav-app/src/lib.rs +++ b/crates/rostdav-app/src/lib.rs @@ -56,6 +56,8 @@ pub use indexer::{PassReport, SearchStatus}; pub use login_flow::FlowStep; pub use login_flow::{Granted, StartedFlow}; pub use password::{MIN_PASSWORD_CHARS, hash_password, verify_password}; +#[cfg(feature = "test-util")] +pub use push_mfa::testkit; pub use push_mfa::{ AttestationPolicy, AttestationResult, ChallengeDetails, ChallengeStatus, DeviceInfo, DevicePrincipal, EnrollToken, MfaAuditEvent, MfaConfig, MfaError, diff --git a/crates/rostdav-app/src/push_mfa/challenges.rs b/crates/rostdav-app/src/push_mfa/challenges.rs index 702cca8..e0e6684 100644 --- a/crates/rostdav-app/src/push_mfa/challenges.rs +++ b/crates/rostdav-app/src/push_mfa/challenges.rs @@ -27,7 +27,8 @@ pub enum ChallengeStatus { } impl ChallengeStatus { - fn as_str(self) -> &'static str { + /// The word the contract uses (`pending`, `approved`, `denied`, `expired`). + pub fn as_str(self) -> &'static str { match self { Self::Pending => "pending", Self::Approved => "approved", @@ -512,8 +513,8 @@ impl AppState { } } - #[cfg(test)] - pub(crate) fn set_push_channel_for_tests(&self, p: Arc) { + #[cfg(any(test, feature = "test-util"))] + pub fn set_push_channel_for_tests(&self, p: Arc) { if let Ok(mut g) = self.inner.mfa_push.write() { *g = p; } diff --git a/crates/rostdav-app/src/push_mfa/enroll.rs b/crates/rostdav-app/src/push_mfa/enroll.rs index 90652e1..14457b4 100644 --- a/crates/rostdav-app/src/push_mfa/enroll.rs +++ b/crates/rostdav-app/src/push_mfa/enroll.rs @@ -216,8 +216,8 @@ impl AppState { .unwrap_or_else(google_roots) } - #[cfg(test)] - pub(crate) fn set_attestation_roots_for_tests(&self, roots: Vec>) { + #[cfg(any(test, feature = "test-util"))] + pub fn set_attestation_roots_for_tests(&self, roots: Vec>) { if let Ok(mut g) = self.inner.mfa_roots.lock() { *g = Some(roots); } diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs index dfc97f9..06787f9 100644 --- a/crates/rostdav-app/src/push_mfa/mod.rs +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -16,8 +16,8 @@ mod enroll_tests; mod error; mod google_roots; mod push; -#[cfg(test)] -pub(crate) mod testkit; +#[cfg(any(test, feature = "test-util"))] +pub mod testkit; pub use attestation::{AttestationResult, verify_attestation}; pub use challenges::{ChallengeDetails, ChallengeStatus, NewChallenge}; diff --git a/crates/rostdav-app/src/push_mfa/testkit.rs b/crates/rostdav-app/src/push_mfa/testkit.rs index 0b02476..d316112 100644 --- a/crates/rostdav-app/src/push_mfa/testkit.rs +++ b/crates/rostdav-app/src/push_mfa/testkit.rs @@ -1,4 +1,6 @@ -//! Test helpers: synthetic Android attestation chains signed with throwaway keys. +//! Test helpers: synthetic Android attestation chains signed with throwaway keys, fake phones, +//! a fresh application state. Compiled for this crate's tests and, with the `test-util` feature, +//! for the integration tests of the HTTP crate. Never part of a production build. use base64::{Engine, engine::general_purpose::STANDARD}; use der::asn1::{BitString, ObjectIdentifier, OctetString, UtcTime}; @@ -22,22 +24,20 @@ use rostdav_meta::NewDevice; use rostdav_storage::LocalStore; use std::sync::{Arc, Mutex}; -pub(crate) const NOW: i64 = 1_800_000_000; -pub(crate) const EC_PUBLIC_KEY: ObjectIdentifier = - ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); -pub(crate) const P256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7"); -pub(crate) const ECDSA_SHA256: ObjectIdentifier = - ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2"); -pub(crate) const KEY_DESCRIPTION: ObjectIdentifier = +pub const NOW: i64 = 1_800_000_000; +pub const EC_PUBLIC_KEY: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); +pub const P256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7"); +pub const ECDSA_SHA256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2"); +pub const KEY_DESCRIPTION: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.4.1.11129.2.1.17"); -pub(crate) fn key() -> EcdsaKeyPair { +pub fn key() -> EcdsaKeyPair { let rng = SystemRandom::new(); let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).expect("key"); EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng).expect("pair") } -pub(crate) fn spki(kp: &EcdsaKeyPair) -> SubjectPublicKeyInfoOwned { +pub fn spki(kp: &EcdsaKeyPair) -> SubjectPublicKeyInfoOwned { SubjectPublicKeyInfoOwned { algorithm: AlgorithmIdentifierOwned { oid: EC_PUBLIC_KEY, @@ -47,16 +47,16 @@ pub(crate) fn spki(kp: &EcdsaKeyPair) -> SubjectPublicKeyInfoOwned { } } -pub(crate) fn spki_b64(kp: &EcdsaKeyPair) -> String { +pub fn spki_b64(kp: &EcdsaKeyPair) -> String { STANDARD.encode(spki(kp).to_der().expect("der")) } -pub(crate) fn time(secs: i64) -> Time { +pub fn time(secs: i64) -> Time { let d = Duration::from_secs(u64::try_from(secs).expect("positive")); Time::UtcTime(UtcTime::from_unix_duration(d).expect("utc time")) } -pub(crate) fn tlv(tag: u8, content: &[u8]) -> Vec { +pub fn tlv(tag: u8, content: &[u8]) -> Vec { let mut out = vec![tag]; match content.len() { n if n < 128 => out.push(u8::try_from(n).expect("short")), @@ -70,11 +70,7 @@ pub(crate) fn tlv(tag: u8, content: &[u8]) -> Vec { } /// The value of the Android key description extension (only the fields the verifier reads). -pub(crate) fn key_description( - attestation_level: u8, - keymaster_level: u8, - challenge: &[u8], -) -> Vec { +pub fn key_description(attestation_level: u8, keymaster_level: u8, challenge: &[u8]) -> Vec { let mut seq = Vec::new(); seq.extend(tlv(0x02, &[4])); // attestationVersion seq.extend(tlv(0x0a, &[attestation_level])); @@ -87,7 +83,7 @@ pub(crate) fn key_description( tlv(0x30, &seq) } -pub(crate) fn issue( +pub fn issue( subject: &str, subject_kp: &EcdsaKeyPair, issuer: &str, @@ -132,16 +128,16 @@ pub(crate) fn issue( .expect("cert") } -pub(crate) struct Opts { - pub(crate) attestation_level: u8, - pub(crate) keymaster_level: u8, - pub(crate) challenge: Vec, - pub(crate) leaf_validity: (i64, i64), - pub(crate) with_extension: bool, - pub(crate) include_root: bool, +pub struct Opts { + pub attestation_level: u8, + pub keymaster_level: u8, + pub challenge: Vec, + pub leaf_validity: (i64, i64), + pub with_extension: bool, + pub include_root: bool, /// The time the root and intermediate certificates are valid around. - pub(crate) base: i64, - pub(crate) leaf_issuer: &'static str, + pub base: i64, + pub leaf_issuer: &'static str, } impl Default for Opts { @@ -159,17 +155,17 @@ impl Default for Opts { } } -pub(crate) struct Fixture { - pub(crate) chain: Vec, - pub(crate) approval_spki: String, - pub(crate) root: Vec, - pub(crate) leaf_der: Vec, - pub(crate) intermediate_der: Vec, +pub struct Fixture { + pub chain: Vec, + pub approval_spki: String, + pub root: Vec, + pub leaf_der: Vec, + pub intermediate_der: Vec, #[allow(dead_code)] // signs the challenge answers in the challenge tests - pub(crate) approval_kp: EcdsaKeyPair, + pub approval_kp: EcdsaKeyPair, } -pub(crate) fn fixture(o: Opts) -> Fixture { +pub fn fixture(o: Opts) -> Fixture { let (root_kp, inter_kp, leaf_kp) = (key(), key(), key()); let wide = (o.base - 100_000, o.base + 100_000); let root = issue( @@ -214,7 +210,7 @@ pub(crate) fn fixture(o: Opts) -> Fixture { } /// A fresh application state in a temporary directory. -pub(crate) async fn state() -> (tempfile::TempDir, AppState) { +pub async fn state() -> (tempfile::TempDir, AppState) { let dir = tempfile::tempdir().expect("tempdir"); let store = LocalStore::open(dir.path().join("data")) .await @@ -225,7 +221,7 @@ pub(crate) async fn state() -> (tempfile::TempDir, AppState) { } /// A user who signed in with the account password, as the web interface sees them. -pub(crate) async fn add_user(st: &AppState, name: &str) -> Principal { +pub async fn add_user(st: &AppState, name: &str) -> Principal { let u = st .add_user(name, "correct-horse-battery", false) .await @@ -240,20 +236,20 @@ pub(crate) async fn add_user(st: &AppState, name: &str) -> Principal { } /// A key whose signatures are raw R||S, as the phone's are. -pub(crate) fn fixed_key() -> EcdsaKeyPair { +pub fn fixed_key() -> EcdsaKeyPair { let rng = SystemRandom::new(); let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).expect("key"); EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).expect("pair") } -pub(crate) struct Phone { - pub(crate) id: String, - pub(crate) request: EcdsaKeyPair, - pub(crate) approval: EcdsaKeyPair, - pub(crate) user_id: i64, +pub struct Phone { + pub id: String, + pub request: EcdsaKeyPair, + pub approval: EcdsaKeyPair, + pub user_id: i64, } -pub(crate) async fn enrolled(st: &AppState, user: &str, id: &str) -> Phone { +pub async fn enrolled(st: &AppState, user: &str, id: &str) -> Phone { let p = add_user(st, user).await; let (request, approval) = (fixed_key(), fixed_key()); st.inner @@ -280,7 +276,7 @@ pub(crate) async fn enrolled(st: &AppState, user: &str, id: &str) -> Phone { } #[derive(Clone, Default)] -pub(crate) struct Capture(pub(crate) Arc>>); +pub struct Capture(pub Arc>>); impl std::io::Write for Capture { fn write(&mut self, b: &[u8]) -> std::io::Result { @@ -304,7 +300,7 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture { /// One capturing subscriber for the whole test binary. A per-test subscriber is not reliable: /// tracing caches whether a call site is enabled, and a parallel test without a subscriber can /// switch it off. -pub(crate) fn captured() -> &'static Capture { +pub fn captured() -> &'static Capture { static CAPTURE: std::sync::OnceLock = std::sync::OnceLock::new(); CAPTURE.get_or_init(|| { let cap = Capture::default(); @@ -320,7 +316,7 @@ pub(crate) fn captured() -> &'static Capture { } /// Attestation options whose certificates are valid now: the service reads the real clock. -pub(crate) fn live_opts() -> Opts { +pub fn live_opts() -> Opts { let now = crate::now_secs(); Opts { base: now, diff --git a/crates/rostdav-mfa-api/Cargo.toml b/crates/rostdav-mfa-api/Cargo.toml new file mode 100644 index 0000000..063a041 --- /dev/null +++ b/crates/rostdav-mfa-api/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "rostdav-mfa-api" +description = "HTTP API of the RustMFA app: phone enrollment, sign-in approval (ADR 0014)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish.workspace = true + +[dependencies] +rostdav-core.workspace = true +rostdav-app.workspace = true +axum.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_urlencoded = "0.7" +tracing.workspace = true + +[dev-dependencies] +rostdav-app = { workspace = true, features = ["test-util"] } +rustmfa-contract.workspace = true +ring.workspace = true +base64.workspace = true +tower.workspace = true +tokio.workspace = true + +[lints] +workspace = true diff --git a/crates/rostdav-mfa-api/src/error.rs b/crates/rostdav-mfa-api/src/error.rs new file mode 100644 index 0000000..7c2f1b1 --- /dev/null +++ b/crates/rostdav-mfa-api/src/error.rs @@ -0,0 +1,84 @@ +//! Errors of the device API: the body and status of the contract. + +use axum::Json; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use rostdav_app::MfaError; +use serde_json::json; + +/// Everything a handler can fail with. +pub enum ApiError { + Mfa(MfaError), + /// The body is longer than the limit (413, `INVALID_REQUEST`). + TooLarge, +} + +impl From for ApiError { + fn from(e: MfaError) -> Self { + Self::Mfa(e) + } +} + +fn body(code: &str, message: &str) -> Json { + Json(json!({ "error": { "code": code, "message": message } })) +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + match self { + Self::TooLarge => ( + StatusCode::PAYLOAD_TOO_LARGE, + body("INVALID_REQUEST", "The request body is too large."), + ) + .into_response(), + Self::Mfa(e) => { + if let MfaError::Internal(cause) = &e { + // the cause stays in the log; the client gets the generic message + tracing::error!(error = %cause, "push sign-in request failed"); + } + let status = + StatusCode::from_u16(e.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut res = (status, body(e.code(), e.message())).into_response(); + if let MfaError::RateLimited { retry_after_secs } = &e + && let Ok(v) = HeaderValue::from_str(&retry_after_secs.to_string()) + { + res.headers_mut().insert(header::RETRY_AFTER, v); + } + res + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn text(e: ApiError) -> (StatusCode, String) { + let res = e.into_response(); + let status = res.status(); + let bytes = axum::body::to_bytes(res.into_body(), 4096) + .await + .expect("body"); + (status, String::from_utf8(bytes.to_vec()).expect("utf8")) + } + + #[tokio::test] + async fn an_internal_error_does_not_reach_the_client() { + let cause = rostdav_core::Error::BadRequest("secret detail of the cause"); + let (status, body) = text(MfaError::Internal(cause).into()).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(body.contains("INTERNAL_ERROR")); + assert!(!body.contains("secret"), "{body}"); + } + + #[tokio::test] + async fn the_bodies_have_the_contract_shape() { + let (status, body) = text(ApiError::TooLarge).await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + let v: serde_json::Value = serde_json::from_str(&body).expect("json"); + assert_eq!(v["error"]["code"], "INVALID_REQUEST"); + assert!(v["error"]["message"].is_string()); + assert_eq!(v.as_object().expect("object").len(), 1); + } +} diff --git a/crates/rostdav-mfa-api/src/lib.rs b/crates/rostdav-mfa-api/src/lib.rs new file mode 100644 index 0000000..5a78eaf --- /dev/null +++ b/crates/rostdav-mfa-api/src/lib.rs @@ -0,0 +1,354 @@ +//! HTTP API of the RustMFA app (ADR 0014, contract `rustmfa-v1.yaml`). +//! +//! A phone proves itself with `Authorization: RustMFA-Sig …` over the method, the path and query +//! exactly as received, a timestamp, a nonce and the hash of the body; the check lives in +//! `rostdav-app`. This crate reads the request, calls the service and shapes the answer. It never +//! touches the database. Registration is the one route without a signature: it carries the +//! single-use enrollment token. + +mod error; + +use std::net::{IpAddr, Ipv4Addr}; + +use axum::body::{Body, Bytes}; +use axum::extract::{Path, Request, State}; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post, put}; +use axum::{Json, Router}; +use rostdav_app::{ + AppState, ChallengeDetails, DeviceInfo, DevicePrincipal, MfaAuditEvent, MfaError, RegisterInput, +}; +use rostdav_core::ClientIp; +use serde::Deserialize; +use serde_json::{Value, json}; + +pub use error::ApiError; + +/// The longest request body read, in bytes. +const MAX_BODY: usize = 64 * 1024; + +/// The ten device routes under `/api/v1`. The eleventh operation of the contract, creating an +/// enrollment token, needs the web session and belongs to the web crate. The paths are complete +/// (no nesting), so the signed path is the one the phone sent. +pub fn router() -> Router { + Router::new() + .route("/api/v1/devices/register", post(register)) + .route("/api/v1/devices", get(list_devices)) + .route("/api/v1/devices/{device_id}", delete(revoke_device)) + .route("/api/v1/devices/{device_id}/push-token", put(push_token)) + .route("/api/v1/devices/{device_id}/rotate-key", post(rotate_key)) + .route("/api/v1/mfa/challenges", get(list_challenges)) + .route("/api/v1/mfa/challenges/{challenge_id}", get(get_challenge)) + .route( + "/api/v1/mfa/challenges/{challenge_id}/approve", + post(approve), + ) + .route("/api/v1/mfa/challenges/{challenge_id}/deny", post(deny)) + .route("/api/v1/audit", get(audit)) + .layer(axum::middleware::map_response(no_store)) +} + +/// Answers carry personal and security data: nothing may be cached. +async fn no_store(mut res: Response) -> Response { + res.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + res +} + +fn invalid(reason: &'static str) -> ApiError { + MfaError::InvalidRequest(reason).into() +} + +/// The body, at most [`MAX_BODY`] bytes. +async fn read_body(body: Body) -> Result { + axum::body::to_bytes(body, MAX_BODY) + .await + .map_err(|_| ApiError::TooLarge) +} + +/// Strict JSON: unknown fields and anything else that does not fit the type are `INVALID_REQUEST`. +fn parse Deserialize<'de>>(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + .map_err(|_| invalid("request body is not valid for this operation")) +} + +/// Checks the signature of the request and returns the phone and the raw body. Exactly one +/// `Authorization` header counts. The body is read before the check because its hash is signed. +async fn authenticate( + state: &AppState, + req: Request, +) -> Result<(DevicePrincipal, Bytes), ApiError> { + let (parts, body) = req.into_parts(); + let mut values = parts.headers.get_all(header::AUTHORIZATION).iter(); + let header = match (values.next(), values.next()) { + (Some(v), None) => v.to_str().map_err(|_| MfaError::Unauthenticated)?, + _ => return Err(MfaError::Unauthenticated.into()), + }; + let bytes = read_body(body).await?; + let path_and_query = parts + .uri + .path_and_query() + .map_or_else(|| parts.uri.path(), |p| p.as_str()); + let who = state + .mfa_authenticate_device(header, parts.method.as_str(), path_and_query, &bytes) + .await?; + Ok((who, bytes)) +} + +// ---- registration ----------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RegisterBody { + enroll_token: String, + device_name: String, + request_public_key: String, + approval_public_key: String, + key_algorithm: String, + attestation_chain: Vec, + push_channel: String, + push_token: String, +} + +async fn register(State(state): State, req: Request) -> Result { + let ip = req + .extensions() + .get::() + .map_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED), |c| c.0); + let bytes = read_body(req.into_body()).await?; + let b: RegisterBody = parse(&bytes)?; + let done = state + .mfa_register_device( + RegisterInput { + enroll_token: b.enroll_token, + device_name: b.device_name, + request_public_key: b.request_public_key, + approval_public_key: b.approval_public_key, + key_algorithm: b.key_algorithm, + attestation_chain: b.attestation_chain, + push_channel: b.push_channel, + push_token: b.push_token, + }, + ip, + ) + .await?; + Ok(( + StatusCode::CREATED, + Json(json!({ "device_id": done.device_id, "status": done.status })), + ) + .into_response()) +} + +// ---- devices ---------------------------------------------------------------------------------- + +fn device_json(d: DeviceInfo) -> Value { + json!({ + "device_id": d.device_id, + "device_name": d.device_name, + "status": d.status, + "push_channel": d.push_channel, + "hardware_backed": d.hardware_backed, + "strongbox": d.strongbox, + "created_at": d.created_at, + "last_seen_at": d.last_seen_at, + }) +} + +async fn list_devices(State(state): State, req: Request) -> Result { + let (who, _) = authenticate(&state, req).await?; + let devices: Vec = state + .mfa_list_devices(who.user_id) + .await? + .into_iter() + .map(device_json) + .collect(); + Ok(Json(json!({ "devices": devices })).into_response()) +} + +async fn revoke_device( + State(state): State, + Path(device_id): Path, + req: Request, +) -> Result { + let (who, _) = authenticate(&state, req).await?; + state.mfa_revoke_device_as(&who, &device_id).await?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PushTokenBody { + push_channel: String, + push_token: String, +} + +fn status_of(device_id: &str) -> Response { + Json(json!({ "device_id": device_id, "status": "active" })).into_response() +} + +async fn push_token( + State(state): State, + Path(device_id): Path, + req: Request, +) -> Result { + let (who, bytes) = authenticate(&state, req).await?; + let b: PushTokenBody = parse(&bytes)?; + state + .mfa_update_push_token(&who, &device_id, &b.push_channel, &b.push_token) + .await?; + Ok(status_of(&device_id)) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RotateBody { + request_public_key: String, + approval_public_key: String, + attestation_chain: Vec, +} + +async fn rotate_key( + State(state): State, + Path(device_id): Path, + req: Request, +) -> Result { + let (who, bytes) = authenticate(&state, req).await?; + let b: RotateBody = parse(&bytes)?; + state + .mfa_rotate_keys( + &who, + &device_id, + &b.request_public_key, + &b.approval_public_key, + &b.attestation_chain, + ) + .await?; + Ok(status_of(&device_id)) +} + +// ---- challenges ------------------------------------------------------------------------------- + +fn challenge_json(c: ChallengeDetails) -> Value { + json!({ + "challenge_id": c.challenge_id, + "service_name": c.service_name, + "login_device": c.login_device, + "approximate_location": c.approximate_location, + "created_at": c.created_at, + "expires_at": c.expires_at, + "status": c.status.as_str(), + "candidate_numbers": c.candidate_numbers, + "nonce": c.nonce, + "login_id": c.login_id, + "audience": c.audience, + }) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ListQuery { + status: Option, +} + +/// Only pending challenges are listed (the polling fallback for a late push). +async fn list_challenges( + State(state): State, + req: Request, +) -> Result { + let query = req.uri().query().unwrap_or("").to_owned(); + let (who, _) = authenticate(&state, req).await?; + let q: ListQuery = serde_urlencoded::from_str(&query) + .map_err(|_| invalid("query string is not valid for this operation"))?; + if q.status.as_deref().is_some_and(|s| s != "pending") { + return Err(invalid("only status=pending can be listed")); + } + let challenges: Vec = state + .mfa_pending_challenges(&who) + .await? + .into_iter() + .map(challenge_json) + .collect(); + Ok(Json(json!({ "challenges": challenges })).into_response()) +} + +async fn get_challenge( + State(state): State, + Path(id): Path, + req: Request, +) -> Result { + let (who, _) = authenticate(&state, req).await?; + let d = state.mfa_get_challenge(&who, &id).await?; + Ok(Json(challenge_json(d)).into_response()) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ApproveBody { + device_id: String, + selected_number: i64, + signature: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DenyBody { + device_id: String, + signature: String, +} + +fn answered(id: &str, status: rostdav_app::ChallengeStatus) -> Response { + Json(json!({ "challenge_id": id, "status": status.as_str() })).into_response() +} + +/// The `device_id` in the body must be the signing phone: a phone cannot answer for another. +fn same_device(who: &DevicePrincipal, body_device: &str) -> Result<(), ApiError> { + if body_device == who.device_id { + Ok(()) + } else { + Err(MfaError::DeviceNotAuthorized.into()) + } +} + +async fn approve( + State(state): State, + Path(id): Path, + req: Request, +) -> Result { + let (who, bytes) = authenticate(&state, req).await?; + let b: ApproveBody = parse(&bytes)?; + same_device(&who, &b.device_id)?; + let status = state + .mfa_approve(&who, &id, b.selected_number, &b.signature) + .await?; + Ok(answered(&id, status)) +} + +async fn deny( + State(state): State, + Path(id): Path, + req: Request, +) -> Result { + let (who, bytes) = authenticate(&state, req).await?; + let b: DenyBody = parse(&bytes)?; + same_device(&who, &b.device_id)?; + let status = state.mfa_deny(&who, &id, &b.signature).await?; + Ok(answered(&id, status)) +} + +// ---- audit ------------------------------------------------------------------------------------ + +fn event_json(e: MfaAuditEvent) -> Value { + json!({ "event": e.event, "at": e.at, "device_id": e.device_id, "detail": e.detail }) +} + +async fn audit(State(state): State, req: Request) -> Result { + let (who, _) = authenticate(&state, req).await?; + let events: Vec = state + .mfa_audit_events(who.user_id) + .await? + .into_iter() + .map(event_json) + .collect(); + Ok(Json(json!({ "events": events })).into_response()) +} diff --git a/crates/rostdav-mfa-api/tests/flow.rs b/crates/rostdav-mfa-api/tests/flow.rs new file mode 100644 index 0000000..e2e50cc --- /dev/null +++ b/crates/rostdav-mfa-api/tests/flow.rs @@ -0,0 +1,1150 @@ +//! The device API end to end: a fake phone signs with the contract's formats against the router. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use axum::Router; +use axum::body::Body; +use axum::http::{HeaderMap, Request, StatusCode}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ring::rand::SystemRandom; +use ring::signature::EcdsaKeyPair; +use rostdav_app::AppState; +use rostdav_app::testkit::{self, Opts}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +static NONCE: AtomicU64 = AtomicU64::new(0); + +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0)) +} + +fn sha256(data: &[u8]) -> Vec { + ring::digest::digest(&ring::digest::SHA256, data) + .as_ref() + .to_vec() +} + +// ---- the contract as JSON --------------------------------------------------------------------- + +fn spec() -> Value { + let p = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../rostdav-mfa/contract/openapi/rustmfa-v1.json" + ); + serde_json::from_str(&std::fs::read_to_string(p).expect("contract JSON mirror missing")) + .expect("contract JSON") +} + +fn resolve<'a>(spec: &'a Value, schema: &'a Value) -> &'a Value { + match schema["$ref"].as_str() { + Some(r) => { + let name = r.strip_prefix("#/components/schemas/").expect("local ref"); + &spec["components"]["schemas"][name] + } + None => schema, + } +} + +/// Checks `v` against a schema of the contract: types, enums, constants, required fields, and +/// that no field is present that the contract does not name. The error names the field. +fn validate(spec: &Value, schema: &Value, v: &Value, at: &str) -> Result<(), String> { + let schema = resolve(spec, schema); + if let Some(c) = schema.get("const") + && v != c + { + return Err(format!("{at}: expected {c}, got {v}")); + } + if let Some(e) = schema["enum"].as_array() + && !e.contains(v) + { + return Err(format!("{at}: {v} is not one of {e:?}")); + } + let types: Vec<&str> = match &schema["type"] { + Value::String(t) => vec![t.as_str()], + Value::Array(a) => a.iter().filter_map(Value::as_str).collect(), + _ => vec![], + }; + if types.is_empty() { + return Ok(()); + } + let ok = types.iter().any(|t| match *t { + "string" => v.is_string(), + "integer" => v.is_i64() || v.is_u64(), + "boolean" => v.is_boolean(), + "null" => v.is_null(), + "array" => v.is_array(), + "object" => v.is_object(), + other => panic!("unhandled type {other}"), + }); + if !ok { + return Err(format!("{at}: {v} is not of type {types:?}")); + } + if let (Some(items), Some(arr)) = (schema.get("items"), v.as_array()) { + for (i, x) in arr.iter().enumerate() { + validate(spec, items, x, &format!("{at}[{i}]"))?; + } + if let Some(min) = schema["minItems"].as_u64() + && (arr.len() as u64) < min + { + return Err(format!("{at}: fewer than {min} items")); + } + if let Some(max) = schema["maxItems"].as_u64() + && (arr.len() as u64) > max + { + return Err(format!("{at}: more than {max} items")); + } + } + if let Some(obj) = v.as_object() { + let props = schema["properties"].as_object(); + for r in schema["required"].as_array().into_iter().flatten() { + let r = r.as_str().expect("required name"); + if !obj.contains_key(r) { + return Err(format!("{at}.{r}: required field missing")); + } + } + for (k, x) in obj { + let Some(p) = props.and_then(|p| p.get(k)) else { + return Err(format!("{at}.{k}: field is not in the contract")); + }; + validate(spec, p, x, &format!("{at}.{k}"))?; + } + } + Ok(()) +} + +fn conforms(schema: &str, v: &Value) { + let spec = spec(); + let s = json!({ "$ref": format!("#/components/schemas/{schema}") }); + if let Err(e) = validate(&spec, &s, v, schema) { + panic!("{e}\nbody: {v}"); + } +} + +/// The success schema of an operation, for `status`. +fn response_schema(path: &str, method: &str, status: &str) -> Value { + let spec = spec(); + spec["paths"][path][method]["responses"][status]["content"]["application/json"]["schema"] + .clone() +} + +fn conforms_to_operation(path: &str, method: &str, status: &str, v: &Value) { + let spec = spec(); + let schema = response_schema(path, method, status); + assert!(!schema.is_null(), "{method} {path} {status} has no schema"); + if let Err(e) = validate(&spec, &schema, v, &format!("{method} {path}")) { + panic!("{e}\nbody: {v}"); + } +} + +// ---- harness and fake phone ------------------------------------------------------------------- + +struct Harness { + _dir: tempfile_guard::Dir, + st: AppState, + app: Router, +} + +mod tempfile_guard { + // keeps the temporary directory of the test state alive without naming its crate + pub struct Dir(#[allow(dead_code)] pub Box); +} + +async fn harness() -> Harness { + let (dir, st) = testkit::state().await; + let app = rostdav_mfa_api::router().with_state(st.clone()); + Harness { + _dir: tempfile_guard::Dir(Box::new(dir)), + st, + app, + } +} + +struct Phone { + id: String, + user_id: i64, + request: EcdsaKeyPair, + /// Signs DER; the fake phone converts to the raw form like the real app does. + approval: EcdsaKeyPair, +} + +fn raw_sign_request(key: &EcdsaKeyPair, msg: &str) -> String { + let sig = key.sign(&SystemRandom::new(), msg.as_bytes()).expect("sig"); + URL_SAFE_NO_PAD.encode(sig.as_ref()) +} + +fn der_to_raw_b64(der: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(rustmfa_contract::der_to_raw(der).expect("raw")) +} + +impl Phone { + fn request(&self, method: &str, pq: &str, body: &[u8]) -> Request { + let ts = now(); + let nonce = format!("n{}", NONCE.fetch_add(1, Ordering::SeqCst)); + let msg = + rustmfa_contract::request_signing_string(method, pq, ts, &nonce, body).expect("msg"); + let sig = raw_sign_request(&self.request, &msg); + Request::builder() + .method(method) + .uri(pq) + .header( + "authorization", + format!( + r#"RustMFA-Sig device_id="{}",ts="{ts}",nonce="{nonce}",sig="{sig}""#, + self.id + ), + ) + .header("content-type", "application/json") + .body(Body::from(body.to_vec())) + .expect("request") + } + + /// The answer signature over the canonical message of challenge details `d`. + fn answer(&self, d: &Value) -> String { + let f = |k: &str| d[k].as_str().expect("field").to_owned(); + let (c, n, l, e, a) = ( + f("challenge_id"), + f("nonce"), + f("login_id"), + f("expires_at"), + f("audience"), + ); + let msg = rustmfa_contract::canonical_message(&rustmfa_contract::ChallengeMessage { + challenge_id: &c, + nonce: &n, + login_id: &l, + device_id: &self.id, + expires_at: &e, + audience: &a, + }) + .expect("message"); + let der = self + .approval + .sign(&SystemRandom::new(), msg.as_bytes()) + .expect("sig"); + der_to_raw_b64(der.as_ref()) + } +} + +struct Reply { + status: StatusCode, + headers: HeaderMap, + body: Value, + raw: Vec, +} + +async fn send(app: &Router, req: Request) -> Reply { + let res = app.clone().oneshot(req).await.expect("response"); + let (parts, body) = res.into_parts(); + let raw = axum::body::to_bytes(body, 1 << 20) + .await + .expect("body") + .to_vec(); + let body = serde_json::from_slice(&raw).unwrap_or(Value::Null); + Reply { + status: parts.status, + headers: parts.headers, + body, + raw, + } +} + +fn error_of(r: &Reply) -> (StatusCode, String) { + let code = r.body["error"]["code"].as_str().unwrap_or("").to_owned(); + assert!( + r.body["error"]["message"].is_string() || r.status == StatusCode::NO_CONTENT, + "no error message in {}", + r.body + ); + (r.status, code) +} + +fn live() -> Opts { + let t = now(); + Opts { + base: t, + leaf_validity: (t - 1000, t + 1000), + ..Opts::default() + } +} + +/// Registers a phone for a new user through the HTTP route, like the app does. +async fn enroll(h: &Harness, user: &str) -> Phone { + let principal = testkit::add_user(&h.st, user).await; + let token = + h.st.mfa_create_enroll_token(&principal) + .await + .expect("token") + .token; + let f = testkit::fixture(Opts { + challenge: sha256(token.as_bytes()), + ..live() + }); + h.st.set_attestation_roots_for_tests(vec![f.root.clone()]); + let request = testkit::fixed_key(); + let body = json!({ + "enroll_token": token, + "device_name": "Pixel 8", + "request_public_key": testkit::spki_b64(&request), + "approval_public_key": f.approval_spki, + "key_algorithm": "ES256", + "attestation_chain": f.chain, + "push_channel": "fcm", + "push_token": "fcm-token", + }); + let r = send( + &h.app, + Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("req"), + ) + .await; + assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); + Phone { + id: r.body["device_id"].as_str().expect("device_id").to_owned(), + user_id: principal.user_id, + request, + approval: f.approval_kp, + } +} + +fn ip() -> std::net::IpAddr { + std::net::IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7)) +} + +async fn challenge(h: &Harness, p: &Phone, login: &str) -> (String, i64) { + let c = + h.st.mfa_create_challenge(p.user_id, &p.id, login, "Firefox on Linux", ip()) + .await + .expect("challenge"); + (c.challenge_id, c.number) +} + +async fn details(h: &Harness, p: &Phone, id: &str) -> Value { + let r = send( + &h.app, + p.request("GET", &format!("/api/v1/mfa/challenges/{id}"), b""), + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.body); + r.body +} + +fn approve_body(p: &Phone, d: &Value, number: i64) -> Vec { + json!({ "device_id": p.id, "selected_number": number, "signature": p.answer(d) }) + .to_string() + .into_bytes() +} + +// ---- tests ------------------------------------------------------------------------------------ + +#[tokio::test] +async fn full_happy_path() { + let h = harness().await; + let p = enroll(&h, "anna").await; + let (id, number) = challenge(&h, &p, "login-1").await; + + let list = send( + &h.app, + p.request("GET", "/api/v1/mfa/challenges?status=pending", b""), + ) + .await; + assert_eq!(list.status, StatusCode::OK, "{}", list.body); + assert_eq!(list.body["challenges"][0]["challenge_id"], id); + + let d = details(&h, &p, &id).await; + assert_eq!(d["status"], "pending"); + assert!( + d["candidate_numbers"] + .as_array() + .expect("3") + .contains(&json!(number)) + ); + + let path = format!("/api/v1/mfa/challenges/{id}/approve"); + let r = send( + &h.app, + p.request("POST", &path, &approve_body(&p, &d, number)), + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.body); + assert_eq!(r.body, json!({ "challenge_id": id, "status": "approved" })); + assert_eq!( + h.st.mfa_challenge_status(&id).await.expect("status"), + rostdav_app::ChallengeStatus::Approved + ); + assert_eq!(details(&h, &p, &id).await["status"], "approved"); +} + +#[tokio::test] +async fn register_response_matches_openapi_schema() { + let h = harness().await; + // `enroll` asserts 201; read the body again through a second user to keep the raw reply + let principal = testkit::add_user(&h.st, "ben").await; + let token = + h.st.mfa_create_enroll_token(&principal) + .await + .expect("t") + .token; + let f = testkit::fixture(Opts { + challenge: sha256(token.as_bytes()), + ..live() + }); + h.st.set_attestation_roots_for_tests(vec![f.root.clone()]); + let body = json!({ + "enroll_token": token, "device_name": "P", "request_public_key": testkit::spki_b64(&testkit::fixed_key()), + "approval_public_key": f.approval_spki, "key_algorithm": "ES256", + "attestation_chain": f.chain, "push_channel": "fcm", "push_token": "t", + }); + let r = send( + &h.app, + Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .body(Body::from(body.to_string())) + .expect("req"), + ) + .await; + assert_eq!(r.status, StatusCode::CREATED); + conforms("DeviceStatusResponse", &r.body); + conforms_to_operation("/api/v1/devices/register", "post", "201", &r.body); + assert_eq!(r.body["status"], "active"); +} + +#[tokio::test] +async fn all_success_responses_match_the_openapi_schemas() { + let h = harness().await; + let p = enroll(&h, "anna").await; + let (id, number) = challenge(&h, &p, "login-1").await; + let d = details(&h, &p, &id).await; + conforms("ChallengeDetails", &d); + + let r = send(&h.app, p.request("GET", "/api/v1/devices", b"")).await; + assert_eq!(r.status, StatusCode::OK); + conforms_to_operation("/api/v1/devices", "get", "200", &r.body); + assert_eq!(r.body["devices"][0]["device_id"], p.id); + + let r = send(&h.app, p.request("GET", "/api/v1/mfa/challenges", b"")).await; + conforms_to_operation("/api/v1/mfa/challenges", "get", "200", &r.body); + + let path = format!("/api/v1/mfa/challenges/{id}/approve"); + let r = send( + &h.app, + p.request("POST", &path, &approve_body(&p, &d, number)), + ) + .await; + conforms_to_operation( + "/api/v1/mfa/challenges/{challenge_id}/approve", + "post", + "200", + &r.body, + ); + + let (id2, _) = challenge(&h, &p, "login-2").await; + let d2 = details(&h, &p, &id2).await; + let body = json!({ "device_id": p.id, "signature": p.answer(&d2) }).to_string(); + let r = send( + &h.app, + p.request( + "POST", + &format!("/api/v1/mfa/challenges/{id2}/deny"), + body.as_bytes(), + ), + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.body); + conforms_to_operation( + "/api/v1/mfa/challenges/{challenge_id}/deny", + "post", + "200", + &r.body, + ); + assert_eq!(r.body["status"], "denied"); + + let body = json!({ "push_channel": "unifiedpush", "push_token": "https://up/x" }).to_string(); + let r = send( + &h.app, + p.request( + "PUT", + &format!("/api/v1/devices/{}/push-token", p.id), + body.as_bytes(), + ), + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.body); + conforms_to_operation( + "/api/v1/devices/{device_id}/push-token", + "put", + "200", + &r.body, + ); + + let r = send(&h.app, p.request("GET", "/api/v1/audit", b"")).await; + assert_eq!(r.status, StatusCode::OK, "{}", r.body); + conforms_to_operation("/api/v1/audit", "get", "200", &r.body); + assert!(!r.body["events"].as_array().expect("events").is_empty()); + + // rotate last: it changes the request key + let new_request = testkit::fixed_key(); + let req_pub = testkit::spki_b64(&new_request); + let f = testkit::fixture(Opts { + challenge: sha256(format!("rotate:{}:{req_pub}", p.id).as_bytes()), + ..live() + }); + h.st.set_attestation_roots_for_tests(vec![f.root.clone()]); + let body = json!({ + "request_public_key": req_pub, + "approval_public_key": f.approval_spki, + "attestation_chain": f.chain, + }) + .to_string(); + let r = send( + &h.app, + p.request( + "POST", + &format!("/api/v1/devices/{}/rotate-key", p.id), + body.as_bytes(), + ), + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.body); + conforms_to_operation( + "/api/v1/devices/{device_id}/rotate-key", + "post", + "200", + &r.body, + ); + // the old request key no longer authenticates + let r = send(&h.app, p.request("GET", "/api/v1/devices", b"")).await; + assert_eq!( + error_of(&r), + (StatusCode::UNAUTHORIZED, "UNAUTHENTICATED".into()) + ); +} + +#[tokio::test] +async fn revoking_a_device_answers_204_and_blocks_it() { + let h = harness().await; + let p = enroll(&h, "anna").await; + let r = send( + &h.app, + p.request("DELETE", &format!("/api/v1/devices/{}", p.id), b""), + ) + .await; + assert_eq!(r.status, StatusCode::NO_CONTENT); + assert!(r.raw.is_empty()); + let r = send(&h.app, p.request("GET", "/api/v1/devices", b"")).await; + assert_eq!( + error_of(&r), + (StatusCode::FORBIDDEN, "DEVICE_REVOKED".into()) + ); +} + +fn authenticated_routes() -> Vec<(&'static str, &'static str, &'static str)> { + vec![ + ("GET", "/api/v1/devices", ""), + ("DELETE", "/api/v1/devices/dev_x", ""), + ( + "PUT", + "/api/v1/devices/dev_x/push-token", + r#"{"push_channel":"fcm","push_token":"t"}"#, + ), + ( + "POST", + "/api/v1/devices/dev_x/rotate-key", + r#"{"request_public_key":"a","approval_public_key":"b","attestation_chain":[]}"#, + ), + ("GET", "/api/v1/mfa/challenges", ""), + ("GET", "/api/v1/mfa/challenges/ch_x", ""), + ( + "POST", + "/api/v1/mfa/challenges/ch_x/approve", + r#"{"device_id":"d","selected_number":10,"signature":"s"}"#, + ), + ( + "POST", + "/api/v1/mfa/challenges/ch_x/deny", + r#"{"device_id":"d","signature":"s"}"#, + ), + ("GET", "/api/v1/audit", ""), + ] +} + +#[tokio::test] +async fn unauthenticated_requests_get_401_with_error_body() { + let h = harness().await; + let p = enroll(&h, "anna").await; + for (i, (method, path, body)) in authenticated_routes().into_iter().enumerate() { + let plain = |auth: Option<&str>| { + let mut b = Request::builder().method(method).uri(path); + if let Some(a) = auth { + b = b.header("authorization", a); + } + b.body(Body::from(body.to_owned())).expect("req") + }; + // five failures block a device, so a real device gets one bad signature and the other + // routes get an unknown one + let device = if i == 0 { + p.id.clone() + } else { + format!("dev_ghost{i}") + }; + for (label, req) in [ + ("no header", plain(None)), + ("garbage", plain(Some("Bearer abc"))), + ( + "bad signature", + plain(Some(&format!( + r#"RustMFA-Sig device_id="{device}",ts="{}",nonce="zz",sig="AAAA""#, + now() + ))), + ), + ] { + let r = send(&h.app, req).await; + assert_eq!( + error_of(&r), + (StatusCode::UNAUTHORIZED, "UNAUTHENTICATED".into()), + "{method} {path} ({label})" + ); + } + // two Authorization headers are refused, even when each would be fine alone + let mut req = p.request(method, path, body.as_bytes()); + let first = req.headers().get("authorization").cloned().expect("auth"); + req.headers_mut().append("authorization", first); + let r = send(&h.app, req).await; + assert_eq!( + r.status, + StatusCode::UNAUTHORIZED, + "{method} {path} (two headers)" + ); + } +} + +#[tokio::test] +async fn query_string_is_part_of_the_signature() { + let h = harness().await; + let p = enroll(&h, "anna").await; + let mut req = p.request("GET", "/api/v1/mfa/challenges?status=pending", b""); + *req.uri_mut() = "/api/v1/mfa/challenges?status=approved" + .parse() + .expect("uri"); + let r = send(&h.app, req).await; + assert_eq!( + error_of(&r), + (StatusCode::UNAUTHORIZED, "UNAUTHENTICATED".into()) + ); + // and the body + let body = json!({ "push_channel": "fcm", "push_token": "t" }).to_string(); + let mut req = p.request( + "PUT", + &format!("/api/v1/devices/{}/push-token", p.id), + body.as_bytes(), + ); + let tampered = json!({ "push_channel": "fcm", "push_token": "evil" }).to_string(); + *req.body_mut() = Body::from(tampered); + let r = send(&h.app, req).await; + assert_eq!(r.status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn error_codes_and_statuses_match_the_contract() { + let h = harness().await; + let h = Harness { + st: h.st.clone().with_mfa(rostdav_app::MfaConfig { + challenge_ttl_secs: 1, + ..rostdav_app::MfaConfig::default() + }), + ..h + }; + // the router holds its own clone of the state: build it from the configured one + let h = Harness { + app: rostdav_mfa_api::router().with_state(h.st.clone()), + ..h + }; + let p = enroll(&h, "anna").await; + let mut seen: Vec<(&str, StatusCode, String)> = Vec::new(); + + // not found: unknown challenge + let r = send( + &h.app, + p.request("GET", "/api/v1/mfa/challenges/ch_unknown", b""), + ) + .await; + let (s, c) = error_of(&r); + seen.push(("unknown challenge", s, c)); + + // expired + let (id, number) = challenge(&h, &p, "l-exp").await; + let d = details(&h, &p, &id).await; + tokio::time::sleep(std::time::Duration::from_millis(2100)).await; + let path = format!("/api/v1/mfa/challenges/{id}/approve"); + let r = send( + &h.app, + p.request("POST", &path, &approve_body(&p, &d, number)), + ) + .await; + let (s, c) = error_of(&r); + seen.push(("expired", s, c)); + + // consumed and mismatch need a longer lifetime + let h2 = { + let st = h.st.clone().with_mfa(rostdav_app::MfaConfig::default()); + Harness { + app: rostdav_mfa_api::router().with_state(st.clone()), + st, + ..h + } + }; + let (id, number) = challenge(&h2, &p, "l-ok").await; + let d = details(&h2, &p, &id).await; + let path = format!("/api/v1/mfa/challenges/{id}/approve"); + let ok = send( + &h2.app, + p.request("POST", &path, &approve_body(&p, &d, number)), + ) + .await; + assert_eq!(ok.status, StatusCode::OK, "{}", ok.body); + let r = send( + &h2.app, + p.request("POST", &path, &approve_body(&p, &d, number)), + ) + .await; + let (s, c) = error_of(&r); + seen.push(("consumed", s, c)); + + let (id, number) = challenge(&h2, &p, "l-mis").await; + let d = details(&h2, &p, &id).await; + let wrong = d["candidate_numbers"] + .as_array() + .expect("3") + .iter() + .filter_map(Value::as_i64) + .find(|n| *n != number) + .expect("decoy"); + let path = format!("/api/v1/mfa/challenges/{id}/approve"); + let r = send( + &h2.app, + p.request("POST", &path, &approve_body(&p, &d, wrong)), + ) + .await; + let (s, c) = error_of(&r); + seen.push(("mismatch", s, c)); + + // another device in the body + let (id, number) = challenge(&h2, &p, "l-dev").await; + let d = details(&h2, &p, &id).await; + let body = + json!({ "device_id": "dev_other", "selected_number": number, "signature": p.answer(&d) }); + let r = send( + &h2.app, + p.request( + "POST", + &format!("/api/v1/mfa/challenges/{id}/approve"), + body.to_string().as_bytes(), + ), + ) + .await; + let (s, c) = error_of(&r); + seen.push(("device_id of another phone", s, c)); + + // invalid request + let r = send( + &h2.app, + p.request( + "PUT", + &format!("/api/v1/devices/{}/push-token", p.id), + json!({ "push_channel": "sms", "push_token": "t" }) + .to_string() + .as_bytes(), + ), + ) + .await; + let (s, c) = error_of(&r); + seen.push(("invalid request", s, c)); + + // rate limited: five wrong tokens from one address, the sixth is refused + let mut last = None; + for i in 0..6 { + let body = json!({ + "enroll_token": "nope", "device_name": "x", "request_public_key": "a", + "approval_public_key": "b", "key_algorithm": "ES256", "attestation_chain": [], + "push_channel": "fcm", "push_token": format!("t{i}"), + }); + // keys must be real for the failure to count, so use valid ones with a wrong token + let mut body = body; + body["request_public_key"] = json!(testkit::spki_b64(&testkit::fixed_key())); + body["approval_public_key"] = json!(testkit::spki_b64(&testkit::fixed_key())); + last = Some( + send( + &h2.app, + Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .body(Body::from(body.to_string())) + .expect("req"), + ) + .await, + ); + } + let r = last.expect("six replies"); + assert!( + r.headers.get("retry-after").is_some(), + "429 needs Retry-After" + ); + let retry: u64 = r.headers["retry-after"] + .to_str() + .expect("str") + .parse() + .expect("secs"); + assert!(retry > 0 && retry <= 900); + let (s, c) = error_of(&r); + seen.push(("rate limited", s, c)); + + // revoked + send( + &h2.app, + p.request("DELETE", &format!("/api/v1/devices/{}", p.id), b""), + ) + .await; + let r = send(&h2.app, p.request("GET", "/api/v1/devices", b"")).await; + let (s, c) = error_of(&r); + seen.push(("revoked", s, c)); + + let expected: [(&str, u16, &str); 9] = [ + ("unknown challenge", 404, "RESOURCE_NOT_FOUND"), + ("expired", 410, "CHALLENGE_EXPIRED"), + ("consumed", 409, "CHALLENGE_ALREADY_CONSUMED"), + ("mismatch", 409, "CHALLENGE_NUMBER_MISMATCH"), + ("device_id of another phone", 403, "DEVICE_NOT_AUTHORIZED"), + ("invalid request", 400, "INVALID_REQUEST"), + ("rate limited", 429, "RATE_LIMITED"), + ("revoked", 403, "DEVICE_REVOKED"), + ("unauthenticated placeholder", 401, "UNAUTHENTICATED"), + ]; + for (name, status, code) in &expected[..8] { + let got = seen + .iter() + .find(|(n, _, _)| n == name) + .unwrap_or_else(|| panic!("{name} not run")); + assert_eq!((got.1.as_u16(), got.2.as_str()), (*status, *code), "{name}"); + } +} + +#[tokio::test] +async fn body_over_64_kib_is_rejected_413() { + let h = harness().await; + let p = enroll(&h, "anna").await; + let big = "a".repeat(64 * 1024 + 1); + let r = send( + &h.app, + Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .body(Body::from(big.clone())) + .expect("req"), + ) + .await; + assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(r.body["error"]["code"], "INVALID_REQUEST"); + // authenticated routes too, even with a good signature over the whole body + let body = format!(r#"{{"push_channel":"fcm","push_token":"{big}"}}"#); + let r = send( + &h.app, + p.request( + "PUT", + &format!("/api/v1/devices/{}/push-token", p.id), + body.as_bytes(), + ), + ) + .await; + assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE); + // exactly the limit still reaches the parser (and fails there, not on size) + let r = send( + &h.app, + Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .body(Body::from("a".repeat(64 * 1024))) + .expect("req"), + ) + .await; + assert_eq!(r.status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn unknown_json_fields_are_rejected() { + let h = harness().await; + let p = enroll(&h, "anna").await; + let (id, number) = challenge(&h, &p, "l").await; + let d = details(&h, &p, &id).await; + let mut body: Value = serde_json::from_slice(&approve_body(&p, &d, number)).expect("json"); + body["extra"] = json!(true); + let r = send( + &h.app, + p.request( + "POST", + &format!("/api/v1/mfa/challenges/{id}/approve"), + body.to_string().as_bytes(), + ), + ) + .await; + assert_eq!( + error_of(&r), + (StatusCode::BAD_REQUEST, "INVALID_REQUEST".into()) + ); + assert_eq!( + h.st.mfa_challenge_status(&id).await.expect("s"), + rostdav_app::ChallengeStatus::Pending + ); + // registration too + let r = send( + &h.app, + Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .body(Body::from( + json!({ "enroll_token": "x", "extra": 1 }).to_string(), + )) + .expect("req"), + ) + .await; + assert_eq!( + error_of(&r), + (StatusCode::BAD_REQUEST, "INVALID_REQUEST".into()) + ); + // not JSON at all + let r = send( + &h.app, + p.request( + "PUT", + &format!("/api/v1/devices/{}/push-token", p.id), + b"not json", + ), + ) + .await; + assert_eq!( + error_of(&r), + (StatusCode::BAD_REQUEST, "INVALID_REQUEST".into()) + ); + // a bad status filter + let r = send( + &h.app, + p.request("GET", "/api/v1/mfa/challenges?status=approved", b""), + ) + .await; + assert_eq!( + error_of(&r), + (StatusCode::BAD_REQUEST, "INVALID_REQUEST".into()) + ); + let r = send(&h.app, p.request("GET", "/api/v1/mfa/challenges?x=1", b"")).await; + assert_eq!( + error_of(&r), + (StatusCode::BAD_REQUEST, "INVALID_REQUEST".into()) + ); +} + +#[tokio::test] +async fn responses_contain_no_candidate_marker_and_no_internal_ids_of_other_users() { + let h = harness().await; + let anna = enroll(&h, "anna").await; + let ben = enroll(&h, "ben").await; + let (a_id, _) = challenge(&h, &anna, "la").await; + let (b_id, _) = challenge(&h, &ben, "lb").await; + + let d = details(&h, &anna, &a_id).await; + let mut keys: Vec<&str> = d + .as_object() + .expect("object") + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + let spec = spec(); + let mut allowed: Vec<&str> = spec["components"]["schemas"]["ChallengeDetails"]["properties"] + .as_object() + .expect("props") + .keys() + .map(String::as_str) + .collect(); + allowed.sort_unstable(); + assert!( + keys.iter().all(|k| allowed.contains(k)), + "{keys:?} vs {allowed:?}" + ); + assert!(d["candidate_numbers"].is_array()); + + // another user's challenge looks exactly like one that does not exist + let other = send( + &h.app, + anna.request("GET", &format!("/api/v1/mfa/challenges/{b_id}"), b""), + ) + .await; + let none = send( + &h.app, + anna.request("GET", "/api/v1/mfa/challenges/ch_doesnotexist", b""), + ) + .await; + assert_eq!(other.status, StatusCode::NOT_FOUND); + assert_eq!((other.status, &other.body), (none.status, &none.body)); + // no list, device list or audit view of anna names ben's ids + let mut text = String::new(); + for path in ["/api/v1/mfa/challenges", "/api/v1/devices", "/api/v1/audit"] { + let r = send(&h.app, anna.request("GET", path, b"")).await; + text.push_str(&String::from_utf8_lossy(&r.raw)); + } + for secret in [b_id.as_str(), ben.id.as_str()] { + assert!(!text.contains(secret), "anna's responses mention {secret}"); + } + // ben cannot revoke or change anna's phone + let r = send( + &h.app, + ben.request("DELETE", &format!("/api/v1/devices/{}", anna.id), b""), + ) + .await; + assert_eq!( + error_of(&r), + (StatusCode::NOT_FOUND, "RESOURCE_NOT_FOUND".into()) + ); + let body = json!({ "push_channel": "fcm", "push_token": "evil" }).to_string(); + let r = send( + &h.app, + ben.request( + "PUT", + &format!("/api/v1/devices/{}/push-token", anna.id), + body.as_bytes(), + ), + ) + .await; + assert_eq!( + error_of(&r), + (StatusCode::FORBIDDEN, "DEVICE_NOT_AUTHORIZED".into()) + ); +} + +#[tokio::test] +async fn every_response_is_marked_not_cacheable() { + let h = harness().await; + let p = enroll(&h, "anna").await; + for req in [ + p.request("GET", "/api/v1/devices", b""), + p.request("GET", "/api/v1/mfa/challenges/ch_none", b""), + Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .body(Body::from("{}")) + .expect("req"), + ] { + let r = send(&h.app, req).await; + assert_eq!( + r.headers.get("cache-control").and_then(|v| v.to_str().ok()), + Some("no-store") + ); + } +} + +#[tokio::test] +async fn error_bodies_never_echo_the_request() { + let h = harness().await; + let p = enroll(&h, "anna").await; + let body = + json!({ "push_channel": "fcm", "push_token": "super-secret-push-token-\n" }).to_string(); + let r = send( + &h.app, + p.request( + "PUT", + &format!("/api/v1/devices/{}/push-token", p.id), + body.as_bytes(), + ), + ) + .await; + assert_eq!(r.status, StatusCode::BAD_REQUEST); + let text = String::from_utf8_lossy(&r.raw); + assert!(!text.contains("super-secret"), "{text}"); +} + +#[tokio::test] +async fn deny_with_another_device_id_in_the_body_is_refused() { + let h = harness().await; + let p = enroll(&h, "anna").await; + let (id, _) = challenge(&h, &p, "l").await; + let d = details(&h, &p, &id).await; + let body = json!({ "device_id": "dev_other", "signature": p.answer(&d) }).to_string(); + let r = send( + &h.app, + p.request( + "POST", + &format!("/api/v1/mfa/challenges/{id}/deny"), + body.as_bytes(), + ), + ) + .await; + assert_eq!( + error_of(&r), + (StatusCode::FORBIDDEN, "DEVICE_NOT_AUTHORIZED".into()) + ); + assert_eq!( + h.st.mfa_challenge_status(&id).await.expect("s"), + rostdav_app::ChallengeStatus::Pending + ); +} + +#[tokio::test] +async fn the_device_list_tells_what_the_attestation_proved() { + let h = harness().await; + let p = enroll(&h, "anna").await; + let r = send(&h.app, p.request("GET", "/api/v1/devices", b"")).await; + let d = &r.body["devices"][0]; + // the test chain says TEE, not StrongBox + assert_eq!( + (d["hardware_backed"].clone(), d["strongbox"].clone()), + (json!(true), json!(false)) + ); + assert_eq!(d["device_name"], "Pixel 8"); + assert_eq!(d["push_channel"], "fcm"); + assert_eq!(d["status"], "active"); +} + +#[tokio::test] +async fn register_with_an_unknown_field_is_rejected_and_keeps_the_token() { + let h = harness().await; + let principal = testkit::add_user(&h.st, "anna").await; + let token = + h.st.mfa_create_enroll_token(&principal) + .await + .expect("t") + .token; + let f = testkit::fixture(Opts { + challenge: sha256(token.as_bytes()), + ..live() + }); + h.st.set_attestation_roots_for_tests(vec![f.root.clone()]); + let mut body = json!({ + "enroll_token": token, "device_name": "P", + "request_public_key": testkit::spki_b64(&testkit::fixed_key()), + "approval_public_key": f.approval_spki, "key_algorithm": "ES256", + "attestation_chain": f.chain, "push_channel": "fcm", "push_token": "t", + }); + let post = |b: &Value| { + Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .body(Body::from(b.to_string())) + .expect("req") + }; + body["extra"] = json!(1); + let r = send(&h.app, post(&body)).await; + assert_eq!( + error_of(&r), + (StatusCode::BAD_REQUEST, "INVALID_REQUEST".into()) + ); + // the request never reached the service: the token is still good + body.as_object_mut().expect("object").remove("extra"); + let r = send(&h.app, post(&body)).await; + assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); +} diff --git a/crates/rostdav/Cargo.toml b/crates/rostdav/Cargo.toml index c13b8de..bc2e55c 100644 --- a/crates/rostdav/Cargo.toml +++ b/crates/rostdav/Cargo.toml @@ -24,6 +24,7 @@ rostdav-app.workspace = true rostdav-dav.workspace = true rostdav-compat.workspace = true rostdav-web.workspace = true +rostdav-mfa-api.workspace = true anyhow.workspace = true axum.workspace = true clap.workspace = true diff --git a/crates/rostdav/src/http.rs b/crates/rostdav/src/http.rs index 4af5325..970d2e6 100644 --- a/crates/rostdav/src/http.rs +++ b/crates/rostdav/src/http.rs @@ -95,6 +95,8 @@ pub fn router_with(state: AppState, opts: &HttpOptions) -> Router { .merge(rostdav_dav::router()) .merge(rostdav_compat::router(compat)) .merge(rostdav_web::router()) + // phones sign their requests; no session cookie, no CSRF token (ADR 0014) + .merge(rostdav_mfa_api::router()) .layer(middleware) .layer(axum::middleware::from_fn_with_state( state.clone(), diff --git a/crates/rostdav/tests/mfa_api.rs b/crates/rostdav/tests/mfa_api.rs new file mode 100644 index 0000000..9faf7cb --- /dev/null +++ b/crates/rostdav/tests/mfa_api.rs @@ -0,0 +1,69 @@ +//! The RustMFA device API is mounted in the server and is not behind the web session or the +//! CSRF check (phones have no cookies); it answers in the contract's error format. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use rostdav::config::Config; +use tower::ServiceExt; + +async fn app() -> (axum::Router, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let mut cfg = Config::default(); + cfg.storage.data_dir = dir.path().join("data"); + let state = rostdav::build_state(&cfg).await.expect("state"); + (rostdav::http::router(state), dir) +} + +async fn call( + app: &axum::Router, + req: Request, +) -> (StatusCode, serde_json::Value, axum::http::HeaderMap) { + let res = app.clone().oneshot(req).await.expect("response"); + let (parts, body) = res.into_parts(); + let bytes = body.collect().await.expect("body").to_bytes(); + ( + parts.status, + serde_json::from_slice(&bytes).unwrap_or_default(), + parts.headers, + ) +} + +#[tokio::test] +async fn device_routes_are_mounted_and_ask_for_a_signature() { + let (app, _d) = app().await; + for (method, path) in [ + ("GET", "/api/v1/devices"), + ("GET", "/api/v1/mfa/challenges"), + ("GET", "/api/v1/mfa/challenges/ch_x"), + ("GET", "/api/v1/audit"), + ("DELETE", "/api/v1/devices/dev_x"), + ] { + let req = Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("req"); + let (status, body, headers) = call(&app, req).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{method} {path}"); + assert_eq!(body["error"]["code"], "UNAUTHENTICATED", "{method} {path}"); + // the server-wide headers apply too + assert!(headers.contains_key("x-request-id")); + assert_eq!(headers["cache-control"], "no-store"); + } +} + +#[tokio::test] +async fn registration_is_not_blocked_by_the_web_csrf_check() { + let (app, _d) = app().await; + let req = Request::builder() + .method("POST") + .uri("/api/v1/devices/register") + .header("content-type", "application/json") + .body(Body::from("{}")) + .expect("req"); + let (status, body, _) = call(&app, req).await; + // refused for its content, not for a missing CSRF token or session + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "INVALID_REQUEST"); +} diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index 3cbde92..81e1447 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -46,6 +46,7 @@ Migration `0013_push_mfa.sql` with four `STRICT` tables, a plain index on every - HTTP API (10 device operations under `/api/v1`) in a new protocol crate `rostdav-mfa-api` that depends on `rostdav-app` and `rostdav-core` only. `architecture.md` gets the new row. - The eleventh contract operation, the enroll-token route, lives in `rostdav-web` because it needs the session and CSRF protection. - `PushChannel` trait in `rostdav-app`; delivery adapters follow in the second step. + **Implementation notes 2026-11-09:** `rostdav-mfa-api` exposes `router() -> Router` (merged into the server's router like the other protocol crates; the paths are complete so the signed path is the received one). It reads at most 64 KiB (413 `INVALID_REQUEST` beyond that; the contract has no 413), refuses unknown JSON fields, and sets `cache-control: no-store`. It has no dependency on `rostdav-meta`. Its integration tests use a `test-util` feature of `rostdav-app` (the `testkit` module: synthetic attestation chains, a fresh state); production builds do not enable it. The tests check responses against a JSON mirror of the OpenAPI file kept in the `rostdav-mfa` repository (a contract test keeps the mirror equal to the YAML), so Rostdav needs no YAML crate. Listing challenges supports `status=pending` only. ### Dependencies (justification) diff --git a/docs/api.md b/docs/api.md index 2f19132..4e2e303 100644 --- a/docs/api.md +++ b/docs/api.md @@ -35,6 +35,49 @@ Causes are only logged (with request ID), never returned. | `x-content-type-options` | `nosniff` | | `referrer-policy` | `no-referrer` | +## RustMFA device API + +Used by the RustMFA Android app ([ADR 0014](adr/0014-push-mfa.md)). The machine-readable contract is `rustmfa-v1.yaml` in the `rostdav-mfa` repository; its JSON mirror is what the tests read. All paths are under `/api/v1`. Bodies are JSON, at most 64 KiB, and fields the contract does not name are refused. Every response has `cache-control: no-store`. + +**Signing.** Every route except registration needs + +```http +Authorization: RustMFA-Sig device_id="…",ts="…",nonce="…",sig="…" +``` + +`ts` is Unix seconds (at most 60 seconds from the server clock). A `nonce` is accepted once per device in 5 minutes. `sig` is the raw 64-byte R‖S ES256 signature, base64url without padding, made with the phone's request key over `METHOD \n path-and-query \n ts \n nonce \n base64url(sha256(body))`. The path and query are the ones the phone sent, as received. Exactly one `Authorization` header counts. Five failures for one device in 15 minutes block it for 15 minutes. + +| Route | Needs | Success | Purpose | +|---|---|---|---| +| `POST /devices/register` | enrollment token in the body | `201` `{device_id, status}` | Register a phone with its two public keys and key attestation | +| `GET /devices` | signature | `200` `{devices}` | The caller's phones | +| `DELETE /devices/{device_id}` | signature | `204` | Revoke a phone of the caller's account (also ends its open approvals) | +| `PUT /devices/{device_id}/push-token` | signature, own device | `200` `{device_id, status}` | Change push channel and token | +| `POST /devices/{device_id}/rotate-key` | signature, own device | `200` `{device_id, status}` | Replace both keys (needs a fresh attestation) | +| `GET /mfa/challenges?status=pending` | signature | `200` `{challenges}` | Open approvals of the phone; only `pending` can be listed | +| `GET /mfa/challenges/{challenge_id}` | signature | `200` | Details of one approval, with three candidate numbers and no marker of the right one | +| `POST /mfa/challenges/{challenge_id}/approve` | signature, approval-key signature in the body | `200` `{challenge_id, status}` | Approve with the picked number | +| `POST /mfa/challenges/{challenge_id}/deny` | signature, approval-key signature in the body | `200` `{challenge_id, status}` | Deny | +| `GET /audit` | signature | `200` `{events}` | The user's own security events | + +Creating an enrollment token is a web-session route and not part of this API yet. Creating a challenge is not an HTTP operation: the sign-in code of the server does it. + +**Errors.** `{"error":{"code","message"}}`; messages never hold keys, tokens or signatures. + +| Status | Code | When | +|---|---|---| +| 400 | `INVALID_REQUEST` | Malformed body, unknown field, bad query, bad key or signature encoding, failed attestation | +| 401 | `UNAUTHENTICATED` | Missing, malformed or wrong signature, stale timestamp, replayed nonce, unknown phone, disabled account | +| 403 | `DEVICE_NOT_AUTHORIZED` | The approval signature does not verify, or the request names another phone | +| 403 | `DEVICE_REVOKED` | The phone was revoked (shown only to a caller whose request signature verified) | +| 404 | `RESOURCE_NOT_FOUND` | Unknown challenge or phone, or one that belongs to another user (the same answer) | +| 409 | `CHALLENGE_ALREADY_CONSUMED` | The approval was already answered | +| 409 | `CHALLENGE_NUMBER_MISMATCH` | Wrong number; the approval is now denied | +| 410 | `CHALLENGE_EXPIRED` | The time ran out | +| 413 | `INVALID_REQUEST` | Body over 64 KiB | +| 429 | `RATE_LIMITED` | Too many attempts; `Retry-After` gives the seconds | +| 500 | `INTERNAL_ERROR` | Cause only in the log | + ## WebDAV Follows in M2. diff --git a/docs/architecture.md b/docs/architecture.md index 6ae6f68..388be9f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,7 +21,8 @@ flowchart TB HTTP --> DAV[dav: WebDAV adapter] HTTP --> COMPAT[compat: status.php, OCS, Login Flow v2] HTTP --> WEB[web: HTML interface] - DAV & COMPAT & WEB --> APP + HTTP --> MFAAPI[mfa-api: RustMFA phone API] + DAV & COMPAT & WEB & MFAAPI --> APP subgraph APP[app: services] ID[Identity] @@ -49,6 +50,7 @@ flowchart TB | `rostdav-dav` | WebDAV methods, PROPFIND/PROPPATCH XML, Nextcloud properties, chunked upload | core, app | | `rostdav-compat` | `status.php`, capabilities, OCS endpoints (user, sharing), Login Flow v2 | core, app | | `rostdav-web` | HTML pages (askama), sessions, CSRF, static assets | core, app | +| `rostdav-mfa-api` | HTTP API of the RustMFA app: phone registration, sign-in approval, device list ([ADR 0014](adr/0014-push-mfa.md)) | core, app | | `rostdav` (binary) | Configuration, wiring, router, CLI (`serve`, `admin …`) | all | Rule: protocol crates know neither the database nor the file system. Only `rostdav-app` coordinates `meta` and `storage`. @@ -64,6 +66,7 @@ Tower layers in this order: - Basic Auth with **app password** → `Principal { user, via: AppPassword(id) }` - Basic Auth with **account password** → only allowed if neither 2FA is active nor "WebDAV with app passwords only" is set - Session cookie → only for `rostdav-web` + - Signed device request (`Authorization: RustMFA-Sig …`) → only for `rostdav-mfa-api`; no cookie, no CSRF token - Public link token → restricted principal for the share 5. **Rate limiting** – per IP and per username on all sign-in paths, including login flow polling and 2FA codes. 6. **CSRF** – only for state-changing web routes. From c92be81e67723d9506877c233047fee4b837e953 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 20:42:54 +0100 Subject: [PATCH 11/24] deps: update zstd to 0.13.4 and zstd-safe to 7.4.0 Fixes RUSTSEC-2026-0391, RUSTSEC-2026-0398 and RUSTSEC-2026-0403 reported by cargo deny. --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 04d221b..457f9e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3981,7 +3981,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", @@ -5211,18 +5211,18 @@ dependencies = [ [[package]] name = "zstd" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "cdb02c3132d251ab1606e9fc913a7da6c6ed08cc263df15edf082e1dc497d157" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.3.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +checksum = "1c8ea04668f5de8d8d51051714ba560c9536519cadcdb8829bba4fe2ad159b03" dependencies = [ "zstd-sys", ] From e9aff3f0bb21cf4c96b1e910d7b7901e507ea8b6 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 20:42:54 +0100 Subject: [PATCH 12/24] ci: tolerate litmus put_no_parent deviation PUT into a missing parent answers 404 like Nextcloud; litmus expects 409. Any other litmus failure still fails the script. --- docs/compatibility.md | 2 +- scripts/litmus.sh | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index 43d4bb3..dd16a38 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -64,7 +64,7 @@ Each line cites its fixture below `tests/fixtures/nextcloud/`. "Differs" marks w - PROPFIND: `oc:id` / `OC-FileId` = 8-digit zero-padded numeric ID + instance ID (`00000078ocb0ngxhatbv`), `oc:fileid` = plain number, `oc:permissions` e.g. `RGDNVCK` (dir) / `RGDNVW` (file), ETag on files is an MD5-like string, on folders a short hex string (`04-webdav/16`). Properties the server does not have come back in a 404 `propstat`. - `PROPFIND Depth: infinity` → 207 without entries (`04-webdav/04`). Differs: Rostdav answers 403 `propfind-finite-depth`. - `PUT`: 201 new / 204 replace; response headers `OC-FileId`, `ETag`, `OC-ETag`, `X-OC-MTime: accepted` (`04-webdav/10`, `11`). Differs: Rostdav sets only `ETag`. -- `PUT` into a missing parent → **404** (`04-webdav/12`); `MKCOL` into a missing parent → 409 (`04-webdav/09`). Differs: Rostdav answers 409 for both. +- `PUT` into a missing parent → **404** (`04-webdav/12`); `MKCOL` into a missing parent → 409 (`04-webdav/09`). Rostdav matches; litmus `put_no_parent` expects 409 and is tolerated in `scripts/litmus.sh`. - `MKCOL` on an existing name → 405 (`04-webdav/08`); `If-None-Match: *` on an existing file → 412 (`04-webdav/13`); stale `If-Match` → 412 (`04-webdav/14`); stale `If-Match` on DELETE → 412 (`04-webdav/27`). - `GET` on a directory → **200** `text/html` (`04-webdav/21`). Differs: Rostdav answers 405. - `MOVE` with `Overwrite: F` onto an existing target → 412 (`04-webdav/24`). A `Destination` with a foreign host is **accepted** (path only is used, 201) (`04-webdav/25`). Differs: Rostdav answers 502. diff --git a/scripts/litmus.sh b/scripts/litmus.sh index 1a73f3f..fef8c37 100755 --- a/scripts/litmus.sh +++ b/scripts/litmus.sh @@ -21,4 +21,17 @@ for _ in $(seq 50); do sleep 0.2 done -TESTS="basic copymove" litmus "http://127.0.0.1:$PORT/remote.php/dav/files/litmus/" litmus "$PASSWORD" +# Known deviation: PUT into a missing parent answers 404 like Nextcloud +# (docs/compatibility.md), litmus `put_no_parent` wants 409. Any other failure +# is an error. +OUT="$DIR/litmus.out" +set +e +TESTS="basic copymove" litmus "http://127.0.0.1:$PORT/remote.php/dav/files/litmus/" litmus "$PASSWORD" 2>&1 | tee "$OUT" +STATUS=${PIPESTATUS[0]} +set -e +[ "$STATUS" -eq 0 ] && exit 0 +if [ "$(grep -c ' FAIL' "$OUT")" -eq 1 ] && grep -q 'put_no_parent\.* FAIL' "$OUT"; then + echo "litmus: only the known put_no_parent deviation failed" + exit 0 +fi +exit "$STATUS" From f641103469fb84fdb5c82819efba3dd9baaeaed1 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 20:49:10 +0100 Subject: [PATCH 13/24] test(mfa): allow one second of clock skew in the lifetime test expires_at comes from the caller's clock and created_at from the database's, so the span can be 29 or 30 seconds at a second boundary. --- crates/rostdav-app/src/push_mfa/challenges_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/rostdav-app/src/push_mfa/challenges_tests.rs b/crates/rostdav-app/src/push_mfa/challenges_tests.rs index edaed1a..bf104ca 100644 --- a/crates/rostdav-app/src/push_mfa/challenges_tests.rs +++ b/crates/rostdav-app/src/push_mfa/challenges_tests.rs @@ -853,7 +853,10 @@ async fn the_lifetime_follows_the_configuration() { let c = create(&st, &p, "l").await; assert_eq!(c.expires_in_seconds, 30); let r = row(&st, &c.challenge_id).await; - assert_eq!(r.expires_at - r.created_at, 30); + // `expires_at` comes from the caller's clock and `created_at` from the database's: they can + // straddle a second boundary, so allow one second. + let span = r.expires_at - r.created_at; + assert!((29..=30).contains(&span), "lifetime span {span}"); } #[tokio::test] From 048be578909834f086cee30affc95d1a9c1a6e7b Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 20:49:10 +0100 Subject: [PATCH 14/24] docs(mfa): changelog, security, admin, state Threat rows S4 and S5, admin note on what is built, M14 state in plan and CLAUDE.md, implementation notes in ADR 0014. --- CLAUDE.md | 1 + docs/admin.md | 2 +- docs/adr/0014-push-mfa.md | 8 ++++++++ docs/plan.md | 2 +- docs/security.md | 2 ++ 5 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 31769d3..b3f73a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ The binding working rules are in [AGENTS.md](AGENTS.md). The full project instru - M2 (WebDAV core) complete. M3: server side recorded (Nextcloud 35.0.2, synthetic requests), real-app capture open. M4 (sign-in) implemented; ADR 0003 (app passwords, Login Flow v2, rate limiting) is accepted (2026-10-23). M5 (Nextcloud WebDAV extensions, chunked upload v2) implemented. M7 (admin CLI, event log, fsck, import) implemented. M6 (real app on the Android emulator) is postponed to the end by the project owner. M8 (versioning), M9 (shares, ADR 0004 accepted) and M10 (trash, ADR 0005) implemented. M11 (web interface, ADR 0006 accepted): slice 11a implemented (sign-in, Files, Trash, Devices, Settings, public link page). 11b implemented (share dialog, versions panel, details, folder filter). Chunked browser upload done (JS not yet tested in a real browser). 11c implemented (administration pages). 11d done (profile, change password, data export, German/English). M11 complete. M12 (two-factor, ADR 0007 accepted) implemented. M13 (retention overview, `default_quota`, `upload_retention_hours`, streamed data export) done. Phase 5 done except coverage-guided fuzzing (authorization matrix, robustness tests, load tests, container image + Compose, backup/restore, operations docs). M6 complete for now (2026-10-23): verified with the real Android app (sign-in incl. 2FA, files, favorites, shares, public link, versions, trash, offline) and the desktop sync engine (`nextcloudcmd`); not tried: desktop GUI, iOS client. ADR 0003 accepted; cargo-fuzz targets in `fuzz/`. Web interface gaps from the mockups closed (maintenance mode, lockouts, shares, recent, sortable columns); left out: avatar upload, editable admin limits. - About page, release notes, opt-in version check and the updater (ADR 0009) are built. The updater needs the maintainer's minisign key (`CONTRIBUTING.md`, "Release signing key"): until `crates/rostdav-app/update-key.pub` holds a key, it reports "no update signing key". The signing step of the release workflow has not run yet. Documentation site in `site/` (GitHub Pages, `just site`). +- M14 (push sign-in approval with the RustMFA app, ADR 0014 accepted 2026-11-08): backend implemented on branch `feat/push-mfa` (`[mfa]` settings, migration 0013, attestation, enrollment, request authentication, challenges, device management, HTTP API in `rostdav-mfa-api`). Open: sign-in page integration, enroll-token web route, FCM adapter, web device pages (Plan 2b) and the Android app (Plan 3). `rustmfa-contract` is a temporary path dependency on `../rostdav-mfa`; it must become a pinned git dependency before the branch merges. ## Releases diff --git a/docs/admin.md b/docs/admin.md index 19279f1..16e2c05 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -131,7 +131,7 @@ Users turn on TOTP (authenticator app) themselves under Devices & security; see ## Push sign-in approval (RustMFA) -Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa.md)). The feature is being built; so far only the settings exist. +Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa.md)). The server side of the phone API is implemented (`/api/v1/devices/…`, `/api/v1/mfa/challenges/…`). Creating an enrollment token in the web interface, the sign-in page step and push delivery (FCM) are not built yet, so no user can enroll a phone through the web interface so far. | Key | Default | Meaning | |---|---|---| diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index 81e1447..62c4e3d 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -68,6 +68,14 @@ Migration `0013_push_mfa.sql` with four `STRICT` tables, a plain index on every - Attestation of key rotation beyond the rule below, remote attestation of boot state (`verifiedBootState`). - Key rotation attestation: the rotation request carries a fresh attestation chain whose challenge is the SHA-256 of `"rotate:" + device_id + ":" + request_pub`. +## Implementation notes (backend, 2026-11-09) + +- The device API lives in the new crate `rostdav-mfa-api` and is merged into the main router. Challenge creation is an in-process call, not an HTTP route. +- Approve and deny verify the approval-key signature before the number check, so the non-biometric request key cannot end a challenge. +- A revoked phone gets `DEVICE_REVOKED` only after its signature verified. Revoking wins races with an approval in flight. +- Bodies over 64 KiB are refused with 413. The challenge list supports `status=pending` only. +- Device ids are `dev_` plus a random token, challenge ids `ch_` plus a random token. + ## Consequences - Sign-in is the most security-sensitive code. Acceptance criteria are tests for: concurrent approve (one winner), nonce replay and window, wrong device and wrong user, revoked device with a pending challenge, enroll token reuse/expiry/other user, software-only and wrong-challenge attestation, DER signature rejected, altered signed fields rejected, error table, no secrets in logs. diff --git a/docs/plan.md b/docs/plan.md index c5383c6..4c33459 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -71,7 +71,7 @@ Prioritized by smallest testable benefit. Every milestone ends with green tests | M11 | Web interface: files, devices, settings, administration, light/dark ([ADR 0006](adr/0006-web-interface.md)). **11a ✅** (sign-in, Files, Trash, Devices, Settings, public link page); **11b (share dialog, versions panel, details, folder filter, chunked browser upload) ✅**; **11c administration (users, event log, status/fsck, server settings) ✅**, **11d: profile, change password, data export, languages (de/en) ✅** | M7–M10 | | M12 ✅ | Optional two-factor authentication (TOTP + recovery codes; [ADR 0007](adr/0007-two-factor-authentication.md), 2026-10-22) | M11 | | M13 ✅ | Retention rules, data export (2026-10-22; the export is streamed as a tar archive instead of a background job with a download link, see [ui.md](ui.md)) | M8, M10 | -| M14 | Push sign-in approval with the RustMFA app: device enrollment, signed challenges, device API ([ADR 0014](adr/0014-push-mfa.md), accepted 2026-11-08, approval point 3 granted) | M12 | +| M14 (backend ✅, 2026-11-09; sign-in page, FCM, web device pages, Android app open) | Push sign-in approval with the RustMFA app: device enrollment, signed challenges, device API ([ADR 0014](adr/0014-push-mfa.md), accepted 2026-11-08, approval point 3 granted) | M12 | ## Phase 5: Hardening and delivery diff --git a/docs/security.md b/docs/security.md index 8adb06a..a217239 100644 --- a/docs/security.md +++ b/docs/security.md @@ -31,6 +31,8 @@ Binding requirements: section 5 of the [instructions](instructions.md). Short ve | D2 | Orphaned uploads fill the disk | Expiry time, cleanup job | open (M5) | | E1 | User calls an admin function | Role check in the service, not only in the UI | open (M7) | | E2 | XSS via file names | Automatic escaping (askama), strict CSP | open (M11) | +| S4 | Approval of a sign-in by an attacker (prompt bombing, a tap without looking, a stolen enrollment QR code) | Push is only a hint: the phone fetches the challenge from the server and shows the sign-in device; the user must pick the two-digit number shown in the browser (number matching), a wrong number denies the challenge. The answer is signed with a biometric-gated key; the server checks that signature before the number. At most five challenges per user in 15 minutes. Enrollment tokens are single use, short lived and stored only as a hash; the phone proves a hardware-backed key (attestation `required` by default, `off` is an admin decision with a stated trade-off). | M14 backend implemented, sign-in page open (ADR 0014) | +| S5 | Replay or forgery of phone requests | Every request carries a timestamp (60 seconds), a nonce (used once in 5 minutes) and a signature by the phone's request key over method, path, body hash and audience; DER signatures are rejected; five failures per phone in 15 minutes block it for 15 minutes; revoked phones and disabled accounts are refused. | M14 backend implemented | ## Cryptography From 2fbb20f4b452ea8c4b7997553629e863d73f7736 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 20:56:43 +0100 Subject: [PATCH 15/24] feat(mfa): an enrolled phone is a second factor Sign-in decisions (browser, Login Flow v2, WebDAV/OCS account password) now ask has_second_factor: TOTP on or at least one active phone. --- CHANGELOG.md | 4 + crates/rostdav-app/src/auth.rs | 2 +- crates/rostdav-app/src/login_flow.rs | 2 +- .../rostdav-app/src/push_mfa/factor_tests.rs | 84 +++++++++++++++++++ crates/rostdav-app/src/push_mfa/mod.rs | 9 ++ crates/rostdav-app/src/web.rs | 2 +- crates/rostdav-meta/src/push_mfa.rs | 12 +++ docs/admin.md | 2 +- docs/adr/0014-push-mfa.md | 2 + 9 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 crates/rostdav-app/src/push_mfa/factor_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bb62bdc..64cfbc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes are listed here. The format follows [Keep a Changelog](https ## [Unreleased] +### Changed + +- **An enrolled RustMFA phone counts as a second factor**, like an authenticator app (ADR 0014). Once a user has an active phone, WebDAV and OCS no longer accept their account password (app passwords still work) and the browser sign-in asks for a second step. Until the sign-in page work lands, a user with only a phone cannot finish a browser sign-in. + ### Added - **Settings for push sign-in approval** (`[mfa]` section: challenge lifetime, enrollment lifetime, audience, attestation policy), the first step of ADR 0014. They have no effect yet. diff --git a/crates/rostdav-app/src/auth.rs b/crates/rostdav-app/src/auth.rs index e69ccf2..fb9dffe 100644 --- a/crates/rostdav-app/src/auth.rs +++ b/crates/rostdav-app/src/auth.rs @@ -171,7 +171,7 @@ impl AppState { .await; Err(Error::Unauthenticated) } - Some(u) if ok && allow_app_password && self.inner.db.totp_enabled(u.id).await? => { + Some(u) if ok && allow_app_password && self.has_second_factor(u.id).await? => { // Two-factor is on: WebDAV and OCS take app passwords only (ADR 0007). // The password was right, so the address is not punished. self.audit( diff --git a/crates/rostdav-app/src/login_flow.rs b/crates/rostdav-app/src/login_flow.rs index 4e6aa7a..e280c71 100644 --- a/crates/rostdav-app/src/login_flow.rs +++ b/crates/rostdav-app/src/login_flow.rs @@ -115,7 +115,7 @@ impl AppState { .await? .ok_or(Error::NotFound)?; let p = self.authenticate_with(user, password, ip, false).await?; - if self.inner.db.totp_enabled(p.user_id).await? { + if self.has_second_factor(p.user_id).await? { return Ok(FlowStep::SecondFactor { ticket: self.second_factor_ticket(p.user_id, login_token), }); diff --git a/crates/rostdav-app/src/push_mfa/factor_tests.rs b/crates/rostdav-app/src/push_mfa/factor_tests.rs new file mode 100644 index 0000000..7e91429 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/factor_tests.rs @@ -0,0 +1,84 @@ +//! A phone is a second factor next to TOTP (ADR 0014). + +use std::net::{IpAddr, Ipv4Addr}; + +use crate::SignIn; +use crate::push_mfa::testkit::{add_user, enrolled, state}; + +const IP: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); +const PW: &str = "correct-horse-battery"; + +#[tokio::test] +async fn a_user_with_only_a_phone_has_a_second_factor() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + assert!(st.has_second_factor(p.user_id).await.expect("q")); +} + +#[tokio::test] +async fn a_user_without_phone_or_totp_has_none() { + let (_d, st) = state().await; + let p = add_user(&st, "ben").await; + assert!(!st.has_second_factor(p.user_id).await.expect("q")); +} + +#[tokio::test] +async fn a_revoked_phone_does_not_count() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + st.mfa_revoke_device(p.user_id, "dev_a", "anna") + .await + .expect("revoke"); + assert!(!st.has_second_factor(p.user_id).await.expect("q")); +} + +#[tokio::test] +async fn a_phone_makes_web_sign_in_ask_for_the_second_step() { + let (_d, st) = state().await; + enrolled(&st, "anna", "dev_a").await; + let r = st.web_sign_in("anna", PW, IP, "ua").await.expect("sign in"); + assert!(matches!(r, SignIn::SecondFactor { .. }), "{r:?}"); +} + +#[tokio::test] +async fn without_a_factor_web_sign_in_opens_the_session() { + let (_d, st) = state().await; + add_user(&st, "ben").await; + let r = st.web_sign_in("ben", PW, IP, "ua").await.expect("sign in"); + assert!(matches!(r, SignIn::Done { .. }), "{r:?}"); +} + +#[tokio::test] +async fn after_the_last_phone_is_revoked_web_sign_in_is_password_only_again() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + st.mfa_revoke_device(p.user_id, "dev_a", "anna") + .await + .expect("revoke"); + let r = st.web_sign_in("anna", PW, IP, "ua").await.expect("sign in"); + assert!(matches!(r, SignIn::Done { .. }), "{r:?}"); +} + +#[tokio::test] +async fn a_phone_makes_webdav_reject_the_account_password_but_not_an_app_password() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let denied = st.authenticate_with("anna", PW, IP, true).await; + assert!( + matches!(denied, Err(rostdav_core::Error::Unauthenticated)), + "{denied:?}" + ); + let app = st + .create_app_password(p.user_id, "dav") + .await + .expect("app pw"); + let ok = st.authenticate_with("anna", &app, IP, true).await; + assert!(ok.is_ok(), "{ok:?}"); +} + +#[tokio::test] +async fn without_a_factor_webdav_takes_the_account_password() { + let (_d, st) = state().await; + add_user(&st, "ben").await; + assert!(st.authenticate_with("ben", PW, IP, true).await.is_ok()); +} diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs index 06787f9..dc79744 100644 --- a/crates/rostdav-app/src/push_mfa/mod.rs +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -14,6 +14,8 @@ mod enroll; #[cfg(test)] mod enroll_tests; mod error; +#[cfg(test)] +mod factor_tests; mod google_roots; mod push; #[cfg(any(test, feature = "test-util"))] @@ -67,6 +69,13 @@ impl AppState { pub fn mfa_cfg(&self) -> MfaConfig { self.cfg().mfa } + + /// Whether sign-in needs a second step for this user: TOTP is on, or at least one phone is + /// active (ADR 0014). Then WebDAV and OCS take app passwords only. + pub async fn has_second_factor(&self, user_id: i64) -> rostdav_core::Result { + Ok(self.inner.db.totp_enabled(user_id).await? + || self.inner.db.mfa_active_device_count(user_id).await? > 0) + } } #[cfg(test)] diff --git a/crates/rostdav-app/src/web.rs b/crates/rostdav-app/src/web.rs index 7d8b4f3..09a490d 100644 --- a/crates/rostdav-app/src/web.rs +++ b/crates/rostdav-app/src/web.rs @@ -76,7 +76,7 @@ impl AppState { user_agent: &str, ) -> Result { let p = self.authenticate_with(user, password, ip, false).await?; - if self.inner.db.totp_enabled(p.user_id).await? { + if self.has_second_factor(p.user_id).await? { return Ok(SignIn::SecondFactor { ticket: self.second_factor_ticket(p.user_id, ""), }); diff --git a/crates/rostdav-meta/src/push_mfa.rs b/crates/rostdav-meta/src/push_mfa.rs index 2ae8a61..0f5e011 100644 --- a/crates/rostdav-meta/src/push_mfa.rs +++ b/crates/rostdav-meta/src/push_mfa.rs @@ -226,6 +226,18 @@ impl Db { Ok(row.map(DeviceRow::from)) } + /// How many active phones a user has. + pub async fn mfa_active_device_count(&self, user_id: i64) -> Result { + let n: (i64,) = sql::query_as( + "SELECT COUNT(*) FROM mfa_devices WHERE user_id = ? AND status = 'active'", + ) + .bind(user_id) + .fetch_one(self.pool()) + .await + .map_err(err)?; + Ok(n.0) + } + pub async fn mfa_devices_of(&self, user_id: i64) -> Result> { let rows: Vec = sql::query_as(format!( "SELECT {DEVICE_COLUMNS} FROM mfa_devices WHERE user_id = ? ORDER BY created_at, id" diff --git a/docs/admin.md b/docs/admin.md index 16e2c05..9b397eb 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -125,7 +125,7 @@ New users get `[limits] default_quota` (default `unlimited`); existing users kee Users turn on TOTP (authenticator app) themselves under Devices & security; see [ADR 0007](adr/0007-two-factor-authentication.md). What an administrator needs to know: - **Key file.** The secrets are encrypted with a key that is *not* in the database: `totp.key` in the data directory (32 random bytes, mode 0600, created when the first user starts a setup). Back it up with the data, but keep it apart from copies of the database if you want a stolen database dump to be useless. Without the key, existing secrets cannot be read: reset 2FA for the affected users. Alternatives: `[security] totp_key_file = "/path"` or `ROSTDAV_SECURITY__TOTP_KEY` (base64 of 32 bytes). -- **WebDAV and OCS.** While 2FA is on for a user, their *account password* no longer works there (401); app passwords do. Tell users who still use the account password in a WebDAV client to create an app password first. +- **WebDAV and OCS.** While 2FA is on for a user, or while they have an enrolled RustMFA phone, their *account password* no longer works there (401); app passwords do. Tell users who still use the account password in a WebDAV client to create an app password first. - **Lockout.** Five wrong codes lock the second step for that user for 15 minutes. A user who lost the authenticator and the recovery codes needs `rostdav admin user reset-2fa ` or Administration › Users › Manage › Reset two-factor authentication. - **Event log:** `auth.2fa_enabled`, `auth.2fa_disabled`, `auth.2fa_codes_regenerated`, `admin.2fa_reset`, failed codes as `auth.login_failed` with `second_factor`. Codes and secrets are never logged. diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index 62c4e3d..655bd1b 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -48,6 +48,8 @@ Migration `0013_push_mfa.sql` with four `STRICT` tables, a plain index on every - `PushChannel` trait in `rostdav-app`; delivery adapters follow in the second step. **Implementation notes 2026-11-09:** `rostdav-mfa-api` exposes `router() -> Router` (merged into the server's router like the other protocol crates; the paths are complete so the signed path is the received one). It reads at most 64 KiB (413 `INVALID_REQUEST` beyond that; the contract has no 413), refuses unknown JSON fields, and sets `cache-control: no-store`. It has no dependency on `rostdav-meta`. Its integration tests use a `test-util` feature of `rostdav-app` (the `testkit` module: synthetic attestation chains, a fresh state); production builds do not enable it. The tests check responses against a JSON mirror of the OpenAPI file kept in the `rostdav-mfa` repository (a contract test keeps the mirror equal to the YAML), so Rostdav needs no YAML crate. Listing challenges supports `status=pending` only. + **Implementation notes 2026-11-10:** `AppState::has_second_factor` (TOTP on or at least one active phone) replaces the TOTP-only check at the three sign-in decisions (browser sign-in, Login Flow v2, WebDAV/OCS password). The TOTP management pages keep asking about TOTP alone. + ### Dependencies (justification) - `rustmfa-contract` (own crate, `rostdav-mfa` repository): signing formats and verification shared with the app and its test vectors. **Decision:** git dependency pinned by revision. Until that repository is published, development uses a local `[patch]` in an uncommitted `.cargo/config.toml`; the dependency is committed only once the repository exists. From ceef962c82303e73eee2a2d381b08aa8d7359e85 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 21:08:07 +0100 Subject: [PATCH 16/24] build(mfa): pin rustmfa-contract by git revision The rostdav-mfa repository is published. The path dependency is gone, cargo-deny allows the one git source, and the HTTP tests read the OpenAPI JSON the crate exports instead of a sibling checkout. --- CLAUDE.md | 2 +- Cargo.lock | 1 + Cargo.toml | 5 ++--- crates/rostdav-mfa-api/tests/flow.rs | 7 +------ deny.toml | 1 + docs/adr/0014-push-mfa.md | 1 + docs/api.md | 2 +- fuzz/Cargo.lock | 1 + 8 files changed, 9 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b3f73a4..92951bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ The binding working rules are in [AGENTS.md](AGENTS.md). The full project instru - M2 (WebDAV core) complete. M3: server side recorded (Nextcloud 35.0.2, synthetic requests), real-app capture open. M4 (sign-in) implemented; ADR 0003 (app passwords, Login Flow v2, rate limiting) is accepted (2026-10-23). M5 (Nextcloud WebDAV extensions, chunked upload v2) implemented. M7 (admin CLI, event log, fsck, import) implemented. M6 (real app on the Android emulator) is postponed to the end by the project owner. M8 (versioning), M9 (shares, ADR 0004 accepted) and M10 (trash, ADR 0005) implemented. M11 (web interface, ADR 0006 accepted): slice 11a implemented (sign-in, Files, Trash, Devices, Settings, public link page). 11b implemented (share dialog, versions panel, details, folder filter). Chunked browser upload done (JS not yet tested in a real browser). 11c implemented (administration pages). 11d done (profile, change password, data export, German/English). M11 complete. M12 (two-factor, ADR 0007 accepted) implemented. M13 (retention overview, `default_quota`, `upload_retention_hours`, streamed data export) done. Phase 5 done except coverage-guided fuzzing (authorization matrix, robustness tests, load tests, container image + Compose, backup/restore, operations docs). M6 complete for now (2026-10-23): verified with the real Android app (sign-in incl. 2FA, files, favorites, shares, public link, versions, trash, offline) and the desktop sync engine (`nextcloudcmd`); not tried: desktop GUI, iOS client. ADR 0003 accepted; cargo-fuzz targets in `fuzz/`. Web interface gaps from the mockups closed (maintenance mode, lockouts, shares, recent, sortable columns); left out: avatar upload, editable admin limits. - About page, release notes, opt-in version check and the updater (ADR 0009) are built. The updater needs the maintainer's minisign key (`CONTRIBUTING.md`, "Release signing key"): until `crates/rostdav-app/update-key.pub` holds a key, it reports "no update signing key". The signing step of the release workflow has not run yet. Documentation site in `site/` (GitHub Pages, `just site`). -- M14 (push sign-in approval with the RustMFA app, ADR 0014 accepted 2026-11-08): backend implemented on branch `feat/push-mfa` (`[mfa]` settings, migration 0013, attestation, enrollment, request authentication, challenges, device management, HTTP API in `rostdav-mfa-api`). Open: sign-in page integration, enroll-token web route, FCM adapter, web device pages (Plan 2b) and the Android app (Plan 3). `rustmfa-contract` is a temporary path dependency on `../rostdav-mfa`; it must become a pinned git dependency before the branch merges. +- M14 (push sign-in approval with the RustMFA app, ADR 0014 accepted 2026-11-08): backend implemented on branch `feat/push-mfa` (`[mfa]` settings, migration 0013, attestation, enrollment, request authentication, challenges, device management, HTTP API in `rostdav-mfa-api`). Open: sign-in page integration, enroll-token web route, FCM adapter, web device pages (Plan 2b) and the Android app (Plan 3). `rustmfa-contract` is a git dependency pinned by revision (repository `pierrejochem/rostdav-mfa`); a contract change needs a commit there and a new pin here. ## Releases diff --git a/Cargo.lock b/Cargo.lock index 457f9e4..74e0a8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3374,6 +3374,7 @@ dependencies = [ [[package]] name = "rustmfa-contract" version = "0.1.0" +source = "git+https://github.com/pierrejochem/rostdav-mfa?rev=9d869bad8f6b971bb6cfe87f869ffe857c435389#9d869bad8f6b971bb6cfe87f869ffe857c435389" dependencies = [ "base64 0.22.1", "p256", diff --git a/Cargo.toml b/Cargo.toml index 14d82ce..6112fd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,9 +61,8 @@ ulid = "3" chacha20poly1305 = "0.11" hmac = "0.13" # Push sign-in approval (ADR 0014) -# TEMPORARY path dependency on the sibling checkout of the rostdav-mfa repository. Replace it with -# a git dependency pinned by revision before this branch is merged (ADR 0014, "Dependencies"). -rustmfa-contract = { path = "../rostdav-mfa/contract/rustmfa-contract" } +# Shared signing formats and test vectors, pinned by revision (ADR 0014, "Dependencies"). +rustmfa-contract = { git = "https://github.com/pierrejochem/rostdav-mfa", rev = "9d869bad8f6b971bb6cfe87f869ffe857c435389" } x509-cert = { version = "0.2", default-features = false } der = { version = "0.7", features = ["alloc", "oid"] } ring = { version = "0.17", default-features = false, features = ["alloc"] } diff --git a/crates/rostdav-mfa-api/tests/flow.rs b/crates/rostdav-mfa-api/tests/flow.rs index e2e50cc..d4ea6b8 100644 --- a/crates/rostdav-mfa-api/tests/flow.rs +++ b/crates/rostdav-mfa-api/tests/flow.rs @@ -31,12 +31,7 @@ fn sha256(data: &[u8]) -> Vec { // ---- the contract as JSON --------------------------------------------------------------------- fn spec() -> Value { - let p = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../../rostdav-mfa/contract/openapi/rustmfa-v1.json" - ); - serde_json::from_str(&std::fs::read_to_string(p).expect("contract JSON mirror missing")) - .expect("contract JSON") + serde_json::from_str(rustmfa_contract::OPENAPI_JSON).expect("contract JSON") } fn resolve<'a>(spec: &'a Value, schema: &'a Value) -> &'a Value { diff --git a/deny.toml b/deny.toml index 92be18b..c837916 100644 --- a/deny.toml +++ b/deny.toml @@ -36,3 +36,4 @@ allow-wildcard-paths = true [sources] unknown-registry = "deny" unknown-git = "deny" +allow-git = ["https://github.com/pierrejochem/rostdav-mfa"] diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index 655bd1b..8cfa8c3 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -54,6 +54,7 @@ Migration `0013_push_mfa.sql` with four `STRICT` tables, a plain index on every - `rustmfa-contract` (own crate, `rostdav-mfa` repository): signing formats and verification shared with the app and its test vectors. **Decision:** git dependency pinned by revision. Until that repository is published, development uses a local `[patch]` in an uncommitted `.cargo/config.toml`; the dependency is committed only once the repository exists. **Amendment 2026-11-08:** the owner chose a temporary path dependency on the sibling checkout (`../rostdav-mfa/contract/rustmfa-contract`, marked in `Cargo.toml`) for the development branch. It must become a git dependency pinned by revision before the branch is merged into `main`; until then the branch builds only next to that checkout. + **Amendment 2026-11-10:** the `rostdav-mfa` repository is published at `https://github.com/pierrejochem/rostdav-mfa` and `Cargo.toml` pins it by revision (the path dependency is gone). `cargo-deny` allows that one git source. The crate exports the OpenAPI JSON mirror (`OPENAPI_JSON`), so the HTTP tests need no sibling checkout. A contract change is a commit there followed by a new revision pin here. - `x509-cert` and `der` (RustCrypto, MIT/Apache): parse the attestation chain and the Android key description. - `ring` (already in the tree through the update check, ISC-style licence the policy allows): verifies the certificate signatures (RSA and ECDSA P-256/P-384). This avoids adding separate RSA and ECDSA crates, and `rcgen` is not needed: the tests build synthetic chains with `x509-cert` and sign them with `ring`. - Android attestation roots are pinned in `push_mfa/google_roots.rs`. Source and check on 2026-11-08: the list at `https://android.googleapis.com/attestation/root` against the certificates on Google's key-attestation documentation page. Pinned are the two that both list: diff --git a/docs/api.md b/docs/api.md index 4e2e303..5477484 100644 --- a/docs/api.md +++ b/docs/api.md @@ -37,7 +37,7 @@ Causes are only logged (with request ID), never returned. ## RustMFA device API -Used by the RustMFA Android app ([ADR 0014](adr/0014-push-mfa.md)). The machine-readable contract is `rustmfa-v1.yaml` in the `rostdav-mfa` repository; its JSON mirror is what the tests read. All paths are under `/api/v1`. Bodies are JSON, at most 64 KiB, and fields the contract does not name are refused. Every response has `cache-control: no-store`. +Used by the RustMFA Android app ([ADR 0014](adr/0014-push-mfa.md)). The machine-readable contract is `rustmfa-v1.yaml` in the `rostdav-mfa` repository; its JSON mirror is exported by the `rustmfa-contract` crate and is what the tests read. All paths are under `/api/v1`. Bodies are JSON, at most 64 KiB, and fields the contract does not name are refused. Every response has `cache-control: no-store`. **Signing.** Every route except registration needs diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 54ee7cf..a87d778 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -3228,6 +3228,7 @@ dependencies = [ [[package]] name = "rustmfa-contract" version = "0.1.0" +source = "git+https://github.com/pierrejochem/rostdav-mfa?rev=9d869bad8f6b971bb6cfe87f869ffe857c435389#9d869bad8f6b971bb6cfe87f869ffe857c435389" dependencies = [ "base64 0.22.1", "p256", From f0ee1f5d0eb881fbaea427e13b7119f5bba44757 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 21:09:51 +0100 Subject: [PATCH 17/24] build(mfa): license the contract, accept RUSTSEC-2026-0387 for it The contract crate got a licence (MIT OR Apache-2.0), so cargo-deny licenses passes. crypto-bigint 0.5 (p256 0.13) has RUSTSEC-2026-0387; the server only verifies public data with it, so the advisory is ignored with the reason in deny.toml. --- Cargo.lock | 2 +- Cargo.toml | 2 +- deny.toml | 5 +++++ fuzz/Cargo.lock | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74e0a8f..0176379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3374,7 +3374,7 @@ dependencies = [ [[package]] name = "rustmfa-contract" version = "0.1.0" -source = "git+https://github.com/pierrejochem/rostdav-mfa?rev=9d869bad8f6b971bb6cfe87f869ffe857c435389#9d869bad8f6b971bb6cfe87f869ffe857c435389" +source = "git+https://github.com/pierrejochem/rostdav-mfa?rev=31e5218c2ad473257ee048691b9017637cbe20c1#31e5218c2ad473257ee048691b9017637cbe20c1" dependencies = [ "base64 0.22.1", "p256", diff --git a/Cargo.toml b/Cargo.toml index 6112fd9..99baa1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,7 @@ chacha20poly1305 = "0.11" hmac = "0.13" # Push sign-in approval (ADR 0014) # Shared signing formats and test vectors, pinned by revision (ADR 0014, "Dependencies"). -rustmfa-contract = { git = "https://github.com/pierrejochem/rostdav-mfa", rev = "9d869bad8f6b971bb6cfe87f869ffe857c435389" } +rustmfa-contract = { git = "https://github.com/pierrejochem/rostdav-mfa", rev = "31e5218c2ad473257ee048691b9017637cbe20c1" } x509-cert = { version = "0.2", default-features = false } der = { version = "0.7", features = ["alloc", "oid"] } ring = { version = "0.17", default-features = false, features = ["alloc"] } diff --git a/deny.toml b/deny.toml index c837916..05571bf 100644 --- a/deny.toml +++ b/deny.toml @@ -9,6 +9,11 @@ ignore = [ # extractor child process, which has a time and memory limit. Look again when pdf_oxide # moves to skrifa. { id = "RUSTSEC-2026-0192", reason = "unmaintained only; used by pdf_oxide in the sandboxed extractor process" }, + # crypto-bigint 0.5 comes with p256 0.13 in rustmfa-contract (ADR 0014). The server only + # verifies ECDSA signatures there: public keys, public signatures and public messages, so + # secret-dependent timing has nothing to leak. The server never signs with p256 (the signing + # code is in the contract's test-vector tool). Look again when p256 0.14 is released. + { id = "RUSTSEC-2026-0387", reason = "crypto-bigint 0.5 via p256 0.13 in rustmfa-contract; verification of public data only" }, ] [licenses] diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index a87d778..af53853 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -3228,7 +3228,7 @@ dependencies = [ [[package]] name = "rustmfa-contract" version = "0.1.0" -source = "git+https://github.com/pierrejochem/rostdav-mfa?rev=9d869bad8f6b971bb6cfe87f869ffe857c435389#9d869bad8f6b971bb6cfe87f869ffe857c435389" +source = "git+https://github.com/pierrejochem/rostdav-mfa?rev=31e5218c2ad473257ee048691b9017637cbe20c1#31e5218c2ad473257ee048691b9017637cbe20c1" dependencies = [ "base64 0.22.1", "p256", From 611e06cd2ef8d74bd209fe013188014f1ac2905e Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 21:17:39 +0100 Subject: [PATCH 18/24] feat(mfa): bind a challenge to one sign-in and release it once A challenge started for a sign-in carries a keyed hash of the ticket and flow. Only the ticket holder can poll it or turn it into a session, once; a phone revoked after it approved releases nothing (migration 0013 gets released_at). --- CHANGELOG.md | 1 + crates/rostdav-app/src/lib.rs | 2 +- crates/rostdav-app/src/push_mfa/login.rs | 139 ++++++++ .../rostdav-app/src/push_mfa/login_tests.rs | 299 ++++++++++++++++++ crates/rostdav-app/src/push_mfa/mod.rs | 4 + .../rostdav-meta/migrations/0013_push_mfa.sql | 6 +- crates/rostdav-meta/src/push_mfa.rs | 18 ++ docs/adr/0014-push-mfa.md | 2 +- docs/architecture.md | 2 +- 9 files changed, 468 insertions(+), 5 deletions(-) create mode 100644 crates/rostdav-app/src/push_mfa/login.rs create mode 100644 crates/rostdav-app/src/push_mfa/login_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 64cfbc4..f3b989a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https ### Added +- **A sign-in approval belongs to one sign-in**: a challenge started for a sign-in can only be polled and turned into a session by the browser that passed the password step, and only once; a phone revoked after it approved releases nothing. Not reachable from the sign-in page yet. - **Settings for push sign-in approval** (`[mfa]` section: challenge lifetime, enrollment lifetime, audience, attestation policy), the first step of ADR 0014. They have no effect yet. - **Database tables for push sign-in approval** (migration 0013: phones, enrollment tokens, challenges, request nonces). Nothing uses them yet. - **Key attestation check for push sign-in approval phones**: verifies that a phone's key lives in secure hardware (certificate chain to a pinned Google root, hardware security level, challenge, key match). Nothing calls it yet. diff --git a/crates/rostdav-app/src/lib.rs b/crates/rostdav-app/src/lib.rs index 4100750..2ecb1c0 100644 --- a/crates/rostdav-app/src/lib.rs +++ b/crates/rostdav-app/src/lib.rs @@ -60,7 +60,7 @@ pub use password::{MIN_PASSWORD_CHARS, hash_password, verify_password}; pub use push_mfa::testkit; pub use push_mfa::{ AttestationPolicy, AttestationResult, ChallengeDetails, ChallengeStatus, DeviceInfo, - DevicePrincipal, EnrollToken, MfaAuditEvent, MfaConfig, MfaError, + DevicePrincipal, EnrollToken, LoginChallenge, MfaAuditEvent, MfaConfig, MfaError, NewChallenge as MfaNewChallenge, NoopPush, ParsedAuth, PushChannel, PushFuture, RegisterInput, Registered, google_roots, parse_authorization, verify_attestation, }; diff --git a/crates/rostdav-app/src/push_mfa/login.rs b/crates/rostdav-app/src/push_mfa/login.rs new file mode 100644 index 0000000..5c63f32 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/login.rs @@ -0,0 +1,139 @@ +//! The link between a password step and a phone approval (ADR 0014). The browser holds a ticket +//! from the password step. Every challenge it starts carries a `login_ref` derived from that +//! ticket, so only the holder can poll it, and only once can it be turned into a session. + +use std::net::IpAddr; + +use serde_json::json; + +use super::{ChallengeStatus, MfaError}; +use crate::secrets::mac; +use crate::{AppState, now_secs}; + +/// A challenge started for a sign-in, for the page that shows the number. +#[derive(Debug)] +pub struct LoginChallenge { + pub challenge_id: String, + /// The number the page shows and the user must pick on the phone. + pub number: i64, + pub expires_in_seconds: i64, +} + +impl AppState { + /// The user of a genuine, current ticket for this flow (`""` is the browser sign-in). + fn login_user(&self, ticket: &str, flow: &str) -> Result { + self.second_factor_ticket_user(ticket, flow) + .ok_or(MfaError::Unauthenticated) + } + + /// What ties a challenge to one ticket. Hex of a keyed hash: 64 characters, no secret leaks. + fn login_ref_for(&self, ticket: &str, flow: &str) -> String { + mac(&self.inner.server_key, &["mfa-login", flow, ticket]) + } + + /// Starts an approval on the user's most recently seen active phone. + pub async fn mfa_login_start( + &self, + ticket: &str, + flow: &str, + login_device: &str, + ip: IpAddr, + ) -> Result { + let user_id = self.login_user(ticket, flow)?; + let devices = self.inner.db.mfa_devices_of(user_id).await?; + let device = devices + .iter() + .filter(|d| d.status == "active") + .max_by_key(|d| d.last_seen_at.unwrap_or(d.created_at)) + .ok_or(MfaError::NotFound)?; + let c = self + .mfa_create_challenge( + user_id, + &device.id, + &self.login_ref_for(ticket, flow), + login_device, + ip, + ) + .await?; + Ok(LoginChallenge { + challenge_id: c.challenge_id, + number: c.number, + expires_in_seconds: c.expires_in_seconds, + }) + } + + /// The row of a challenge that this ticket started. Anything else is `NotFound`. + async fn login_challenge( + &self, + ticket: &str, + flow: &str, + challenge_id: &str, + ) -> Result<(i64, String), MfaError> { + let user_id = self.login_user(ticket, flow)?; + let login_ref = self.login_ref_for(ticket, flow); + let row = self + .inner + .db + .mfa_challenge_get(challenge_id) + .await? + .filter(|r| r.user_id == user_id && r.login_ref == login_ref) + .ok_or(MfaError::NotFound)?; + Ok((user_id, row.login_ref)) + } + + /// Where the challenge of this sign-in stands. + pub async fn mfa_login_status( + &self, + ticket: &str, + flow: &str, + challenge_id: &str, + ) -> Result { + self.login_challenge(ticket, flow, challenge_id).await?; + self.mfa_challenge_status(challenge_id).await + } + + /// Takes the approval: true for one caller only. Returns the user who may now get a session. + pub async fn mfa_login_release( + &self, + ticket: &str, + flow: &str, + challenge_id: &str, + ) -> Result { + let (user_id, login_ref) = self.login_challenge(ticket, flow, challenge_id).await?; + match self.mfa_challenge_status(challenge_id).await? { + ChallengeStatus::Approved => {} + ChallengeStatus::Pending => { + return Err(MfaError::InvalidRequest("challenge is not approved")); + } + ChallengeStatus::Denied => { + return Err(MfaError::InvalidRequest("challenge was denied")); + } + ChallengeStatus::Expired => return Err(MfaError::Expired), + } + let user = self + .inner + .db + .find_user_by_id(user_id) + .await? + .filter(|u| !u.disabled) + .ok_or(MfaError::Unauthenticated)?; + if !self + .inner + .db + .mfa_challenge_release(challenge_id, &login_ref, now_secs()) + .await? + { + // used before, or the approving phone was revoked in the meantime + return Err(MfaError::AlreadyConsumed); + } + self.audit( + &user.name, + "mfa.login_released", + challenge_id, + None, + json!({}), + ) + .await; + Ok(user_id) + } +} diff --git a/crates/rostdav-app/src/push_mfa/login_tests.rs b/crates/rostdav-app/src/push_mfa/login_tests.rs new file mode 100644 index 0000000..0c7e651 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/login_tests.rs @@ -0,0 +1,299 @@ +//! A challenge belongs to one sign-in (its ticket) and is released to the browser once. + +use std::net::{IpAddr, Ipv4Addr}; + +use super::testkit::{Phone, enrolled, fixed_key, spki_b64, state}; +use super::{ChallengeStatus, MfaError}; +use crate::{AppState, now_secs}; + +const IP: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); + +fn ticket(st: &AppState, user_id: i64, flow: &str) -> String { + st.second_factor_ticket(user_id, flow) +} + +async fn started(st: &AppState, p: &Phone, flow: &str) -> (String, String) { + let t = ticket(st, p.user_id, flow); + let c = st + .mfa_login_start(&t, flow, "Firefox on Linux", IP) + .await + .expect("start"); + (t, c.challenge_id) +} + +async fn approve(st: &AppState, id: &str) { + assert!( + st.inner + .db + .mfa_challenge_resolve(id, "approved", now_secs()) + .await + .expect("resolve") + ); +} + +async fn add_phone(st: &AppState, user_id: i64, id: &str, seen: i64) { + let (a, b) = (fixed_key(), fixed_key()); + st.inner + .db + .mfa_device_insert(&rostdav_meta::NewDevice { + id, + user_id, + name: "Second", + request_pub: &spki_b64(&a), + approval_pub: &spki_b64(&b), + push_channel: "fcm", + push_token: "t2", + hardware_backed: true, + strongbox: false, + }) + .await + .expect("insert"); + st.inner.db.mfa_device_touch(id, seen).await.expect("touch"); +} + +#[tokio::test] +async fn start_creates_a_challenge_for_the_newest_active_phone() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + st.inner + .db + .mfa_device_touch("dev_a", 1_000) + .await + .expect("t"); + add_phone(&st, p.user_id, "dev_b", 2_000).await; + let (_t, id) = started(&st, &p, "").await; + let row = st + .inner + .db + .mfa_challenge_get(&id) + .await + .expect("q") + .expect("row"); + assert_eq!(row.device_id, "dev_b"); +} + +#[tokio::test] +async fn start_skips_a_revoked_phone() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + st.inner + .db + .mfa_device_touch("dev_a", 1_000) + .await + .expect("t"); + add_phone(&st, p.user_id, "dev_b", 2_000).await; + st.mfa_revoke_device(p.user_id, "dev_b", "anna") + .await + .expect("revoke"); + let (_t, id) = started(&st, &p, "").await; + let row = st + .inner + .db + .mfa_challenge_get(&id) + .await + .expect("q") + .expect("row"); + assert_eq!(row.device_id, "dev_a"); +} + +#[tokio::test] +async fn start_without_a_phone_is_not_found() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + st.mfa_revoke_device(p.user_id, "dev_a", "anna") + .await + .expect("revoke"); + let t = ticket(&st, p.user_id, ""); + let r = st.mfa_login_start(&t, "", "x", IP).await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); +} + +#[tokio::test] +async fn start_with_a_forged_ticket_is_unauthenticated() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let r = st + .mfa_login_start(&format!("{}.9999999999.beef", p.user_id), "", "x", IP) + .await; + assert!(matches!(r, Err(MfaError::Unauthenticated)), "{r:?}"); +} + +#[tokio::test] +async fn status_follows_the_challenge() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (t, id) = started(&st, &p, "").await; + assert_eq!( + st.mfa_login_status(&t, "", &id).await.expect("s"), + ChallengeStatus::Pending + ); + approve(&st, &id).await; + assert_eq!( + st.mfa_login_status(&t, "", &id).await.expect("s"), + ChallengeStatus::Approved + ); +} + +#[tokio::test] +async fn status_of_another_tickets_challenge_is_not_found() { + let (_d, st) = state().await; + let a = enrolled(&st, "anna", "dev_a").await; + let b = enrolled(&st, "ben", "dev_b").await; + let (_ta, id_a) = started(&st, &a, "").await; + // another user's ticket + let tb = ticket(&st, b.user_id, ""); + let r = st.mfa_login_status(&tb, "", &id_a).await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); + // the same user, but a ticket of a Login Flow v2 sign-in + let tf = ticket(&st, a.user_id, "flowtoken"); + let r = st.mfa_login_status(&tf, "flowtoken", &id_a).await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); + // an unknown id + let t = ticket(&st, a.user_id, ""); + let r = st.mfa_login_status(&t, "", "ch_nope").await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); +} + +#[tokio::test] +async fn an_expired_ticket_cannot_poll_or_release() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (_t, id) = started(&st, &p, "").await; + approve(&st, &id).await; + let (uid, exp) = (p.user_id.to_string(), (now_secs() - 5).to_string()); + let sig = crate::secrets::mac(&st.inner.server_key, &["2fa", &uid, &exp, ""]); + let old = format!("{uid}.{exp}.{sig}"); + let r = st.mfa_login_status(&old, "", &id).await; + assert!(matches!(r, Err(MfaError::Unauthenticated)), "{r:?}"); + let r = st.mfa_login_release(&old, "", &id).await; + assert!(matches!(r, Err(MfaError::Unauthenticated)), "{r:?}"); +} + +#[tokio::test] +async fn release_after_approve_returns_the_user_once() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (t, id) = started(&st, &p, "").await; + approve(&st, &id).await; + assert_eq!( + st.mfa_login_release(&t, "", &id).await.expect("release"), + p.user_id + ); + let again = st.mfa_login_release(&t, "", &id).await; + assert!(matches!(again, Err(MfaError::AlreadyConsumed)), "{again:?}"); +} + +#[tokio::test] +async fn two_concurrent_releases_have_one_winner() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (t, id) = started(&st, &p, "").await; + approve(&st, &id).await; + let (a, b) = tokio::join!( + st.mfa_login_release(&t, "", &id), + st.mfa_login_release(&t, "", &id) + ); + assert_eq!( + [a.is_ok(), b.is_ok()].iter().filter(|x| **x).count(), + 1, + "{a:?} {b:?}" + ); +} + +#[tokio::test] +async fn release_while_pending_fails_and_does_not_burn_the_challenge() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (t, id) = started(&st, &p, "").await; + let r = st.mfa_login_release(&t, "", &id).await; + assert!(matches!(r, Err(MfaError::InvalidRequest(_))), "{r:?}"); + approve(&st, &id).await; + assert!(st.mfa_login_release(&t, "", &id).await.is_ok()); +} + +#[tokio::test] +async fn release_after_deny_fails() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (t, id) = started(&st, &p, "").await; + assert!( + st.inner + .db + .mfa_challenge_resolve(&id, "denied", now_secs()) + .await + .expect("resolve") + ); + let r = st.mfa_login_release(&t, "", &id).await; + assert!(matches!(r, Err(MfaError::InvalidRequest(_))), "{r:?}"); +} + +#[tokio::test] +async fn release_of_an_expired_challenge_fails() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (t, id) = started(&st, &p, "").await; + assert!( + st.inner + .db + .mfa_challenge_cancel(&id, now_secs()) + .await + .expect("c") + ); + let r = st.mfa_login_release(&t, "", &id).await; + assert!(matches!(r, Err(MfaError::Expired)), "{r:?}"); +} + +#[tokio::test] +async fn a_flow_ticket_cannot_release_a_web_challenge_and_the_reverse() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (_t, web_id) = started(&st, &p, "").await; + approve(&st, &web_id).await; + let tf = ticket(&st, p.user_id, "flowtoken"); + let r = st.mfa_login_release(&tf, "flowtoken", &web_id).await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); +} + +#[tokio::test] +async fn release_after_the_phone_was_revoked_fails() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (t, id) = started(&st, &p, "").await; + approve(&st, &id).await; + st.mfa_revoke_device(p.user_id, "dev_a", "anna") + .await + .expect("revoke"); + let r = st.mfa_login_release(&t, "", &id).await; + assert!(r.is_err(), "released: {r:?}"); +} + +#[tokio::test] +async fn a_disabled_user_cannot_release() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (t, id) = started(&st, &p, "").await; + approve(&st, &id).await; + st.inner + .db + .set_user_disabled(p.user_id, true) + .await + .expect("disable"); + let r = st.mfa_login_release(&t, "", &id).await; + assert!(matches!(r, Err(MfaError::Unauthenticated)), "{r:?}"); +} + +#[tokio::test] +async fn the_audit_has_start_and_release_but_never_the_number() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (t, id) = started(&st, &p, "").await; + approve(&st, &id).await; + st.mfa_login_release(&t, "", &id).await.expect("release"); + let events = st.inner.db.events_since(0).await.expect("events"); + let actions: Vec<&str> = events.iter().map(|e| e.action.as_str()).collect(); + assert!(actions.contains(&"mfa.challenge_created"), "{actions:?}"); + assert!(actions.contains(&"mfa.login_released"), "{actions:?}"); + let details: String = events.iter().map(|e| e.details.as_str()).collect(); + assert!(!details.contains("number"), "{details}"); + assert!(!details.contains(&t), "the ticket is a secret"); +} diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs index dc79744..9c510b9 100644 --- a/crates/rostdav-app/src/push_mfa/mod.rs +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -17,6 +17,9 @@ mod error; #[cfg(test)] mod factor_tests; mod google_roots; +mod login; +#[cfg(test)] +mod login_tests; mod push; #[cfg(any(test, feature = "test-util"))] pub mod testkit; @@ -28,6 +31,7 @@ pub use devices::{DeviceInfo, MfaAuditEvent}; pub use enroll::{EnrollToken, RegisterInput, Registered}; pub use error::MfaError; pub use google_roots::google_roots; +pub use login::LoginChallenge; pub use push::{NoopPush, PushChannel, PushFuture}; use crate::AppState; diff --git a/crates/rostdav-meta/migrations/0013_push_mfa.sql b/crates/rostdav-meta/migrations/0013_push_mfa.sql index b4f9158..326ac9c 100644 --- a/crates/rostdav-meta/migrations/0013_push_mfa.sql +++ b/crates/rostdav-meta/migrations/0013_push_mfa.sql @@ -30,7 +30,8 @@ CREATE TABLE mfa_enroll_tokens ( ) STRICT; CREATE INDEX mfa_enroll_tokens_user ON mfa_enroll_tokens(user_id); --- One sign-in approval request for one phone. `login_ref` ties it to the sign-in that asked. +-- One sign-in approval request for one phone. `login_ref` ties it to the sign-in that asked; +-- `released_at` is set once, when the browser took the approval. -- `candidates` is a JSON array of the three numbers shown on the phone, `number` the right one. CREATE TABLE mfa_challenges ( id TEXT PRIMARY KEY, @@ -46,7 +47,8 @@ CREATE TABLE mfa_challenges ( status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied', 'expired')), consumed_at INTEGER, - login_device TEXT NOT NULL DEFAULT '' + login_device TEXT NOT NULL DEFAULT '', + released_at INTEGER ) STRICT; CREATE INDEX mfa_challenges_user ON mfa_challenges(user_id); CREATE INDEX mfa_challenges_device ON mfa_challenges(device_id, status); diff --git a/crates/rostdav-meta/src/push_mfa.rs b/crates/rostdav-meta/src/push_mfa.rs index 0f5e011..af1030e 100644 --- a/crates/rostdav-meta/src/push_mfa.rs +++ b/crates/rostdav-meta/src/push_mfa.rs @@ -413,6 +413,24 @@ impl Db { Ok(r.rows_affected() > 0) } + /// Hands an approved challenge to the sign-in that started it, once. True for exactly one + /// caller, and only while the approving phone is still active (a phone revoked after it + /// approved releases nothing). + pub async fn mfa_challenge_release(&self, id: &str, login_ref: &str, now: i64) -> Result { + let r = sql::query( + "UPDATE mfa_challenges SET released_at = ? + WHERE id = ? AND login_ref = ? AND status = 'approved' AND released_at IS NULL + AND device_id IN (SELECT id FROM mfa_devices WHERE status = 'active')", + ) + .bind(now) + .bind(id) + .bind(login_ref) + .execute(self.pool()) + .await + .map_err(err)?; + Ok(r.rows_affected() > 0) + } + /// Ends a pending challenge as `expired` whatever its time (its phone was revoked). True for /// the one caller that changed it. pub async fn mfa_challenge_cancel(&self, id: &str, now: i64) -> Result { diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index 8cfa8c3..49dcd4a 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -48,7 +48,7 @@ Migration `0013_push_mfa.sql` with four `STRICT` tables, a plain index on every - `PushChannel` trait in `rostdav-app`; delivery adapters follow in the second step. **Implementation notes 2026-11-09:** `rostdav-mfa-api` exposes `router() -> Router` (merged into the server's router like the other protocol crates; the paths are complete so the signed path is the received one). It reads at most 64 KiB (413 `INVALID_REQUEST` beyond that; the contract has no 413), refuses unknown JSON fields, and sets `cache-control: no-store`. It has no dependency on `rostdav-meta`. Its integration tests use a `test-util` feature of `rostdav-app` (the `testkit` module: synthetic attestation chains, a fresh state); production builds do not enable it. The tests check responses against a JSON mirror of the OpenAPI file kept in the `rostdav-mfa` repository (a contract test keeps the mirror equal to the YAML), so Rostdav needs no YAML crate. Listing challenges supports `status=pending` only. - **Implementation notes 2026-11-10:** `AppState::has_second_factor` (TOTP on or at least one active phone) replaces the TOTP-only check at the three sign-in decisions (browser sign-in, Login Flow v2, WebDAV/OCS password). The TOTP management pages keep asking about TOTP alone. + **Implementation notes 2026-11-10:** `AppState::has_second_factor` (TOTP on or at least one active phone) replaces the TOTP-only check at the three sign-in decisions (browser sign-in, Login Flow v2, WebDAV/OCS password). The TOTP management pages keep asking about TOTP alone. A challenge started for a sign-in carries a `login_ref`, a keyed hash of the sign-in's ticket and flow. Only the holder of that ticket can poll it (`mfa_login_status`) or turn it into a session (`mfa_login_release`). The release is one conditional update on `released_at` and needs the approving phone to be still active, so a phone revoked after it approved releases nothing. ### Dependencies (justification) diff --git a/docs/architecture.md b/docs/architecture.md index 388be9f..4b94ba6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,7 +108,7 @@ A central error type in `rostdav-core` (`NotFound`, `Forbidden`, `Conflict`, `Pr | `recovery_codes` | id, user_id, code_hash, used_at | | `mfa_devices` | id, user_id, name, status (active/revoked), request_pub, approval_pub, push_channel, push_token, hardware_backed, strongbox, created_at, last_seen_at, revoked_at ([ADR 0014](adr/0014-push-mfa.md)) | | `mfa_enroll_tokens` | token_hash, user_id, expires_at, used_at | -| `mfa_challenges` | id, user_id, device_id, login_ref, nonce, number, candidates, audience, created_at, expires_at, status, consumed_at, login_device | +| `mfa_challenges` | id, user_id, device_id, login_ref, nonce, number, candidates, audience, created_at, expires_at, status, consumed_at, login_device, released_at | | `mfa_nonces` | device_id, nonce, expires_at | Migration 0013 also adds `events_actor` on `events(actor, action)` for the own-events view of the RustMFA app. From 341181389895f05ab5dd77fa095e58c7c0c7cda2 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 21:30:41 +0100 Subject: [PATCH 19/24] feat(mfa): approve a browser sign-in on the phone The second step offers "Send to my phone" (POST with CSRF, never a GET), shows the number, polls the status (JSON without the number) and signs in once the phone approved. Tied to the ticket of the password step. --- CHANGELOG.md | 3 +- crates/rostdav-app/src/lib.rs | 2 +- crates/rostdav-app/src/push_mfa/login.rs | 53 +++- .../rostdav-app/src/push_mfa/login_tests.rs | 46 ++++ crates/rostdav-app/src/push_mfa/mod.rs | 2 +- crates/rostdav-app/src/push_mfa/testkit.rs | 41 ++- crates/rostdav-web/i18n/de.tsv | 15 ++ crates/rostdav-web/src/layout.rs | 9 +- crates/rostdav-web/src/lib.rs | 4 + crates/rostdav-web/src/phone_step.rs | 252 ++++++++++++++++++ crates/rostdav-web/src/twofactor.rs | 132 ++++++--- crates/rostdav-web/static/app.css | 2 + crates/rostdav-web/static/phone.js | 24 ++ crates/rostdav/Cargo.toml | 2 +- crates/rostdav/tests/web.rs | 249 +++++++++++++++++ docs/admin.md | 2 +- docs/security.md | 2 +- docs/ui.md | 1 + 18 files changed, 795 insertions(+), 46 deletions(-) create mode 100644 crates/rostdav-web/src/phone_step.rs create mode 100644 crates/rostdav-web/static/phone.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b989a..622ea9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,11 @@ All notable changes are listed here. The format follows [Keep a Changelog](https ### Changed -- **An enrolled RustMFA phone counts as a second factor**, like an authenticator app (ADR 0014). Once a user has an active phone, WebDAV and OCS no longer accept their account password (app passwords still work) and the browser sign-in asks for a second step. Until the sign-in page work lands, a user with only a phone cannot finish a browser sign-in. +- **An enrolled RustMFA phone counts as a second factor**, like an authenticator app (ADR 0014). Once a user has an active phone, WebDAV and OCS no longer accept their account password (app passwords still work) and the browser sign-in asks for a second step. ### Added +- **Sign in with the RustMFA phone in the browser**: the second step offers "Send to my phone" (next to the code form when authenticator codes are on). The page shows a number, the user picks it on the phone, and the page signs in once the phone approved. A denied or expired request goes back to the second step. The Login Flow v2 page follows. - **A sign-in approval belongs to one sign-in**: a challenge started for a sign-in can only be polled and turned into a session by the browser that passed the password step, and only once; a phone revoked after it approved releases nothing. Not reachable from the sign-in page yet. - **Settings for push sign-in approval** (`[mfa]` section: challenge lifetime, enrollment lifetime, audience, attestation policy), the first step of ADR 0014. They have no effect yet. - **Database tables for push sign-in approval** (migration 0013: phones, enrollment tokens, challenges, request nonces). Nothing uses them yet. diff --git a/crates/rostdav-app/src/lib.rs b/crates/rostdav-app/src/lib.rs index 2ecb1c0..ec02fa2 100644 --- a/crates/rostdav-app/src/lib.rs +++ b/crates/rostdav-app/src/lib.rs @@ -62,7 +62,7 @@ pub use push_mfa::{ AttestationPolicy, AttestationResult, ChallengeDetails, ChallengeStatus, DeviceInfo, DevicePrincipal, EnrollToken, LoginChallenge, MfaAuditEvent, MfaConfig, MfaError, NewChallenge as MfaNewChallenge, NoopPush, ParsedAuth, PushChannel, PushFuture, RegisterInput, - Registered, google_roots, parse_authorization, verify_attestation, + Registered, SecondStepOptions, google_roots, parse_authorization, verify_attestation, }; pub use search::{KindFilter, MAX_VISITED, SearchHit, SearchPage, SearchQuery, SnippetPart}; pub use shares::{ diff --git a/crates/rostdav-app/src/push_mfa/login.rs b/crates/rostdav-app/src/push_mfa/login.rs index 5c63f32..286b978 100644 --- a/crates/rostdav-app/src/push_mfa/login.rs +++ b/crates/rostdav-app/src/push_mfa/login.rs @@ -8,7 +8,7 @@ use serde_json::json; use super::{ChallengeStatus, MfaError}; use crate::secrets::mac; -use crate::{AppState, now_secs}; +use crate::{AppState, Principal, Scope, Via, now_secs}; /// A challenge started for a sign-in, for the page that shows the number. #[derive(Debug)] @@ -19,7 +19,58 @@ pub struct LoginChallenge { pub expires_in_seconds: i64, } +/// What the second step of the browser sign-in can offer this user. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SecondStepOptions { + pub totp: bool, + pub phone: bool, +} + impl AppState { + /// The second-step choices behind a browser sign-in ticket; `None` if the ticket is not + /// genuine, current and for the browser sign-in. + pub async fn web_second_step_options(&self, ticket: &str) -> Option { + let user_id = self.second_factor_ticket_user(ticket, "")?; + let totp = self.inner.db.totp_enabled(user_id).await.ok()?; + let phone = self.inner.db.mfa_active_device_count(user_id).await.ok()? > 0; + Some(SecondStepOptions { totp, phone }) + } + + /// Second step of a browser sign-in with an approved phone challenge: one session, once. + pub async fn web_phone_finish( + &self, + ticket: &str, + challenge_id: &str, + ip: IpAddr, + user_agent: &str, + ) -> Result<(String, Principal), MfaError> { + let user_id = self.mfa_login_release(ticket, "", challenge_id).await?; + let u = self + .inner + .db + .find_user_by_id(user_id) + .await? + .filter(|u| !u.disabled) + .ok_or(MfaError::Unauthenticated)?; + let p = Principal { + user_id: u.id, + name: u.name, + is_admin: u.is_admin, + via: Via::AccountPassword, + scope: Scope::Full, + }; + let cookie = self.open_session(&p, ip, user_agent).await?; + self.audit( + &p.name, + "auth.login", + "web", + Some(ip), + json!({"second_factor": "phone"}), + ) + .await; + Ok((cookie, p)) + } + /// The user of a genuine, current ticket for this flow (`""` is the browser sign-in). fn login_user(&self, ticket: &str, flow: &str) -> Result { self.second_factor_ticket_user(ticket, flow) diff --git a/crates/rostdav-app/src/push_mfa/login_tests.rs b/crates/rostdav-app/src/push_mfa/login_tests.rs index 0c7e651..ac741ad 100644 --- a/crates/rostdav-app/src/push_mfa/login_tests.rs +++ b/crates/rostdav-app/src/push_mfa/login_tests.rs @@ -297,3 +297,49 @@ async fn the_audit_has_start_and_release_but_never_the_number() { assert!(!details.contains("number"), "{details}"); assert!(!details.contains(&t), "the ticket is a secret"); } + +#[tokio::test] +async fn second_step_options_report_what_the_user_can_use() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let t = ticket(&st, p.user_id, ""); + let o = st.web_second_step_options(&t).await.expect("options"); + assert!(o.phone && !o.totp, "{o:?}"); + assert!(st.web_second_step_options("1.2.bad").await.is_none()); + // a ticket of the Login Flow page is not one of the browser sign-in + let tf = ticket(&st, p.user_id, "flowtoken"); + assert!(st.web_second_step_options(&tf).await.is_none()); +} + +#[tokio::test] +async fn web_phone_finish_opens_one_session() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (t, id) = started(&st, &p, "").await; + approve(&st, &id).await; + let (cookie, principal) = st + .web_phone_finish(&t, &id, IP, "ua") + .await + .expect("finish"); + assert_eq!(principal.user_id, p.user_id); + let s = st.web_session(&cookie).await.expect("q").expect("session"); + assert_eq!(s.principal.user_id, p.user_id); + let again = st.web_phone_finish(&t, &id, IP, "ua").await; + assert!(matches!(again, Err(MfaError::AlreadyConsumed)), "{again:?}"); + let events = st.inner.db.events_since(0).await.expect("events"); + assert!( + events + .iter() + .any(|e| e.action == "auth.login" && e.details.contains("phone")), + "{events:?}" + ); +} + +#[tokio::test] +async fn web_phone_finish_before_approval_opens_no_session() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let (t, id) = started(&st, &p, "").await; + let r = st.web_phone_finish(&t, &id, IP, "ua").await; + assert!(matches!(r, Err(MfaError::InvalidRequest(_))), "{r:?}"); +} diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs index 9c510b9..fb50637 100644 --- a/crates/rostdav-app/src/push_mfa/mod.rs +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -31,7 +31,7 @@ pub use devices::{DeviceInfo, MfaAuditEvent}; pub use enroll::{EnrollToken, RegisterInput, Registered}; pub use error::MfaError; pub use google_roots::google_roots; -pub use login::LoginChallenge; +pub use login::{LoginChallenge, SecondStepOptions}; pub use push::{NoopPush, PushChannel, PushFuture}; use crate::AppState; diff --git a/crates/rostdav-app/src/push_mfa/testkit.rs b/crates/rostdav-app/src/push_mfa/testkit.rs index d316112..590c468 100644 --- a/crates/rostdav-app/src/push_mfa/testkit.rs +++ b/crates/rostdav-app/src/push_mfa/testkit.rs @@ -250,13 +250,26 @@ pub struct Phone { } pub async fn enrolled(st: &AppState, user: &str, id: &str) -> Phone { - let p = add_user(st, user).await; + add_user(st, user).await; + enroll_existing(st, user, id).await +} + +/// A phone for a user that already exists. +pub async fn enroll_existing(st: &AppState, user: &str, id: &str) -> Phone { + let user_id = st + .inner + .db + .find_user(user) + .await + .expect("query") + .expect("user exists") + .id; let (request, approval) = (fixed_key(), fixed_key()); st.inner .db .mfa_device_insert(&NewDevice { id, - user_id: p.user_id, + user_id, name: "Pixel", request_pub: &spki_b64(&request), approval_pub: &spki_b64(&approval), @@ -271,10 +284,20 @@ pub async fn enrolled(st: &AppState, user: &str, id: &str) -> Phone { id: id.to_owned(), request, approval, - user_id: p.user_id, + user_id, } } +/// Answers a pending challenge as the phone would (`approved` or `denied`), without the signed +/// request. For tests of the sign-in pages, which do not test the phone's protocol. +pub async fn resolve_challenge(st: &AppState, challenge_id: &str, to: &str) -> bool { + st.inner + .db + .mfa_challenge_resolve(challenge_id, to, crate::now_secs()) + .await + .expect("resolve") +} + #[derive(Clone, Default)] pub struct Capture(pub Arc>>); @@ -324,3 +347,15 @@ pub fn live_opts() -> Opts { ..Opts::default() } } + +/// Ids of the open challenges of one phone. +pub async fn pending_challenge_ids(st: &AppState, device_id: &str) -> Vec { + st.inner + .db + .mfa_challenges_pending(device_id, crate::now_secs()) + .await + .expect("pending") + .into_iter() + .map(|c| c.id) + .collect() +} diff --git a/crates/rostdav-web/i18n/de.tsv b/crates/rostdav-web/i18n/de.tsv index 235bf91..c40508a 100644 --- a/crates/rostdav-web/i18n/de.tsv +++ b/crates/rostdav-web/i18n/de.tsv @@ -456,3 +456,18 @@ The search index is not open yet. The server opens it a few seconds after it sta The index is built in the background. It holds the text of files in a file of its own in the data directory and can be removed at any time with the command rostdav admin search rebuild. Der Index wird im Hintergrund aufgebaut. Er enthält den Text von Dateien in einer eigenen Datei im Datenverzeichnis und kann jederzeit mit dem Befehl rostdav admin search rebuild entfernt werden. More Mehr Close Schließen +Send to my phone An mein Handy senden +Approve the request on your RustMFA phone. Bestätige die Anfrage auf deinem RustMFA-Handy. +Approve on your phone Auf dem Handy bestätigen +Pick this number on your phone: Wähle diese Zahl auf deinem Handy: +Waiting for approval… Warte auf Bestätigung … +Use another method Andere Methode verwenden +Valid for {0} seconds. Gültig für {0} Sekunden. +Not approved yet. Noch nicht bestätigt. +The request was denied on the phone. Die Anfrage wurde auf dem Handy abgelehnt. +The request expired. Send a new one. Die Anfrage ist abgelaufen. Sende eine neue. +This approval was already used. Sign in again. Diese Bestätigung wurde schon verwendet. Melde dich neu an. +No phone is enrolled for this account. Für dieses Konto ist kein Handy eingerichtet. +Too many requests. Try again later. Zu viele Anfragen. Versuche es später noch einmal. +Sign-in with the phone failed. Die Anmeldung mit dem Handy ist fehlgeschlagen. +Continue Weiter diff --git a/crates/rostdav-web/src/layout.rs b/crates/rostdav-web/src/layout.rs index 4bc75f5..07596ac 100644 --- a/crates/rostdav-web/src/layout.rs +++ b/crates/rostdav-web/src/layout.rs @@ -8,6 +8,7 @@ use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; const APP_CSS: &str = include_str!("../static/app.css"); const UPLOAD_JS: &str = include_str!("../static/upload.js"); const COPY_JS: &str = include_str!("../static/copy.js"); +const PHONE_JS: &str = include_str!("../static/phone.js"); const PASSWORD_JS: &str = include_str!("../static/password.js"); const AVATAR_JS: &str = include_str!("../static/avatar.js"); const DROPDOWN_JS: &str = include_str!("../static/dropdown.js"); @@ -160,6 +161,11 @@ fn html(status: StatusCode, doc: String) -> Response { /// A page without navigation (sign-in, device connection). pub fn bare(lang: Lang, status: StatusCode, title: &str, body: &str) -> Response { + bare_with(lang, status, title, body, "") +} + +/// Like [`bare`], with more markup (script tags) after the page's own script. +pub fn bare_with(lang: Lang, status: StatusCode, title: &str, body: &str, extra: &str) -> Response { html( status, format!( @@ -169,7 +175,7 @@ pub fn bare(lang: Lang, status: StatusCode, title: &str, body: &str) -> Response
\
{}

{}

\
  • {}
  • {}
  • {}
\ -
{}
{PASSWORD_SCRIPT}", +
{}
{PASSWORD_SCRIPT}{extra}", lang.code(), esc(&t(lang, title)), brand_logo(), @@ -414,6 +420,7 @@ pub async fn static_file( "upload.js" => (UPLOAD_JS.as_bytes(), "text/javascript; charset=utf-8"), "favicon.svg" => (favicon.as_bytes(), "image/svg+xml"), "copy.js" => (COPY_JS.as_bytes(), "text/javascript; charset=utf-8"), + "phone.js" => (PHONE_JS.as_bytes(), "text/javascript; charset=utf-8"), "password.js" => (PASSWORD_JS.as_bytes(), "text/javascript; charset=utf-8"), "avatar.js" => (AVATAR_JS.as_bytes(), "text/javascript; charset=utf-8"), "dropdown.js" => (DROPDOWN_JS.as_bytes(), "text/javascript; charset=utf-8"), diff --git a/crates/rostdav-web/src/lib.rs b/crates/rostdav-web/src/lib.rs index 58e5698..0519c7b 100644 --- a/crates/rostdav-web/src/lib.rs +++ b/crates/rostdav-web/src/lib.rs @@ -12,6 +12,7 @@ mod login_page; mod more; mod pages; mod pages_more; +mod phone_step; mod session; mod sharing; mod twofactor; @@ -130,6 +131,9 @@ pub fn router() -> Router { "/login/2fa", get(twofactor::login_second_show).post(twofactor::login_second_submit), ) + .route("/login/2fa/phone", post(phone_step::start)) + .route("/login/2fa/phone/status", get(phone_step::status)) + .route("/login/2fa/phone/finish", post(phone_step::finish)) .route( "/settings/2fa", get(twofactor::setup_page).post(twofactor::setup_confirm), diff --git a/crates/rostdav-web/src/phone_step.rs b/crates/rostdav-web/src/phone_step.rs new file mode 100644 index 0000000..064a45f --- /dev/null +++ b/crates/rostdav-web/src/phone_step.rs @@ -0,0 +1,252 @@ +//! The phone step of the browser sign-in (ADR 0014): the user presses "Send to my phone", the +//! page shows a number, the phone answers, and the page turns the approval into a session. +//! Every step needs the ticket of the password step; a challenge never leaves that sign-in. + +use axum::Form; +use axum::extract::{Query, State}; +use axum::http::request::Parts; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Redirect, Response}; +use rostdav_app::{AppState, ChallengeStatus, MfaError}; +use serde::Deserialize; + +use crate::i18n::{Lang, t, tf}; +use crate::layout::{self, esc}; +use crate::session::{ + LOGIN_COOKIE, TWOFA_COOKIE, accept_lang, client_ip, cookie_value, same_origin, set_cookie, +}; +use crate::twofactor::signed_in; + +const PHONE_SCRIPT: &str = ""; + +fn user_agent(headers: &HeaderMap) -> &str { + headers + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") +} + +/// What the phone shows as the sign-in device: the browser's own description, shortened. +fn login_device(headers: &HeaderMap) -> String { + let ua: String = user_agent(headers) + .chars() + .filter(|c| !c.is_control()) + .take(100) + .collect(); + if ua.trim().is_empty() { + "Web browser".to_owned() + } else { + ua + } +} + +fn no_store(mut r: Response) -> Response { + r.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + r +} + +/// The step page with a message, and a new CSRF value (the old form is spent). +async fn back_to_step( + state: &AppState, + headers: &HeaderMap, + status: StatusCode, + text: &str, +) -> Response { + let ticket = cookie_value(headers, TWOFA_COOKIE).unwrap_or_default(); + crate::twofactor::step_page(state, headers, &ticket, status, Some(text)).await +} + +fn error_text(lang: Lang, e: &MfaError) -> (StatusCode, String) { + match e { + MfaError::NotFound => ( + StatusCode::NOT_FOUND, + t(lang, "No phone is enrolled for this account."), + ), + MfaError::Expired => ( + StatusCode::GONE, + t(lang, "The request expired. Send a new one."), + ), + MfaError::AlreadyConsumed => ( + StatusCode::CONFLICT, + t(lang, "This approval was already used. Sign in again."), + ), + MfaError::RateLimited { .. } => ( + StatusCode::TOO_MANY_REQUESTS, + t(lang, "Too many requests. Try again later."), + ), + _ => ( + StatusCode::CONFLICT, + t(lang, "Sign-in with the phone failed."), + ), + } +} + +#[derive(Deserialize)] +pub struct StartForm { + csrf: String, +} + +/// POST /login/2fa/phone: starts the challenge and shows the number. +pub async fn start( + State(state): State, + parts: Parts, + Form(f): Form, +) -> Response { + let headers = &parts.headers; + let lang = accept_lang(headers); + let ticket = cookie_value(headers, TWOFA_COOKIE).unwrap_or_default(); + if !state.web_ticket_valid(&ticket) { + return Redirect::to("/login").into_response(); + } + let cookie = cookie_value(headers, LOGIN_COOKIE).unwrap_or_default(); + if !same_origin(headers) || state.login_csrf_check(&cookie, &f.csrf).is_err() { + return back_to_step( + &state, + headers, + StatusCode::FORBIDDEN, + &t(lang, "The form could not be verified. Try again."), + ) + .await; + } + match state + .mfa_login_start(&ticket, "", &login_device(headers), client_ip(&parts)) + .await + { + Ok(c) => number_page(&state, headers, lang, &c), + Err(MfaError::Unauthenticated) => Redirect::to("/login").into_response(), + Err(e) => { + let (status, text) = error_text(lang, &e); + let mut r = back_to_step(&state, headers, status, &text).await; + if let MfaError::RateLimited { retry_after_secs } = e + && let Ok(v) = HeaderValue::from_str(&retry_after_secs.to_string()) + { + r.headers_mut().insert(header::RETRY_AFTER, v); + } + r + } + } +} + +fn number_page( + state: &AppState, + headers: &HeaderMap, + lang: Lang, + c: &rostdav_app::LoginChallenge, +) -> Response { + let (cookie, csrf) = state.login_csrf_issue(); + let id = esc(&c.challenge_id); + let body = format!( + "
{}
\ +

{}

{}

\ +

{}

\ +
{}
\ +
\ + \ +
\ +

{}

\ +
{}
", + esc(&t(lang, "Step 2 of 2")), + esc(&t(lang, "Approve on your phone")), + esc(&t(lang, "Pick this number on your phone:")), + c.number, + esc(&t(lang, "Waiting for approval…")), + esc(&csrf), + esc(&t(lang, "Continue")), + esc(&t(lang, "Use another method")), + esc(&tf( + lang, + "Valid for {0} seconds.", + &[&c.expires_in_seconds.to_string()] + )), + ); + let mut r = layout::bare_with( + lang, + StatusCode::OK, + "Confirm your sign-in", + &body, + PHONE_SCRIPT, + ); + r.headers_mut().append( + header::SET_COOKIE, + set_cookie(headers, LOGIN_COOKIE, &cookie, Some(900)), + ); + no_store(r) +} + +#[derive(Deserialize)] +pub struct StatusQuery { + #[serde(default)] + id: String, +} + +/// GET /login/2fa/phone/status?id=…: where the challenge stands, as JSON. The number is never in it. +pub async fn status( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> Response { + let ticket = cookie_value(&headers, TWOFA_COOKIE).unwrap_or_default(); + let r = match state.mfa_login_status(&ticket, "", &q.id).await { + Ok(s) => ( + [(header::CONTENT_TYPE, "application/json")], + format!("{{\"status\":\"{}\"}}", ChallengeStatus::as_str(s)), + ) + .into_response(), + Err(MfaError::Unauthenticated) => StatusCode::UNAUTHORIZED.into_response(), + Err(MfaError::NotFound) => StatusCode::NOT_FOUND.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + no_store(r) +} + +#[derive(Deserialize)] +pub struct FinishForm { + csrf: String, + id: String, +} + +/// POST /login/2fa/phone/finish: turns an approved challenge into the session, once. +pub async fn finish( + State(state): State, + parts: Parts, + Form(f): Form, +) -> Response { + let headers = &parts.headers; + let lang = accept_lang(headers); + let ticket = cookie_value(headers, TWOFA_COOKIE).unwrap_or_default(); + if !state.web_ticket_valid(&ticket) { + return Redirect::to("/login").into_response(); + } + let cookie = cookie_value(headers, LOGIN_COOKIE).unwrap_or_default(); + if !same_origin(headers) || state.login_csrf_check(&cookie, &f.csrf).is_err() { + return back_to_step( + &state, + headers, + StatusCode::FORBIDDEN, + &t(lang, "The form could not be verified. Try again."), + ) + .await; + } + match state + .web_phone_finish(&ticket, &f.id, client_ip(&parts), user_agent(headers)) + .await + { + Ok((token, _)) => no_store(signed_in(headers, &token)), + Err(MfaError::Unauthenticated) => Redirect::to("/login").into_response(), + Err(e) => { + // tell "not yet" and "denied" apart; both come back as a refusal + let text = match state.mfa_login_status(&ticket, "", &f.id).await { + Ok(ChallengeStatus::Pending) => t(lang, "Not approved yet."), + Ok(ChallengeStatus::Denied) => t(lang, "The request was denied on the phone."), + _ => error_text(lang, &e).1, + }; + let status = match e { + MfaError::Expired => StatusCode::GONE, + MfaError::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::CONFLICT, + }; + back_to_step(&state, headers, status, &text).await + } + } +} diff --git a/crates/rostdav-web/src/twofactor.rs b/crates/rostdav-web/src/twofactor.rs index 366e762..8b98db0 100644 --- a/crates/rostdav-web/src/twofactor.rs +++ b/crates/rostdav-web/src/twofactor.rs @@ -142,9 +142,10 @@ pub fn code_error_text(lang: Lang, e: &Error) -> (StatusCode, String) { // ---- second step of the web sign-in ----------------------------------------------------------- -fn second_step_page( +async fn second_step_page( state: &AppState, headers: &HeaderMap, + ticket: &str, recovery: bool, error: Option<&str>, status: StatusCode, @@ -156,25 +157,43 @@ fn second_step_page( } else { "/login/2fa?recovery=1" }; + // a ticket that does not check out gets the code form, as before + let opts = + state + .web_second_step_options(ticket) + .await + .unwrap_or(rostdav_app::SecondStepOptions { + totp: true, + phone: false, + }); + let phone = if opts.phone { + phone_card(lang, &csrf, if opts.totp { None } else { error }) + } else { + String::new() + }; + let code = if opts.totp || !opts.phone { + second_step_card( + lang, + &StepCard { + action: "/login/2fa", + csrf: &csrf, + hidden: "", + recovery, + error, + submit_label: &t(lang, "Confirm and sign in"), + toggle_href: toggle, + client: None, + }, + ) + } else { + String::new() + }; let mut r = layout::bare( lang, status, "Confirm your sign-in", &format!( - "{}

{}

", - second_step_card( - lang, - &StepCard { - action: "/login/2fa", - csrf: &csrf, - hidden: "", - recovery, - error, - submit_label: &t(lang, "Confirm and sign in"), - toggle_href: toggle, - client: None, - }, - ), + "{phone}{code}

{}

", esc(&t(lang, "Back to sign-in")) ), ); @@ -185,6 +204,54 @@ fn second_step_page( r } +/// The second step without the recovery view, for the phone step's messages. +pub(crate) async fn step_page( + state: &AppState, + headers: &HeaderMap, + ticket: &str, + status: StatusCode, + error: Option<&str>, +) -> Response { + second_step_page(state, headers, ticket, false, error, status).await +} + +/// "Send to my phone": a form, because starting a challenge is a POST with a CSRF value. +fn phone_card(lang: Lang, csrf: &str, error: Option<&str>) -> String { + let err = error + .map(|e| format!("
{}
", esc(e))) + .unwrap_or_default(); + format!( + "
{}
\ +

{}

{}

{err}\ +
\ +
", + esc(&t(lang, "Step 2 of 2")), + esc(&t(lang, "Confirm your sign-in")), + esc(&t(lang, "Approve the request on your RustMFA phone.")), + esc(csrf), + esc(&t(lang, "Send to my phone")), + ) +} + +/// The redirect after a finished sign-in: session cookie set, the step cookies cleared. +pub(crate) fn signed_in(headers: &HeaderMap, token: &str) -> Response { + let mut r = Redirect::to("/files").into_response(); + let h = r.headers_mut(); + h.append( + header::SET_COOKIE, + set_cookie(headers, SESSION_COOKIE, token, None), + ); + h.append( + header::SET_COOKIE, + set_cookie(headers, LOGIN_COOKIE, "", Some(0)), + ); + h.append( + header::SET_COOKIE, + set_cookie_at(headers, TWOFA_COOKIE, "", "/login", Some(0)), + ); + r +} + #[derive(Deserialize, Default)] pub struct StepQuery { #[serde(default)] @@ -200,7 +267,15 @@ pub async fn login_second_show( if !state.web_ticket_valid(&ticket) { return Redirect::to("/login").into_response(); } - second_step_page(&state, &headers, q.recovery == "1", None, StatusCode::OK) + second_step_page( + &state, + &headers, + &ticket, + q.recovery == "1", + None, + StatusCode::OK, + ) + .await } #[derive(Deserialize)] @@ -225,10 +300,12 @@ pub async fn login_second_submit( return second_step_page( &state, headers, + &ticket, false, Some(&t(lang, "The form could not be verified. Try again.")), StatusCode::FORBIDDEN, - ); + ) + .await; } let ua = headers .get(header::USER_AGENT) @@ -238,27 +315,12 @@ pub async fn login_second_submit( .web_second_factor(&ticket, &f.code, client_ip(&parts), ua) .await { - Ok((token, _)) => { - let mut r = Redirect::to("/files").into_response(); - let h = r.headers_mut(); - h.append( - header::SET_COOKIE, - set_cookie(headers, SESSION_COOKIE, &token, None), - ); - h.append( - header::SET_COOKIE, - set_cookie(headers, LOGIN_COOKIE, "", Some(0)), - ); - h.append( - header::SET_COOKIE, - set_cookie_at(headers, TWOFA_COOKIE, "", "/login", Some(0)), - ); - r - } + Ok((token, _)) => signed_in(headers, &token), Err(e) => { let (status, text) = code_error_text(lang, &e); let recovery = f.code.trim().len() > 7; - let mut r = second_step_page(&state, headers, recovery, Some(&text), status); + let mut r = + second_step_page(&state, headers, &ticket, recovery, Some(&text), status).await; if let Error::RateLimited { retry_after_secs } = e && let Ok(v) = HeaderValue::from_str(&retry_after_secs.to_string()) { diff --git a/crates/rostdav-web/static/app.css b/crates/rostdav-web/static/app.css index 6f055ff..af63af7 100644 --- a/crates/rostdav-web/static/app.css +++ b/crates/rostdav-web/static/app.css @@ -357,3 +357,5 @@ a.name>span{min-width:0} @media (max-width:760px){.listing th:nth-child(3),.listing td:nth-child(3){width:96px;white-space:normal}.listing th:nth-child(4),.listing td:nth-child(4){width:92px}.listing td:first-child{max-width:0;width:100%}} /* Phone: long values (source URL, database name) wrap instead of being clipped */ @media (max-width:760px){td.num{white-space:normal;overflow-wrap:anywhere;word-break:break-word}.tbl table{table-layout:auto}} +.phone-number{font-size:56px;font-weight:700;letter-spacing:.12em;text-align:center;margin:8px 0} +.phone-wait{text-align:center;color:var(--text-2)} diff --git a/crates/rostdav-web/static/phone.js b/crates/rostdav-web/static/phone.js new file mode 100644 index 0000000..056d2db --- /dev/null +++ b/crates/rostdav-web/static/phone.js @@ -0,0 +1,24 @@ +// Sign-in with the RustMFA phone: asks the server every two seconds whether the phone has +// answered.
marks the waiting area. When the +// answer is "approved" the page submits the finish form; when it was denied or has expired the +// page goes back to the second step. Without this script the form's button does the same. +(function () { + "use strict"; + var box = document.querySelector("[data-status-url]"); + if (!box || !window.fetch) return; + var url = box.getAttribute("data-status-url"); + var form = document.getElementById(box.getAttribute("data-finish-form")); + var done = false; + function poll() { + if (done) return; + fetch(url, { credentials: "same-origin", cache: "no-store" }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (j) { + if (!j || done) return; + if (j.status === "approved") { done = true; if (form) form.submit(); } + else if (j.status === "denied" || j.status === "expired") { done = true; location.href = "/login/2fa"; } + }) + .catch(function () {}); + } + setInterval(poll, 2000); +})(); diff --git a/crates/rostdav/Cargo.toml b/crates/rostdav/Cargo.toml index bc2e55c..8c0794d 100644 --- a/crates/rostdav/Cargo.toml +++ b/crates/rostdav/Cargo.toml @@ -48,7 +48,7 @@ zip.workspace = true http-body-util = "0.1" quick-xml.workspace = true httpdate.workspace = true -rostdav-app.workspace = true +rostdav-app = { workspace = true, features = ["test-util"] } figment = { workspace = true, features = ["test"] } [lints] diff --git a/crates/rostdav/tests/web.rs b/crates/rostdav/tests/web.rs index 9bd6db0..489eb55 100644 --- a/crates/rostdav/tests/web.rs +++ b/crates/rostdav/tests/web.rs @@ -5524,3 +5524,252 @@ async fn the_search_page_finds_words_in_files_and_shows_a_safe_snippet() { .contains("minutes.txt") ); } + +// ---- sign-in with the RustMFA phone (ADR 0014) -------------------------------------------------- + +use rostdav_app::testkit; + +/// The password step of `user`, then the second-step page: the cookies and the form value. +struct Step { + /// `rostdav_2fa=…; rostdav_login=…` + cookies: String, + ticket: String, + csrf: String, + html: String, +} + +async fn second_step(e: &Env, user: &str) -> Step { + let r = password_step(e, user).await; + assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.text()); + let ticket = r.cookie("rostdav_2fa").expect("ticket cookie"); + let page = get(e, "/login/2fa", &ticket).await; + assert_eq!(page.status, StatusCode::OK, "{}", page.text()); + let lc = page.cookie("rostdav_login").expect("login cookie"); + let html = page.text(); + Step { + cookies: format!("{ticket}; {lc}"), + ticket, + csrf: extract_csrf(&html), + html, + } +} + +/// Presses "Send to my phone": the number page (and its cookies). +async fn send_to_phone(e: &Env, s: &Step) -> Res { + post_form(e, "/login/2fa/phone", &s.cookies, &[("csrf", &s.csrf)]).await +} + +fn challenge_id_of(html: &str) -> String { + let i = html + .find("name=\"id\" value=\"") + .expect("challenge id field") + + 17; + html[i..].split('"').next().expect("id").to_owned() +} + +async fn phone_user(e: &Env, user: &str, device: &str) { + testkit::enroll_existing(&e.state, user, device).await; +} + +#[tokio::test] +async fn second_step_offers_only_the_phone_when_there_is_no_totp() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = second_step(&e, "bob").await; + assert!(s.html.contains("action=\"/login/2fa/phone\""), "{}", s.html); + assert!(s.html.contains("Send to my phone")); + assert!( + !s.html.contains("name=\"code\""), + "no code form without TOTP" + ); +} + +#[tokio::test] +async fn second_step_offers_phone_and_code_when_both_are_on() { + let e = env().await; + let (_c, _secret, _codes) = enable_2fa(&e, "anna").await; + phone_user(&e, "anna", "dev_a").await; + let s = second_step(&e, "anna").await; + assert!(s.html.contains("Send to my phone") && s.html.contains("name=\"code\"")); +} + +#[tokio::test] +async fn a_get_never_creates_a_challenge() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = second_step(&e, "bob").await; + for uri in ["/login/2fa", "/login/2fa/phone", "/login/2fa/phone/finish"] { + let r = get(&e, uri, &s.cookies).await; + assert!(r.status != StatusCode::INTERNAL_SERVER_ERROR); + } + assert!( + testkit::pending_challenge_ids(&e.state, "dev_b") + .await + .is_empty() + ); +} + +#[tokio::test] +async fn sending_to_the_phone_needs_csrf_and_origin() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = second_step(&e, "bob").await; + let r = post_form(&e, "/login/2fa/phone", &s.cookies, &[("csrf", "forged")]).await; + assert_eq!(r.status, StatusCode::FORBIDDEN); + let r = send( + &e, + "POST", + "/login/2fa/phone", + &[ + ("content-type", "application/x-www-form-urlencoded"), + ("cookie", &s.cookies), + ("origin", "http://evil.test"), + ], + Body::from(form(&[("csrf", &s.csrf)])), + ) + .await; + assert_eq!(r.status, StatusCode::FORBIDDEN); + let r = post_form(&e, "/login/2fa/phone", "", &[("csrf", &s.csrf)]).await; + assert_eq!( + r.h("location"), + "/login", + "no ticket goes back to the password" + ); + assert!( + testkit::pending_challenge_ids(&e.state, "dev_b") + .await + .is_empty() + ); +} + +#[tokio::test] +async fn approve_then_finish_opens_a_session() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = second_step(&e, "bob").await; + let r = send_to_phone(&e, &s).await; + assert_eq!(r.status, StatusCode::OK, "{}", r.text()); + assert_eq!(r.h("cache-control"), "no-store"); + let page = r.text(); + let id = challenge_id_of(&page); + assert!(page.contains("/static/phone.js") && page.contains("data-status-url")); + assert!( + page.contains("class=\"phone-number\""), + "the number is shown" + ); + let status_uri = format!("/login/2fa/phone/status?id={id}"); + let st = get(&e, &status_uri, &s.ticket).await; + assert_eq!(st.status, StatusCode::OK); + assert_eq!(st.h("cache-control"), "no-store"); + assert_eq!(st.text(), r#"{"status":"pending"}"#); + assert!(testkit::resolve_challenge(&e.state, &id, "approved").await); + assert_eq!( + get(&e, &status_uri, &s.ticket).await.text(), + r#"{"status":"approved"}"# + ); + + let lc = r.cookie("rostdav_login").expect("fresh login cookie"); + let both = format!("{}; {lc}", s.ticket); + let fin = post_form( + &e, + "/login/2fa/phone/finish", + &both, + &[("csrf", &extract_csrf(&page)), ("id", &id)], + ) + .await; + assert_eq!(fin.status, StatusCode::SEE_OTHER, "{}", fin.text()); + assert_eq!(fin.h("location"), "/files"); + let session = fin.cookie("rostdav_session").expect("session cookie"); + assert_eq!(get(&e, "/files", &session).await.status, StatusCode::OK); + // the ticket cookie is cleared + assert!( + fin.cookies() + .iter() + .any(|c| c.starts_with("rostdav_2fa=") && c.contains("Max-Age=0")) + ); + // a second finish gives nothing + let again = post_form( + &e, + "/login/2fa/phone/finish", + &both, + &[("csrf", &extract_csrf(&page)), ("id", &id)], + ) + .await; + assert!( + again.cookie("rostdav_session").is_none(), + "{}", + again.text() + ); +} + +#[tokio::test] +async fn finish_before_approval_opens_no_session() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = second_step(&e, "bob").await; + let r = send_to_phone(&e, &s).await; + let page = r.text(); + let id = challenge_id_of(&page); + let lc = r.cookie("rostdav_login").expect("login cookie"); + let fin = post_form( + &e, + "/login/2fa/phone/finish", + &format!("{}; {lc}", s.ticket), + &[("csrf", &extract_csrf(&page)), ("id", &id)], + ) + .await; + assert!(fin.cookie("rostdav_session").is_none()); + assert_ne!(fin.status, StatusCode::SEE_OTHER, "{}", fin.text()); +} + +#[tokio::test] +async fn a_denied_request_shows_a_message_and_opens_no_session() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = second_step(&e, "bob").await; + let r = send_to_phone(&e, &s).await; + let page = r.text(); + let id = challenge_id_of(&page); + assert!(testkit::resolve_challenge(&e.state, &id, "denied").await); + let lc = r.cookie("rostdav_login").expect("login cookie"); + let fin = post_form( + &e, + "/login/2fa/phone/finish", + &format!("{}; {lc}", s.ticket), + &[("csrf", &extract_csrf(&page)), ("id", &id)], + ) + .await; + assert!(fin.cookie("rostdav_session").is_none()); + assert!(fin.text().contains("denied"), "{}", fin.text()); +} + +#[tokio::test] +async fn status_of_another_sign_ins_challenge_is_404() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + phone_user(&e, "anna", "dev_a").await; + let sb = second_step(&e, "bob").await; + let r = send_to_phone(&e, &sb).await; + let id = challenge_id_of(&r.text()); + let sa = second_step(&e, "anna").await; + let st = get(&e, &format!("/login/2fa/phone/status?id={id}"), &sa.ticket).await; + assert_eq!(st.status, StatusCode::NOT_FOUND); + let none = get(&e, &format!("/login/2fa/phone/status?id={id}"), "").await; + assert_eq!(none.status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn the_number_is_on_the_page_and_nowhere_else() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = second_step(&e, "bob").await; + let r = send_to_phone(&e, &s).await; + let page = r.text(); + let number = between(&page, "

", "

"); + assert_eq!(number.len(), 2, "{number}"); + let id = challenge_id_of(&page); + let st = get(&e, &format!("/login/2fa/phone/status?id={id}"), &s.ticket).await; + assert!(!st.text().contains(&number), "{}", st.text()); + let js = send(&e, "GET", "/static/phone.js", &[], Body::empty()).await; + assert_eq!(js.status, StatusCode::OK); +} diff --git a/docs/admin.md b/docs/admin.md index 9b397eb..c64a228 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -131,7 +131,7 @@ Users turn on TOTP (authenticator app) themselves under Devices & security; see ## Push sign-in approval (RustMFA) -Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa.md)). The server side of the phone API is implemented (`/api/v1/devices/…`, `/api/v1/mfa/challenges/…`). Creating an enrollment token in the web interface, the sign-in page step and push delivery (FCM) are not built yet, so no user can enroll a phone through the web interface so far. +Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa.md)). The server side of the phone API is implemented (`/api/v1/devices/…`, `/api/v1/mfa/challenges/…`). The browser sign-in has the phone step. Creating an enrollment token in the web interface, the Login Flow v2 step and push delivery (FCM) are not built yet: so far no user can enroll a phone through the web interface, and a phone only sees a request when its app polls. | Key | Default | Meaning | |---|---|---| diff --git a/docs/security.md b/docs/security.md index a217239..761a363 100644 --- a/docs/security.md +++ b/docs/security.md @@ -31,7 +31,7 @@ Binding requirements: section 5 of the [instructions](instructions.md). Short ve | D2 | Orphaned uploads fill the disk | Expiry time, cleanup job | open (M5) | | E1 | User calls an admin function | Role check in the service, not only in the UI | open (M7) | | E2 | XSS via file names | Automatic escaping (askama), strict CSP | open (M11) | -| S4 | Approval of a sign-in by an attacker (prompt bombing, a tap without looking, a stolen enrollment QR code) | Push is only a hint: the phone fetches the challenge from the server and shows the sign-in device; the user must pick the two-digit number shown in the browser (number matching), a wrong number denies the challenge. The answer is signed with a biometric-gated key; the server checks that signature before the number. At most five challenges per user in 15 minutes. Enrollment tokens are single use, short lived and stored only as a hash; the phone proves a hardware-backed key (attestation `required` by default, `off` is an admin decision with a stated trade-off). | M14 backend implemented, sign-in page open (ADR 0014) | +| S4 | Approval of a sign-in by an attacker (prompt bombing, a tap without looking, a stolen enrollment QR code) | Push is only a hint: the phone fetches the challenge from the server and shows the sign-in device; the user must pick the two-digit number shown in the browser (number matching), a wrong number denies the challenge. The answer is signed with a biometric-gated key; the server checks that signature before the number. At most five challenges per user in 15 minutes. Enrollment tokens are single use, short lived and stored only as a hash; the phone proves a hardware-backed key (attestation `required` by default, `off` is an admin decision with a stated trade-off). | M14 backend and browser sign-in step implemented, Login Flow v2 step open (ADR 0014) | | S5 | Replay or forgery of phone requests | Every request carries a timestamp (60 seconds), a nonce (used once in 5 minutes) and a signature by the phone's request key over method, path, body hash and audience; DER signatures are rejected; five failures per phone in 15 minutes block it for 15 minutes; revoked phones and disabled accounts are refused. | M14 backend implemented | ## Cryptography diff --git a/docs/ui.md b/docs/ui.md index b854cde..020ad3e 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -54,6 +54,7 @@ Avoid collisions with Nextcloud paths that the apps call: check the UI routes ag | `/admin/security`, `POST /admin/security/{maintenance,unlock}` | Administration: maintenance mode on/off; active sign-in lockouts (address, user, two-factor) with "Unlock" | | `/login/2fa`, `/settings/2fa[/disable\|/codes]` | Second sign-in step; set up, turn off and recovery codes of two-factor authentication (ADR 0007); admin reset at `/admin/users/two-factor-reset` | | (all sign-in pages) | Layout of `layout::bare`: a dark brand panel with the full logo (`assets/brand/rostdav-logo-dark.svg`, put into the page so the wordmark uses Inter; the file's own dark tile is cut out) beside the form; below 860 px the panel is a band above it. This replaces the single folder tile of `design/mockups/web-ui/Login.html`; the mockup was not updated. | +| `/login/2fa/phone`, `/login/2fa/phone/status`, `/login/2fa/phone/finish` | Phone step of the browser sign-in (ADR 0014): "Send to my phone" (POST, CSRF) shows the number, a script (`/static/phone.js`) polls the status (JSON without the number) and submits the finish form; without the script a "Continue" button does the same. All three need the ticket cookie of the password step. | | `/login/v2/flow/` | Connect device (Login Flow v2; second step on the same page) | | `/static/{app,login}.css` | Embedded stylesheets | From 165394d91ad89ed6049c68a9007acf410bb23e06 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 21:39:29 +0100 Subject: [PATCH 20/24] feat(mfa): approve a Login Flow v2 sign-in on the phone The flow page keeps its state in the form: the phone step is the form actions phone and phone_finish, the status poll is a GET with the flow's ticket. A ticket of another flow cannot finish this one. --- CHANGELOG.md | 2 +- crates/rostdav-app/src/login_flow.rs | 82 ++++++++ crates/rostdav-web/src/lib.rs | 4 + crates/rostdav-web/src/login_page.rs | 290 ++++++++++++++++++++++++--- crates/rostdav-web/src/phone_step.rs | 63 ++++-- crates/rostdav-web/static/phone.js | 5 +- crates/rostdav/tests/web.rs | 247 +++++++++++++++++++++++ docs/admin.md | 2 +- docs/security.md | 2 +- docs/ui.md | 2 +- 10 files changed, 658 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 622ea9f..2d8bb9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https ### Added -- **Sign in with the RustMFA phone in the browser**: the second step offers "Send to my phone" (next to the code form when authenticator codes are on). The page shows a number, the user picks it on the phone, and the page signs in once the phone approved. A denied or expired request goes back to the second step. The Login Flow v2 page follows. +- **Sign in with the RustMFA phone in the browser**: the second step offers "Send to my phone" (next to the code form when authenticator codes are on). The page shows a number, the user picks it on the phone, and the page signs in once the phone approved. A denied or expired request goes back to the second step. The Login Flow v2 page (connecting a Nextcloud app) has the same step. - **A sign-in approval belongs to one sign-in**: a challenge started for a sign-in can only be polled and turned into a session by the browser that passed the password step, and only once; a phone revoked after it approved releases nothing. Not reachable from the sign-in page yet. - **Settings for push sign-in approval** (`[mfa]` section: challenge lifetime, enrollment lifetime, audience, attestation policy), the first step of ADR 0014. They have no effect yet. - **Database tables for push sign-in approval** (migration 0013: phones, enrollment tokens, challenges, request nonces). Nothing uses them yet. diff --git a/crates/rostdav-app/src/login_flow.rs b/crates/rostdav-app/src/login_flow.rs index e280c71..14b2dd2 100644 --- a/crates/rostdav-app/src/login_flow.rs +++ b/crates/rostdav-app/src/login_flow.rs @@ -9,6 +9,7 @@ use rostdav_core::{Error, Result}; use serde_json::json; use crate::AppState; +use crate::push_mfa::{ChallengeStatus, LoginChallenge, MfaError, SecondStepOptions}; use crate::secrets::{flow_key, hash_token, mac, mac_eq, open, random_token, seal}; const TOKEN_LEN: usize = 128; @@ -147,6 +148,87 @@ impl AppState { self.grant_flow_to(&flow, &p, ip).await } + /// Whether the CSRF value belongs to this flow's form. + pub fn flow_check_csrf(&self, login_token: &str, csrf: &str) -> Result<()> { + self.check_csrf(login_token, "allow", csrf) + } + + /// What the second step of this flow can offer; `None` if the ticket is not genuine, current + /// and issued for this flow. + pub async fn flow_second_step_options( + &self, + login_token: &str, + ticket: &str, + ) -> Option { + let user_id = self.second_factor_ticket_user(ticket, login_token)?; + Some(SecondStepOptions { + totp: self.inner.db.totp_enabled(user_id).await.ok()?, + phone: self.inner.db.mfa_active_device_count(user_id).await.ok()? > 0, + }) + } + + /// The phone step of the flow: starts the challenge. The flow must still be open. + pub async fn flow_phone_start( + &self, + login_token: &str, + ticket: &str, + login_device: &str, + ip: IpAddr, + ) -> Result { + self.open_flow(login_token).await?; + self.mfa_login_start(ticket, login_token, login_device, ip) + .await + } + + /// Where the flow's challenge stands. + pub async fn flow_phone_status( + &self, + login_token: &str, + ticket: &str, + challenge_id: &str, + ) -> Result { + self.mfa_login_status(ticket, login_token, challenge_id) + .await + } + + /// An approved challenge becomes the app password for the poller, once. + pub async fn flow_phone_finish( + &self, + login_token: &str, + ticket: &str, + challenge_id: &str, + ip: IpAddr, + ) -> Result<(), MfaError> { + let flow = self.open_flow(login_token).await?; + let user_id = self + .mfa_login_release(ticket, login_token, challenge_id) + .await?; + let u = self + .inner + .db + .find_user_by_id(user_id) + .await? + .filter(|u| !u.disabled) + .ok_or(MfaError::Unauthenticated)?; + let p = crate::auth::Principal { + user_id: u.id, + name: u.name, + is_admin: u.is_admin, + via: crate::auth::Via::AccountPassword, + scope: crate::auth::Scope::Full, + }; + self.grant_flow_to(&flow, &p, ip).await?; + Ok(()) + } + + async fn open_flow(&self, login_token: &str) -> Result { + self.inner + .db + .open_flow_by_login(&hash_token(login_token)) + .await? + .ok_or(MfaError::NotFound) + } + async fn grant_flow_to( &self, flow: &rostdav_meta::LoginFlow, diff --git a/crates/rostdav-web/src/lib.rs b/crates/rostdav-web/src/lib.rs index 0519c7b..e2236cf 100644 --- a/crates/rostdav-web/src/lib.rs +++ b/crates/rostdav-web/src/lib.rs @@ -157,6 +157,10 @@ pub fn router() -> Router { "/login/v2/flow/{token}", get(login_page::show).post(login_page::submit), ) + .route( + "/login/v2/flow/{token}/phone-status", + get(login_page::phone_status), + ) .route( "/index.php/login/v2/flow/{token}", get(login_page::show).post(login_page::submit), diff --git a/crates/rostdav-web/src/login_page.rs b/crates/rostdav-web/src/login_page.rs index 385d115..0222b99 100644 --- a/crates/rostdav-web/src/login_page.rs +++ b/crates/rostdav-web/src/login_page.rs @@ -4,16 +4,17 @@ use std::net::{IpAddr, Ipv4Addr}; use axum::Form; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::request::Parts; use axum::http::{HeaderValue, StatusCode, header}; use axum::response::Response; -use rostdav_app::AppState; +use rostdav_app::{AppState, ChallengeStatus, MfaError}; use rostdav_core::{ClientIp, Error}; use serde::Deserialize; use crate::i18n::{Lang, t, tf}; use crate::layout::esc; +use crate::phone_step::{NumberCard, PHONE_SCRIPT, number_card}; use crate::session::accept_lang; use crate::twofactor::{StepCard, code_error_text, second_step_card}; @@ -100,7 +101,7 @@ struct Step<'a> { status: StatusCode, } -fn second_page(lang: Lang, state: &AppState, s: &Step<'_>) -> Response { +async fn second_page(lang: Lang, state: &AppState, s: &Step<'_>) -> Response { let Step { token, client, @@ -119,11 +120,22 @@ fn second_page(lang: Lang, state: &AppState, s: &Step<'_>) -> Response { } else { "button:recovery" }; - page( - lang, - status, - "Confirm your sign-in", - &second_step_card( + // a ticket that does not check out gets the code form, as before + let opts = state + .flow_second_step_options(token, ticket) + .await + .unwrap_or(rostdav_app::SecondStepOptions { + totp: true, + phone: false, + }); + let phone = if opts.phone { + let err = if opts.totp { None } else { error }; + phone_card(lang, &csrf, &hidden, client, err) + } else { + String::new() + }; + let code = if opts.totp || !opts.phone { + second_step_card( lang, &StepCard { action: "", @@ -135,7 +147,34 @@ fn second_page(lang: Lang, state: &AppState, s: &Step<'_>) -> Response { toggle_href: toggle, client: Some(client), }, - ), + ) + } else { + String::new() + }; + page( + lang, + status, + "Confirm your sign-in", + &format!("{phone}{code}"), + ) +} + +/// "Send to my phone": a button of its own form (the flow keeps its state in the form). +fn phone_card(lang: Lang, csrf: &str, hidden: &str, client: &str, error: Option<&str>) -> String { + let err = error + .map(|e| format!("
{}
", esc(e))) + .unwrap_or_default(); + format!( + "
{}{}
\ +

{}

{}

{err}\ +
{hidden}\ +
", + esc(&t(lang, "Step 2 of 2")), + esc(client), + esc(&t(lang, "Confirm your sign-in")), + esc(&t(lang, "Approve the request on your RustMFA phone.")), + esc(csrf), + esc(&t(lang, "Send to my phone")), ) } @@ -175,6 +214,9 @@ pub struct Submit { ticket: String, #[serde(default)] code: String, + /// Phone step: the challenge being finished. + #[serde(default)] + id: String, } pub async fn submit( @@ -202,6 +244,10 @@ pub async fn submit( }; } + if matches!(f.action.as_str(), "phone" | "phone_finish") { + return phone_action(&state, &token, &parts, &f, lang, ip).await; + } + if matches!(f.action.as_str(), "second" | "recovery" | "totp") { let Ok(Some(client)) = state.login_flow_client(&token).await else { return message( @@ -224,7 +270,8 @@ pub async fn submit( error: None, status: StatusCode::OK, }, - ); + ) + .await; } return match state .flow_second_factor(&token, &f.csrf, &f.ticket, &f.code, ip) @@ -256,7 +303,8 @@ pub async fn submit( error: Some(&text), status, }, - ); + ) + .await; if let Error::RateLimited { retry_after_secs } = e && let Ok(v) = HeaderValue::from_str(&retry_after_secs.to_string()) { @@ -280,18 +328,21 @@ pub async fn submit( ), Ok(rostdav_app::FlowStep::SecondFactor { ticket }) => { match state.login_flow_client(&token).await { - Ok(Some(client)) => second_page( - lang, - &state, - &Step { - token: &token, - client: &client, - ticket: &ticket, - recovery: false, - error: None, - status: StatusCode::OK, - }, - ), + Ok(Some(client)) => { + second_page( + lang, + &state, + &Step { + token: &token, + client: &client, + ticket: &ticket, + recovery: false, + error: None, + status: StatusCode::OK, + }, + ) + .await + } _ => message( lang, StatusCode::NOT_FOUND, @@ -349,6 +400,199 @@ pub async fn submit( } } +/// The phone step of the flow: "phone" starts a challenge and shows the number, "phone_finish" +/// turns the approval into the app password. +async fn phone_action( + state: &AppState, + token: &str, + parts: &Parts, + f: &Submit, + lang: Lang, + ip: IpAddr, +) -> Response { + let Ok(Some(client)) = state.login_flow_client(token).await else { + return message( + lang, + StatusCode::NOT_FOUND, + "Link invalid or expired", + "Start the connection again from your app.", + ); + }; + if state.flow_check_csrf(token, &f.csrf).is_err() { + return forbidden(lang); + } + let step = |error: &str, status: StatusCode| { + let (token, client, ticket) = (token.to_owned(), client.clone(), f.ticket.clone()); + let error = error.to_owned(); + async move { + second_page( + lang, + state, + &Step { + token: &token, + client: &client, + ticket: &ticket, + recovery: false, + error: Some(&error), + status, + }, + ) + .await + } + }; + if f.action == "phone" { + let device = parts + .headers + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let device: String = device + .chars() + .filter(|c| !c.is_control()) + .take(100) + .collect(); + let device = if device.trim().is_empty() { + "Web browser".to_owned() + } else { + device + }; + return match state.flow_phone_start(token, &f.ticket, &device, ip).await { + Ok(c) => { + let csrf = state.flow_csrf(token, "allow"); + let ticket = esc(&f.ticket); + let status_url = format!( + "/login/v2/flow/{}/phone-status?ticket={ticket}&id={}", + esc(token), + esc(&c.challenge_id) + ); + let hidden = format!( + "\ + " + ); + let back = format!("/login/v2/flow/{token}"); + let card = number_card( + lang, + &c, + &NumberCard { + csrf: &csrf, + status_url: &status_url, + action: "", + hidden: &hidden, + back_href: &back, + }, + ); + let mut r = crate::layout::bare_with( + lang, + StatusCode::OK, + "Confirm your sign-in", + &card, + PHONE_SCRIPT, + ); + r.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + r + } + Err(MfaError::Unauthenticated) => { + step( + &t(lang, "Sign-in with the phone failed."), + StatusCode::UNAUTHORIZED, + ) + .await + } + Err(MfaError::NotFound) => { + step( + &t(lang, "No phone is enrolled for this account."), + StatusCode::NOT_FOUND, + ) + .await + } + Err(MfaError::RateLimited { retry_after_secs }) => { + let mut r = step( + &t(lang, "Too many requests. Try again later."), + StatusCode::TOO_MANY_REQUESTS, + ) + .await; + if let Ok(v) = HeaderValue::from_str(&retry_after_secs.to_string()) { + r.headers_mut().insert(header::RETRY_AFTER, v); + } + r + } + Err(_) => { + step( + &t(lang, "Sign-in with the phone failed."), + StatusCode::CONFLICT, + ) + .await + } + }; + } + match state.flow_phone_finish(token, &f.ticket, &f.id, ip).await { + Ok(()) => message( + lang, + StatusCode::OK, + "Device connected", + "You can close this window and return to your app.", + ), + Err(MfaError::Unauthenticated) => { + step( + &t(lang, "Sign-in with the phone failed."), + StatusCode::UNAUTHORIZED, + ) + .await + } + Err(e) => { + let text = match state.flow_phone_status(token, &f.ticket, &f.id).await { + Ok(ChallengeStatus::Pending) => t(lang, "Not approved yet."), + Ok(ChallengeStatus::Denied) => t(lang, "The request was denied on the phone."), + _ if matches!(e, MfaError::Expired) => { + t(lang, "The request expired. Send a new one.") + } + _ if matches!(e, MfaError::AlreadyConsumed) => { + t(lang, "This approval was already used. Sign in again.") + } + _ => t(lang, "Sign-in with the phone failed."), + }; + let status = match e { + MfaError::Expired => StatusCode::GONE, + MfaError::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::CONFLICT, + }; + step(&text, status).await + } + } +} + +#[derive(Deserialize)] +pub struct PhoneStatusQuery { + #[serde(default)] + ticket: String, + #[serde(default)] + id: String, +} + +/// GET /login/v2/flow//phone-status: where the challenge stands, as JSON (no number). +pub async fn phone_status( + State(state): State, + Path(token): Path, + Query(q): Query, +) -> Response { + use axum::response::IntoResponse; + let r = match state.flow_phone_status(&token, &q.ticket, &q.id).await { + Ok(s) => ( + [(header::CONTENT_TYPE, "application/json")], + format!("{{\"status\":\"{}\"}}", s.as_str()), + ) + .into_response(), + Err(MfaError::Unauthenticated) => StatusCode::UNAUTHORIZED.into_response(), + Err(MfaError::NotFound) => StatusCode::NOT_FOUND.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + let mut r = r; + r.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + r +} + fn forbidden(lang: Lang) -> Response { message( lang, diff --git a/crates/rostdav-web/src/phone_step.rs b/crates/rostdav-web/src/phone_step.rs index 064a45f..2e0e6d4 100644 --- a/crates/rostdav-web/src/phone_step.rs +++ b/crates/rostdav-web/src/phone_step.rs @@ -17,7 +17,7 @@ use crate::session::{ }; use crate::twofactor::signed_in; -const PHONE_SCRIPT: &str = ""; +pub(crate) const PHONE_SCRIPT: &str = ""; fn user_agent(headers: &HeaderMap) -> &str { headers @@ -128,37 +128,76 @@ pub async fn start( } } -fn number_page( - state: &AppState, - headers: &HeaderMap, +/// What the number card needs besides the challenge. +pub(crate) struct NumberCard<'a> { + pub csrf: &'a str, + /// Where the page polls; already a safe attribute value. + pub status_url: &'a str, + /// Where the finish form posts to. + pub action: &'a str, + /// More hidden fields of the finish form (already escaped markup). + pub hidden: &'a str, + /// Where "Use another method" and a denied or expired request lead. + pub back_href: &'a str, +} + +/// The card with the number, the waiting area (polled by `phone.js`) and the finish form. +pub(crate) fn number_card( lang: Lang, c: &rostdav_app::LoginChallenge, -) -> Response { - let (cookie, csrf) = state.login_csrf_issue(); + n: &NumberCard<'_>, +) -> String { let id = esc(&c.challenge_id); - let body = format!( + format!( "
{}
\

{}

{}

\

{}

\ -
{}
\ -
\ - \ +
{}
\ + \ + {}\
\ -

{}

\ +

{}

\
{}
", esc(&t(lang, "Step 2 of 2")), esc(&t(lang, "Approve on your phone")), esc(&t(lang, "Pick this number on your phone:")), c.number, + n.status_url, + esc(n.back_href), esc(&t(lang, "Waiting for approval…")), - esc(&csrf), + esc(n.action), + esc(n.csrf), + n.hidden, esc(&t(lang, "Continue")), + esc(n.back_href), esc(&t(lang, "Use another method")), esc(&tf( lang, "Valid for {0} seconds.", &[&c.expires_in_seconds.to_string()] )), + ) +} + +/// The page for the browser sign-in (it has its own LOGIN cookie for the finish form). +fn number_page( + state: &AppState, + headers: &HeaderMap, + lang: Lang, + c: &rostdav_app::LoginChallenge, +) -> Response { + let (cookie, csrf) = state.login_csrf_issue(); + let status_url = format!("/login/2fa/phone/status?id={}", esc(&c.challenge_id)); + let body = number_card( + lang, + c, + &NumberCard { + csrf: &csrf, + status_url: &status_url, + action: "/login/2fa/phone/finish", + hidden: "", + back_href: "/login/2fa", + }, ); let mut r = layout::bare_with( lang, diff --git a/crates/rostdav-web/static/phone.js b/crates/rostdav-web/static/phone.js index 056d2db..2510043 100644 --- a/crates/rostdav-web/static/phone.js +++ b/crates/rostdav-web/static/phone.js @@ -1,5 +1,5 @@ // Sign-in with the RustMFA phone: asks the server every two seconds whether the phone has -// answered.
marks the waiting area. When the +// answered.
marks the waiting area. When the // answer is "approved" the page submits the finish form; when it was denied or has expired the // page goes back to the second step. Without this script the form's button does the same. (function () { @@ -8,6 +8,7 @@ if (!box || !window.fetch) return; var url = box.getAttribute("data-status-url"); var form = document.getElementById(box.getAttribute("data-finish-form")); + var back = box.getAttribute("data-back-url") || "/login/2fa"; var done = false; function poll() { if (done) return; @@ -16,7 +17,7 @@ .then(function (j) { if (!j || done) return; if (j.status === "approved") { done = true; if (form) form.submit(); } - else if (j.status === "denied" || j.status === "expired") { done = true; location.href = "/login/2fa"; } + else if (j.status === "denied" || j.status === "expired") { done = true; location.href = back; } }) .catch(function () {}); } diff --git a/crates/rostdav/tests/web.rs b/crates/rostdav/tests/web.rs index 489eb55..aa2dfd6 100644 --- a/crates/rostdav/tests/web.rs +++ b/crates/rostdav/tests/web.rs @@ -5773,3 +5773,250 @@ async fn the_number_is_on_the_page_and_nowhere_else() { let js = send(&e, "GET", "/static/phone.js", &[], Body::empty()).await; assert_eq!(js.status, StatusCode::OK); } + +// ---- Login Flow v2 with the RustMFA phone -------------------------------------------------------- + +struct FlowStep { + url: String, + poll_token: String, + csrf: String, + ticket: String, + html: String, +} + +async fn flow_post(e: &Env, url: &str, fields: &[(&str, &str)]) -> Res { + send( + e, + "POST", + url, + &[ + ("content-type", "application/x-www-form-urlencoded"), + ("origin", &origin()), + ], + Body::from(form(fields)), + ) + .await +} + +/// A started flow whose password step is done: the second-step page of `user`. +async fn flow_second_step(e: &Env, user: &str) -> FlowStep { + let flow = e + .state + .start_login_flow("Nextcloud Android") + .await + .expect("flow"); + let url = format!("/login/v2/flow/{}", flow.login_token); + let page = send(e, "GET", &url, &[], Body::empty()).await; + let csrf = extract_csrf(&page.text()); + let r = flow_post( + e, + &url, + &[ + ("csrf", &csrf), + ("user", user), + ("password", PW), + ("action", "allow"), + ], + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.text()); + let html = r.text(); + let ticket = between(&html, "name=\"ticket\" value=\"", "\""); + FlowStep { + url, + poll_token: flow.poll_token, + csrf, + ticket, + html, + } +} + +async fn flow_send_to_phone(e: &Env, s: &FlowStep) -> Res { + flow_post( + e, + &s.url, + &[ + ("csrf", &s.csrf), + ("ticket", &s.ticket), + ("action", "phone"), + ], + ) + .await +} + +async fn flow_finish(e: &Env, s: &FlowStep, id: &str) -> Res { + flow_post( + e, + &s.url, + &[ + ("csrf", &s.csrf), + ("ticket", &s.ticket), + ("id", id), + ("action", "phone_finish"), + ], + ) + .await +} + +fn flow_status_url(page: &str) -> String { + between(page, "data-status-url=\"", "\"").replace("&", "&") +} + +#[tokio::test] +async fn flow_with_a_phone_offers_it_and_grants_after_approval() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = flow_second_step(&e, "bob").await; + assert!( + s.html.contains("Send to my phone") && s.html.contains("value=\"phone\""), + "{}", + s.html + ); + assert!( + !s.html.contains("name=\"code\""), + "no code form without TOTP" + ); + let r = flow_send_to_phone(&e, &s).await; + assert_eq!(r.status, StatusCode::OK, "{}", r.text()); + let page = r.text(); + assert!(page.contains("class=\"phone-number\"") && page.contains("/static/phone.js")); + let id = challenge_id_of(&page); + let status_url = flow_status_url(&page); + let st = send(&e, "GET", &status_url, &[], Body::empty()).await; + assert_eq!( + (st.status, st.text()), + (StatusCode::OK, r#"{"status":"pending"}"#.to_owned()) + ); + assert_eq!(st.h("cache-control"), "no-store"); + assert!( + e.state + .poll_login_flow(&s.poll_token) + .await + .expect("poll") + .is_none(), + "nothing granted before the approval" + ); + assert!(testkit::resolve_challenge(&e.state, &id, "approved").await); + let fin = flow_finish(&e, &s, &id).await; + assert_eq!(fin.status, StatusCode::OK, "{}", fin.text()); + assert!(fin.text().contains("Device connected")); + let granted = e + .state + .poll_login_flow(&s.poll_token) + .await + .expect("poll") + .expect("credentials"); + assert_eq!(granted.login_name, "bob"); + // a second finish is refused and creates nothing + let again = flow_finish(&e, &s, &id).await; + assert!( + !again.text().contains("Device connected"), + "{}", + again.text() + ); +} + +#[tokio::test] +async fn flow_finish_before_approval_grants_nothing() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = flow_second_step(&e, "bob").await; + let id = challenge_id_of(&flow_send_to_phone(&e, &s).await.text()); + let fin = flow_finish(&e, &s, &id).await; + assert!(!fin.text().contains("Device connected")); + assert!( + e.state + .poll_login_flow(&s.poll_token) + .await + .expect("poll") + .is_none() + ); +} + +#[tokio::test] +async fn a_denied_flow_request_grants_nothing() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = flow_second_step(&e, "bob").await; + let id = challenge_id_of(&flow_send_to_phone(&e, &s).await.text()); + assert!(testkit::resolve_challenge(&e.state, &id, "denied").await); + let fin = flow_finish(&e, &s, &id).await; + assert!(fin.text().contains("denied"), "{}", fin.text()); + assert!( + e.state + .poll_login_flow(&s.poll_token) + .await + .expect("poll") + .is_none() + ); +} + +#[tokio::test] +async fn flow_phone_start_needs_the_csrf_value_and_a_genuine_ticket() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = flow_second_step(&e, "bob").await; + let r = flow_post( + &e, + &s.url, + &[ + ("csrf", "forged"), + ("ticket", &s.ticket), + ("action", "phone"), + ], + ) + .await; + assert_eq!(r.status, StatusCode::FORBIDDEN); + let r = flow_post( + &e, + &s.url, + &[ + ("csrf", &s.csrf), + ("ticket", "1.99999999999.deadbeef"), + ("action", "phone"), + ], + ) + .await; + assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text()); + assert!( + testkit::pending_challenge_ids(&e.state, "dev_b") + .await + .is_empty() + ); +} + +#[tokio::test] +async fn a_ticket_of_another_flow_cannot_finish_this_one() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let a = flow_second_step(&e, "bob").await; + let b = flow_second_step(&e, "bob").await; + let id = challenge_id_of(&flow_send_to_phone(&e, &a).await.text()); + assert!(testkit::resolve_challenge(&e.state, &id, "approved").await); + // flow B's page with flow A's ticket and challenge + let r = flow_post( + &e, + &b.url, + &[ + ("csrf", &b.csrf), + ("ticket", &a.ticket), + ("id", &id), + ("action", "phone_finish"), + ], + ) + .await; + assert!(!r.text().contains("Device connected"), "{}", r.text()); + assert!( + e.state + .poll_login_flow(&b.poll_token) + .await + .expect("poll") + .is_none() + ); + // the status of A's challenge under B's address + let uri = format!("{}/phone-status?ticket={}&id={id}", b.url, a.ticket); + assert_ne!( + send(&e, "GET", &uri, &[], Body::empty()).await.status, + StatusCode::OK + ); +} diff --git a/docs/admin.md b/docs/admin.md index c64a228..60728d3 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -131,7 +131,7 @@ Users turn on TOTP (authenticator app) themselves under Devices & security; see ## Push sign-in approval (RustMFA) -Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa.md)). The server side of the phone API is implemented (`/api/v1/devices/…`, `/api/v1/mfa/challenges/…`). The browser sign-in has the phone step. Creating an enrollment token in the web interface, the Login Flow v2 step and push delivery (FCM) are not built yet: so far no user can enroll a phone through the web interface, and a phone only sees a request when its app polls. +Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa.md)). The server side of the phone API is implemented (`/api/v1/devices/…`, `/api/v1/mfa/challenges/…`). The browser sign-in and the Login Flow v2 page (connecting a Nextcloud app) have the phone step. Creating an enrollment token in the web interface and push delivery (FCM) are not built yet: so far no user can enroll a phone through the web interface, and a phone only sees a request when its app polls. | Key | Default | Meaning | |---|---|---| diff --git a/docs/security.md b/docs/security.md index 761a363..3f70cca 100644 --- a/docs/security.md +++ b/docs/security.md @@ -31,7 +31,7 @@ Binding requirements: section 5 of the [instructions](instructions.md). Short ve | D2 | Orphaned uploads fill the disk | Expiry time, cleanup job | open (M5) | | E1 | User calls an admin function | Role check in the service, not only in the UI | open (M7) | | E2 | XSS via file names | Automatic escaping (askama), strict CSP | open (M11) | -| S4 | Approval of a sign-in by an attacker (prompt bombing, a tap without looking, a stolen enrollment QR code) | Push is only a hint: the phone fetches the challenge from the server and shows the sign-in device; the user must pick the two-digit number shown in the browser (number matching), a wrong number denies the challenge. The answer is signed with a biometric-gated key; the server checks that signature before the number. At most five challenges per user in 15 minutes. Enrollment tokens are single use, short lived and stored only as a hash; the phone proves a hardware-backed key (attestation `required` by default, `off` is an admin decision with a stated trade-off). | M14 backend and browser sign-in step implemented, Login Flow v2 step open (ADR 0014) | +| S4 | Approval of a sign-in by an attacker (prompt bombing, a tap without looking, a stolen enrollment QR code) | Push is only a hint: the phone fetches the challenge from the server and shows the sign-in device; the user must pick the two-digit number shown in the browser (number matching), a wrong number denies the challenge. The answer is signed with a biometric-gated key; the server checks that signature before the number. At most five challenges per user in 15 minutes. Enrollment tokens are single use, short lived and stored only as a hash; the phone proves a hardware-backed key (attestation `required` by default, `off` is an admin decision with a stated trade-off). | M14 backend and sign-in steps (browser, Login Flow v2) implemented (ADR 0014) | | S5 | Replay or forgery of phone requests | Every request carries a timestamp (60 seconds), a nonce (used once in 5 minutes) and a signature by the phone's request key over method, path, body hash and audience; DER signatures are rejected; five failures per phone in 15 minutes block it for 15 minutes; revoked phones and disabled accounts are refused. | M14 backend implemented | ## Cryptography diff --git a/docs/ui.md b/docs/ui.md index 020ad3e..b2ddbac 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -55,7 +55,7 @@ Avoid collisions with Nextcloud paths that the apps call: check the UI routes ag | `/login/2fa`, `/settings/2fa[/disable\|/codes]` | Second sign-in step; set up, turn off and recovery codes of two-factor authentication (ADR 0007); admin reset at `/admin/users/two-factor-reset` | | (all sign-in pages) | Layout of `layout::bare`: a dark brand panel with the full logo (`assets/brand/rostdav-logo-dark.svg`, put into the page so the wordmark uses Inter; the file's own dark tile is cut out) beside the form; below 860 px the panel is a band above it. This replaces the single folder tile of `design/mockups/web-ui/Login.html`; the mockup was not updated. | | `/login/2fa/phone`, `/login/2fa/phone/status`, `/login/2fa/phone/finish` | Phone step of the browser sign-in (ADR 0014): "Send to my phone" (POST, CSRF) shows the number, a script (`/static/phone.js`) polls the status (JSON without the number) and submits the finish form; without the script a "Continue" button does the same. All three need the ticket cookie of the password step. | -| `/login/v2/flow/` | Connect device (Login Flow v2; second step on the same page) | +| `/login/v2/flow/` | Connect device (Login Flow v2; second step on the same page, with the phone step as the form actions `phone` and `phone_finish`; `/login/v2/flow//phone-status?ticket=…&id=…` is the JSON status the page polls. The ticket is in the query because the page polls with a plain GET; the access log does not record queries) | | `/static/{app,login}.css` | Embedded stylesheets | Route check against `docs/compatibility.md`: none of these paths is used by the Nextcloud apps' WebDAV, OCS or `status.php` calls. `/s/` is the address Nextcloud itself uses for public links, so links created through the OCS API open in this page. From 03fd31138701ebdbde24a18898454a5a950f6e54 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 21:50:36 +0100 Subject: [PATCH 21/24] feat(mfa): enroll and revoke phones in the web interface Devices & security lists the user's RustMFA phones with revoke and the last security events. Add a phone asks for the account password again and shows a single-use enrollment QR code. server.public_url (else the Host header) is the address the code carries. --- CHANGELOG.md | 1 + crates/rostdav-app/src/lib.rs | 5 +- crates/rostdav-app/src/push_mfa/enroll.rs | 47 +++ .../src/push_mfa/enroll_qr_tests.rs | 104 ++++++ crates/rostdav-app/src/push_mfa/mod.rs | 4 +- crates/rostdav-app/src/push_mfa/testkit.rs | 11 + crates/rostdav-app/src/two_factor.rs | 4 +- crates/rostdav-app/src/web.rs | 9 + crates/rostdav-web/i18n/de.tsv | 25 ++ crates/rostdav-web/src/lib.rs | 6 + crates/rostdav-web/src/pages_more.rs | 3 +- crates/rostdav-web/src/phones.rs | 310 ++++++++++++++++++ crates/rostdav/src/lib.rs | 1 + crates/rostdav/tests/web.rs | 169 ++++++++++ docs/admin.md | 2 +- docs/adr/0014-push-mfa.md | 2 +- docs/ui.md | 1 + 17 files changed, 696 insertions(+), 8 deletions(-) create mode 100644 crates/rostdav-app/src/push_mfa/enroll_qr_tests.rs create mode 100644 crates/rostdav-web/src/phones.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d8bb9d..83cfb61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https ### Added +- **Phones in the web interface**: Devices & security lists the user's RustMFA phones (name, status, last seen) with revoke, and the last security events. "Add a phone" asks for the account password again and shows a single-use enrollment QR code (`rustmfa://enroll?server=…&token=…`). Five codes per user in 15 minutes. Revoking a phone ends its open sign-in approvals. - **Sign in with the RustMFA phone in the browser**: the second step offers "Send to my phone" (next to the code form when authenticator codes are on). The page shows a number, the user picks it on the phone, and the page signs in once the phone approved. A denied or expired request goes back to the second step. The Login Flow v2 page (connecting a Nextcloud app) has the same step. - **A sign-in approval belongs to one sign-in**: a challenge started for a sign-in can only be polled and turned into a session by the browser that passed the password step, and only once; a phone revoked after it approved releases nothing. Not reachable from the sign-in page yet. - **Settings for push sign-in approval** (`[mfa]` section: challenge lifetime, enrollment lifetime, audience, attestation policy), the first step of ADR 0014. They have no effect yet. diff --git a/crates/rostdav-app/src/lib.rs b/crates/rostdav-app/src/lib.rs index ec02fa2..ae9e516 100644 --- a/crates/rostdav-app/src/lib.rs +++ b/crates/rostdav-app/src/lib.rs @@ -60,9 +60,10 @@ pub use password::{MIN_PASSWORD_CHARS, hash_password, verify_password}; pub use push_mfa::testkit; pub use push_mfa::{ AttestationPolicy, AttestationResult, ChallengeDetails, ChallengeStatus, DeviceInfo, - DevicePrincipal, EnrollToken, LoginChallenge, MfaAuditEvent, MfaConfig, MfaError, + DevicePrincipal, EnrollQr, EnrollToken, LoginChallenge, MfaAuditEvent, MfaConfig, MfaError, NewChallenge as MfaNewChallenge, NoopPush, ParsedAuth, PushChannel, PushFuture, RegisterInput, - Registered, SecondStepOptions, google_roots, parse_authorization, verify_attestation, + Registered, SecondStepOptions, enroll_qr_payload, google_roots, parse_authorization, + verify_attestation, }; pub use search::{KindFilter, MAX_VISITED, SearchHit, SearchPage, SearchQuery, SnippetPart}; pub use shares::{ diff --git a/crates/rostdav-app/src/push_mfa/enroll.rs b/crates/rostdav-app/src/push_mfa/enroll.rs index 14457b4..42b2bb1 100644 --- a/crates/rostdav-app/src/push_mfa/enroll.rs +++ b/crates/rostdav-app/src/push_mfa/enroll.rs @@ -92,6 +92,22 @@ fn validate(i: &RegisterInput) -> Result { Ok(name) } +/// The enrollment QR code: the token, what the code says, and the code as an inline SVG. +#[derive(Debug)] +pub struct EnrollQr { + pub token: EnrollToken, + pub payload: String, + pub qr_svg: String, +} + +/// What the QR code of the enrollment says: the server address and the token. +pub fn enroll_qr_payload(base_url: &str, token: &str) -> String { + format!( + "rustmfa://enroll?server={}&token={token}", + crate::two_factor::percent_label(base_url) + ) +} + impl AppState { /// A single-use token for the enrollment QR code. Only a user who signed in with the /// account itself may create one: not an app password, not a public link. @@ -120,6 +136,37 @@ impl AppState { }) } + /// The web interface asks for the account password again before it shows an enrollment QR + /// code: a stolen session cookie alone cannot add a phone. + pub async fn web_mfa_enroll_start( + &self, + s: &crate::WebSession, + password: &str, + server_url: &str, + ip: IpAddr, + ) -> Result { + let p = &s.principal; + if p.via != Via::AccountPassword || p.scope != Scope::Full { + return Err(MfaError::Unauthenticated); + } + match self.authenticate_with(&p.name, password, ip, false).await { + Ok(_) => {} + Err(rostdav_core::Error::Unauthenticated) => return Err(MfaError::Unauthenticated), + Err(rostdav_core::Error::RateLimited { retry_after_secs }) => { + return Err(MfaError::RateLimited { retry_after_secs }); + } + Err(e) => return Err(e.into()), + } + let token = self.mfa_create_enroll_token(p).await?; + let payload = enroll_qr_payload(server_url, &token.token); + let qr_svg = crate::two_factor::qr_svg(&payload)?; + Ok(EnrollQr { + token, + payload, + qr_svg, + }) + } + /// Registers a phone. A wrong token or a failed attestation counts against the caller's /// address (five in 15 minutes); a malformed request does not. The token is used up before /// the attestation is checked, so a failed attempt burns it. diff --git a/crates/rostdav-app/src/push_mfa/enroll_qr_tests.rs b/crates/rostdav-app/src/push_mfa/enroll_qr_tests.rs new file mode 100644 index 0000000..ae1b13f --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/enroll_qr_tests.rs @@ -0,0 +1,104 @@ +//! The enrollment QR code of the web interface. + +use std::net::{IpAddr, Ipv4Addr}; + +use super::MfaError; +use super::enroll::enroll_qr_payload; +use super::testkit::{add_user, state}; +use crate::{Scope, Via, WebSession}; + +const IP: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); + +#[test] +fn the_payload_escapes_the_server_address() { + assert_eq!( + enroll_qr_payload("https://cloud.example.org/x?y=1&z", "tok"), + "rustmfa://enroll?server=https%3A%2F%2Fcloud.example.org%2Fx%3Fy%3D1%26z&token=tok" + ); +} + +#[tokio::test] +async fn enrolling_needs_the_account_password() { + let (_d, st) = state().await; + let p = add_user(&st, "anna").await; + let s = WebSession { + id: 1, + principal: p, + }; + let r = st + .web_mfa_enroll_start(&s, "wrong-password", "http://h", IP) + .await; + assert!(matches!(r, Err(MfaError::Unauthenticated)), "{r:?}"); +} + +#[tokio::test] +async fn enrolling_gives_a_token_a_payload_and_a_qr_code() { + let (_d, st) = state().await; + let p = add_user(&st, "anna").await; + let s = WebSession { + id: 1, + principal: p, + }; + let q = st + .web_mfa_enroll_start(&s, "correct-horse-battery", "http://cloud.test", IP) + .await + .expect("enroll"); + assert_eq!(q.token.expires_in_seconds, 300); + assert_eq!( + q.payload, + format!( + "rustmfa://enroll?server=http%3A%2F%2Fcloud.test&token={}", + q.token.token + ) + ); + assert!( + q.qr_svg.starts_with(" Vec i64 { + st.inner + .db + .find_user(name) + .await + .expect("query") + .expect("user exists") + .id +} diff --git a/crates/rostdav-app/src/two_factor.rs b/crates/rostdav-app/src/two_factor.rs index efeffe0..969a3fe 100644 --- a/crates/rostdav-app/src/two_factor.rs +++ b/crates/rostdav-app/src/two_factor.rs @@ -588,7 +588,7 @@ fn matching_step(secret: &[u8], code: &str, last: i64) -> Option { found } -fn percent_label(s: &str) -> String { +pub(crate) fn percent_label(s: &str) -> String { let mut out = String::new(); for b in s.bytes() { if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { @@ -600,7 +600,7 @@ fn percent_label(s: &str) -> String { out } -fn qr_svg(uri: &str) -> Result { +pub(crate) fn qr_svg(uri: &str) -> Result { let code = qrcode::QrCode::new(uri.as_bytes()).map_err(|_| Error::Unavailable)?; let svg = code .render::() diff --git a/crates/rostdav-app/src/web.rs b/crates/rostdav-app/src/web.rs index 09a490d..3961717 100644 --- a/crates/rostdav-app/src/web.rs +++ b/crates/rostdav-app/src/web.rs @@ -21,6 +21,9 @@ pub struct WebOptions { pub idle_secs: i64, /// Seconds after which a session ends regardless of activity. pub max_secs: i64, + /// Externally visible base URL (`server.public_url`); the enrollment QR code of the RustMFA + /// app carries it. `None`: the pages use the `Host` header of the request. + pub public_url: Option, } impl Default for WebOptions { @@ -28,6 +31,7 @@ impl Default for WebOptions { Self { idle_secs: 8 * 3600, max_secs: 14 * 86_400, + public_url: None, } } } @@ -66,6 +70,11 @@ const PREFS: &[(&str, &[&str])] = &[ impl AppState { // ---- sessions ---------------------------------------------------------------- + /// `server.public_url`, if it is set. + pub fn public_url(&self) -> Option { + self.cfg().web.public_url + } + /// Checks the account password. Without a second factor the session is opened; /// with one the caller gets a ticket for the second step (ADR 0007). pub async fn web_sign_in( diff --git a/crates/rostdav-web/i18n/de.tsv b/crates/rostdav-web/i18n/de.tsv index c40508a..eb9a671 100644 --- a/crates/rostdav-web/i18n/de.tsv +++ b/crates/rostdav-web/i18n/de.tsv @@ -471,3 +471,28 @@ No phone is enrolled for this account. Für dieses Konto ist kein Handy eingeric Too many requests. Try again later. Zu viele Anfragen. Versuche es später noch einmal. Sign-in with the phone failed. Die Anmeldung mit dem Handy ist fehlgeschlagen. Continue Weiter +Phones (RustMFA) Handys (RustMFA) +Add a phone Handy hinzufügen +No phone is set up for sign-in approval. Kein Handy für die Anmelde-Bestätigung eingerichtet. +Approve browser sign-ins on your phone with the RustMFA app. Once a phone is set up, the account password no longer works for WebDAV; app passwords keep working. Bestätige Browser-Anmeldungen auf dem Handy mit der RustMFA-App. Sobald ein Handy eingerichtet ist, gilt das Kontopasswort nicht mehr für WebDAV; App-Passwörter funktionieren weiter. +Added {0} Hinzugefügt {0} +Hardware key Hardware-Schlüssel +Revoked Widerrufen +Recent events Letzte Ereignisse +Phone added Handy hinzugefügt +Phone revoked Handy widerrufen +Keys renewed Schlüssel erneuert +Sign-in approval requested Anmelde-Bestätigung angefragt +Sign-in approved Anmeldung bestätigt +Sign-in denied Anmeldung abgelehnt +Sign-in request expired Anmelde-Anfrage abgelaufen +Wrong number chosen Falsche Zahl gewählt +Enter your password to show the enrollment code for the RustMFA app. Gib dein Passwort ein, um den Einrichtungscode für die RustMFA-App anzuzeigen. +Confirm with your password Mit deinem Passwort bestätigen +Show enrollment code Einrichtungscode anzeigen +Open the RustMFA app and scan this code. It is valid for {0} minutes and works once. Öffne die RustMFA-App und scanne diesen Code. Er ist {0} Minuten gültig und funktioniert nur einmal. +Cannot scan? This is what the code says: Scannen geht nicht? Das sagt der Code: +Server address: {0} Serveradresse: {0} +Scan with the RustMFA app Mit der RustMFA-App scannen +Anyone who scans this code before it expires can add a phone to your account. Keep it to yourself. Wer diesen Code vor Ablauf scannt, kann ein Handy zu deinem Konto hinzufügen. Behalte ihn für dich. +Enrollment code Einrichtungscode diff --git a/crates/rostdav-web/src/lib.rs b/crates/rostdav-web/src/lib.rs index e2236cf..e8b3b98 100644 --- a/crates/rostdav-web/src/lib.rs +++ b/crates/rostdav-web/src/lib.rs @@ -13,6 +13,7 @@ mod more; mod pages; mod pages_more; mod phone_step; +mod phones; mod session; mod sharing; mod twofactor; @@ -147,6 +148,11 @@ pub fn router() -> Router { get(twofactor::codes_page).post(twofactor::codes_submit), ) .route("/devices", get(pages_more::devices)) + .route( + "/devices/phones/enroll", + get(phones::enroll_page).post(phones::enroll_submit), + ) + .route("/devices/phones/revoke", post(phones::revoke)) .route("/devices/create", post(pages_more::device_create)) .route("/devices/revoke", post(pages_more::device_revoke)) .route( diff --git a/crates/rostdav-web/src/pages_more.rs b/crates/rostdav-web/src/pages_more.rs index 250f9cb..7d178a3 100644 --- a/crates/rostdav-web/src/pages_more.rs +++ b/crates/rostdav-web/src/pages_more.rs @@ -343,8 +343,9 @@ async fn devices_page(state: &AppState, a: &Authed, status: StatusCode, notice: } _ => String::new(), }; + let phones = crate::phones::phones_card(state, a).await; let body = format!( - "{}{notice}{}{twofa}
{}{}
", + "{}{notice}{}{twofa}{phones}
{}{}
", ui::page_head( lang, "Devices & security", diff --git a/crates/rostdav-web/src/phones.rs b/crates/rostdav-web/src/phones.rs new file mode 100644 index 0000000..9995530 --- /dev/null +++ b/crates/rostdav-web/src/phones.rs @@ -0,0 +1,310 @@ +//! RustMFA phones in the web interface (ADR 0014): the list on the devices page, the enrollment +//! QR code (behind the account password) and revoking a phone. + +use axum::Form; +use axum::extract::State; +use axum::http::request::Parts; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Redirect, Response}; +use rostdav_app::{AppState, DeviceInfo, MfaError}; +use rostdav_core::Error; +use serde::Deserialize; + +use crate::i18n::{Lang, t, tf}; +use crate::layout::esc; +use crate::pages::error_response_to; +use crate::session::{Authed, client_ip}; +use crate::ui; + +fn csrf_ok(state: &AppState, a: &Authed, given: &str) -> bool { + state.web_check_csrf(a.session.id, given).is_ok() +} + +fn no_store(mut r: Response) -> Response { + r.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + r +} + +/// The date part of an RFC 3339 time. +fn day(rfc3339: &str) -> &str { + rfc3339.get(..10).unwrap_or(rfc3339) +} + +fn event_label(lang: Lang, kind: &str) -> String { + t( + lang, + match kind { + "device_registered" => "Phone added", + "device_revoked" => "Phone revoked", + "key_rotated" => "Keys renewed", + "challenge_created" => "Sign-in approval requested", + "challenge_approved" => "Sign-in approved", + "challenge_denied" => "Sign-in denied", + "challenge_expired" => "Sign-in request expired", + "number_mismatch" => "Wrong number chosen", + _ => "Event", + }, + ) +} + +fn phone_row(lang: Lang, csrf: &str, d: &DeviceInfo) -> String { + let active = d.status == "active"; + let mut meta = tf(lang, "Added {0}", &[day(&d.created_at)]); + if d.strongbox { + meta.push_str(" · StrongBox"); + } else if d.hardware_backed { + meta.push_str(" · "); + meta.push_str(&t(lang, "Hardware key")); + } + let seen = d + .last_seen_at + .as_deref() + .map_or_else(|| t(lang, "Never used"), |s| day(s).to_owned()); + let action = if active { + format!( + "
\ + \ +
", + esc(csrf), + esc(&d.device_id), + esc(&tf(lang, "Revoke {0}", &[&d.device_name])), + esc(&t(lang, "Revoke")), + ) + } else { + String::new() + }; + format!( + "
  • {}
    {}\ + {}
    {} {}{action}
  • ", + ui::svg("phone"), + esc(&d.device_name), + esc(&meta), + if active { " ok" } else { "" }, + esc(&t(lang, if active { "Active" } else { "Revoked" })), + esc(&seen), + ) +} + +/// The card of the devices page. Empty when the account has no phone and the server cannot +/// enroll one. +pub async fn phones_card(state: &AppState, a: &Authed) -> String { + let lang = a.lang; + let user_id = a.session.principal.user_id; + let Ok(devices) = state.mfa_list_devices(user_id).await else { + return String::new(); + }; + let events = state.mfa_audit_events(user_id).await.unwrap_or_default(); + let rows: String = devices + .iter() + .map(|d| phone_row(lang, &a.csrf, d)) + .collect(); + let list = if rows.is_empty() { + format!( + "
    {}
    ", + esc(&t(lang, "No phone is set up for sign-in approval.")) + ) + } else { + format!("
      {rows}
    ") + }; + let history = if events.is_empty() { + String::new() + } else { + let items: String = events + .iter() + .take(5) + .map(|e| { + format!( + "
  • {} · {}
  • ", + esc(&event_label(lang, &e.event)), + esc(day(&e.at)) + ) + }) + .collect(); + format!( + "

    {}

      {items}
    ", + esc(&t(lang, "Recent events")) + ) + }; + let active = devices.iter().filter(|d| d.status == "active").count(); + let chip = format!( + "{}", + if active > 0 { " ok" } else { "" }, + esc(&tf(lang, "{0} active", &[&active.to_string()])) + ); + let inner = format!( + "

    {}

    {list}\ +

    {}

    {history}", + esc(&t( + lang, + "Approve browser sign-ins on your phone with the RustMFA app. Once a phone is set up, the account password no longer works for WebDAV; app passwords keep working." + )), + esc(&t(lang, "Add a phone")), + ); + ui::card(lang, "Phones (RustMFA)", &chip, &inner) +} + +// ---- enrollment --------------------------------------------------------------------------------- + +fn enroll_form(a: &Authed, error: Option<&str>, status: StatusCode) -> Response { + let lang = a.lang; + let err = error + .map(|e| format!("
    {}
    ", esc(e))) + .unwrap_or_default(); + let form = format!( + "{err}
    \ + \ + \ +
    {}
    ", + esc(&a.csrf), + esc(&t(lang, "Password")), + esc(&t(lang, "Show enrollment code")), + esc(&t(lang, "Cancel")), + ); + let body = format!( + "{}{}", + ui::page_head( + lang, + "Add a phone", + "Enter your password to show the enrollment code for the RustMFA app.", + "" + ), + ui::card(lang, "Confirm with your password", "", &form) + ); + no_store(ui::page(a, status, "Add a phone", "devices", &body)) +} + +pub async fn enroll_page(a: Authed) -> Response { + enroll_form(&a, None, StatusCode::OK) +} + +#[derive(Deserialize)] +pub struct EnrollForm { + csrf: String, + password: String, +} + +/// The address the phone should talk to: `server.public_url`, else the `Host` of this request. +fn server_url(state: &AppState, headers: &HeaderMap) -> Option { + if let Some(u) = state.public_url() { + return Some(u.trim_end_matches('/').to_owned()); + } + let host = headers.get(header::HOST)?.to_str().ok()?; + let ok = !host.is_empty() + && host.len() <= 255 + && host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']')); + ok.then(|| format!("http://{host}")) +} + +pub async fn enroll_submit( + State(state): State, + a: Authed, + parts: Parts, + Form(f): Form, +) -> Response { + let lang = a.lang; + if !csrf_ok(&state, &a, &f.csrf) { + return StatusCode::FORBIDDEN.into_response(); + } + let Some(server) = server_url(&state, &parts.headers) else { + return error_response_to( + &a, + &Error::BadRequest("no server address"), + "/devices", + "devices", + ); + }; + match state + .web_mfa_enroll_start(&a.session, &f.password, &server, client_ip(&parts)) + .await + { + Ok(q) => { + let minutes = ((q.token.expires_in_seconds + 59) / 60).to_string(); + let inner = format!( + "
    {}

    {}

    \ +

    {}

    {}

    \ +

    {}

    \ +

    {}

    ", + q.qr_svg, + esc(&tf( + lang, + "Open the RustMFA app and scan this code. It is valid for {0} minutes and works once.", + &[&minutes] + )), + esc(&t(lang, "Cannot scan? This is what the code says:")), + esc(&q.payload), + esc(&tf(lang, "Server address: {0}", &[&server])), + esc(&t(lang, "Done")), + ); + let body = format!( + "{}{}", + ui::page_head( + lang, + "Scan with the RustMFA app", + "Anyone who scans this code before it expires can add a phone to your account. Keep it to yourself.", + "" + ), + ui::card(lang, "Enrollment code", "", &inner) + ); + no_store(ui::page( + &a, + StatusCode::OK, + "Add a phone", + "devices", + &body, + )) + } + Err(MfaError::Unauthenticated) => enroll_form( + &a, + Some(&t(lang, "Wrong password.")), + StatusCode::UNAUTHORIZED, + ), + Err(MfaError::RateLimited { retry_after_secs }) => { + let mut r = enroll_form( + &a, + Some(&tf( + lang, + "Too many attempts. Try again in {0} minutes.", + &[&retry_after_secs.div_ceil(60).to_string()], + )), + StatusCode::TOO_MANY_REQUESTS, + ); + if let Ok(v) = HeaderValue::from_str(&retry_after_secs.to_string()) { + r.headers_mut().insert(header::RETRY_AFTER, v); + } + r + } + Err(MfaError::Internal(e)) => error_response_to(&a, &e, "/devices", "devices"), + Err(_) => error_response_to(&a, &Error::Unavailable, "/devices", "devices"), + } +} + +// ---- revoking ----------------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct RevokeForm { + csrf: String, + device_id: String, +} + +pub async fn revoke( + State(state): State, + a: Authed, + Form(f): Form, +) -> Response { + if !csrf_ok(&state, &a, &f.csrf) { + return StatusCode::FORBIDDEN.into_response(); + } + let p = &a.session.principal; + match state + .mfa_revoke_device(p.user_id, &f.device_id, &p.name) + .await + { + Ok(()) => Redirect::to("/devices").into_response(), + Err(MfaError::NotFound) => error_response_to(&a, &Error::NotFound, "/devices", "devices"), + Err(MfaError::Internal(e)) => error_response_to(&a, &e, "/devices", "devices"), + Err(_) => error_response_to(&a, &Error::Unavailable, "/devices", "devices"), + } +} diff --git a/crates/rostdav/src/lib.rs b/crates/rostdav/src/lib.rs index f866b95..5c045df 100644 --- a/crates/rostdav/src/lib.rs +++ b/crates/rostdav/src/lib.rs @@ -56,6 +56,7 @@ pub async fn build_state(cfg: &config::Config) -> anyhow::Result { .with_web(rostdav_app::WebOptions { idle_secs: i64::from(cfg.web.session_idle_hours.max(1)) * 3600, max_secs: i64::from(cfg.web.session_max_days.max(1)) * 86_400, + public_url: cfg.server.public_url.clone(), }) .with_audit(rostdav_app::AuditOptions { anonymize_ip: cfg.audit.anonymize_ip, diff --git a/crates/rostdav/tests/web.rs b/crates/rostdav/tests/web.rs index aa2dfd6..9bb08e3 100644 --- a/crates/rostdav/tests/web.rs +++ b/crates/rostdav/tests/web.rs @@ -6020,3 +6020,172 @@ async fn a_ticket_of_another_flow_cannot_finish_this_one() { StatusCode::OK ); } + +// ---- phones in the web interface ------------------------------------------------------------------ + +async fn enroll_post(e: &Env, cookie: &str, csrf: &str, password: &str) -> Res { + post_form( + e, + "/devices/phones/enroll", + cookie, + &[("csrf", csrf), ("password", password)], + ) + .await +} + +#[tokio::test] +async fn enroll_page_asks_for_the_password_and_needs_a_session() { + let e = env().await; + let c = login(&e, "anna").await; + let r = get(&e, "/devices/phones/enroll", &c).await; + assert_eq!(r.status, StatusCode::OK); + assert!(r.text().contains("name=\"password\"") && r.text().contains("/devices/phones/enroll")); + let r = send(&e, "GET", "/devices/phones/enroll", &[], Body::empty()).await; + assert_eq!(r.status, StatusCode::SEE_OTHER); + assert_eq!(r.h("location"), "/login"); +} + +#[tokio::test] +async fn enrolling_needs_the_password_and_csrf() { + let e = env().await; + let c = login(&e, "anna").await; + let csrf = page_csrf(&e, &c).await; + let r = enroll_post(&e, &c, &csrf, "wrong-password").await; + assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text()); + assert!(!r.text().contains("rustmfa://")); + let r = enroll_post(&e, &c, "forged", PW).await; + assert_eq!(r.status, StatusCode::FORBIDDEN); + let r = post_form( + &e, + "/devices/phones/enroll", + "", + &[("csrf", &csrf), ("password", PW)], + ) + .await; + assert_ne!(r.status, StatusCode::OK); +} + +#[tokio::test] +async fn enrolling_shows_a_qr_code_once() { + let e = env().await; + let c = login(&e, "anna").await; + let csrf = page_csrf(&e, &c).await; + let r = enroll_post(&e, &c, &csrf, PW).await; + assert_eq!(r.status, StatusCode::OK, "{}", r.text()); + assert_eq!(r.h("cache-control"), "no-store"); + let html = r.text(); + assert!(html.contains("", ""); + assert!( + payload.starts_with("rustmfa://enroll?server=http%3A%2F%2F"), + "{payload}" + ); + let token = payload.split("token=").nth(1).expect("token").to_owned(); + // the token is not on the devices page or in a link + let devices = get(&e, "/devices", &c).await.text(); + assert!(!devices.contains(&token)); + assert!(!html.contains(&format!("href=\"{token}"))); +} + +#[tokio::test] +async fn the_sixth_enrollment_in_fifteen_minutes_is_429() { + let e = env().await; + let c = login(&e, "anna").await; + let csrf = page_csrf(&e, &c).await; + for _ in 0..5 { + assert_eq!(enroll_post(&e, &c, &csrf, PW).await.status, StatusCode::OK); + } + let r = enroll_post(&e, &c, &csrf, PW).await; + assert_eq!(r.status, StatusCode::TOO_MANY_REQUESTS, "{}", r.text()); + assert!(!r.h("retry-after").is_empty()); +} + +#[tokio::test] +async fn the_devices_page_lists_only_own_phones() { + let e = env().await; + // sign in first: a user with a phone has to approve on it afterwards + let c = login(&e, "anna").await; + phone_user(&e, "anna", "dev_a").await; + phone_user(&e, "bob", "dev_b").await; + let html = get(&e, "/devices", &c).await.text(); + assert!( + html.contains("RustMFA") && html.contains("value=\"dev_a\""), + "{html}" + ); + assert!(!html.contains("dev_b")); + assert!(html.contains("/devices/phones/enroll")); +} + +#[tokio::test] +async fn revoking_a_phone_ends_its_open_challenges() { + let e = env().await; + let c = login(&e, "anna").await; + phone_user(&e, "anna", "dev_a").await; + let anna = testkit::user_id(&e.state, "anna").await; + e.state + .mfa_create_challenge( + anna, + "dev_a", + "ref_1", + "Firefox", + "127.0.0.1".parse().expect("ip"), + ) + .await + .expect("challenge"); + assert_eq!( + testkit::pending_challenge_ids(&e.state, "dev_a") + .await + .len(), + 1 + ); + let csrf = page_csrf(&e, &c).await; + let r = post_form( + &e, + "/devices/phones/revoke", + &c, + &[("csrf", &csrf), ("device_id", "dev_a")], + ) + .await; + assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.text()); + assert_eq!(r.h("location"), "/devices"); + assert!( + testkit::pending_challenge_ids(&e.state, "dev_a") + .await + .is_empty() + ); + let html = get(&e, "/devices", &c).await.text(); + assert!(html.contains("Revoked"), "{html}"); +} + +#[tokio::test] +async fn revoking_needs_csrf_and_cannot_touch_another_users_phone() { + let e = env().await; + let c = login(&e, "anna").await; + let b = login(&e, "bob").await; + phone_user(&e, "anna", "dev_a").await; + phone_user(&e, "bob", "dev_b").await; + let csrf = page_csrf(&e, &c).await; + let r = post_form( + &e, + "/devices/phones/revoke", + &c, + &[("csrf", "forged"), ("device_id", "dev_a")], + ) + .await; + assert_eq!(r.status, StatusCode::FORBIDDEN); + let r = post_form( + &e, + "/devices/phones/revoke", + &c, + &[("csrf", &csrf), ("device_id", "dev_b")], + ) + .await; + assert_eq!(r.status, StatusCode::NOT_FOUND, "{}", r.text()); + assert!( + get(&e, "/devices", &b) + .await + .text() + .contains("value=\"dev_b\""), + "bob's phone is untouched" + ); +} diff --git a/docs/admin.md b/docs/admin.md index 60728d3..21e4062 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -131,7 +131,7 @@ Users turn on TOTP (authenticator app) themselves under Devices & security; see ## Push sign-in approval (RustMFA) -Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa.md)). The server side of the phone API is implemented (`/api/v1/devices/…`, `/api/v1/mfa/challenges/…`). The browser sign-in and the Login Flow v2 page (connecting a Nextcloud app) have the phone step. Creating an enrollment token in the web interface and push delivery (FCM) are not built yet: so far no user can enroll a phone through the web interface, and a phone only sees a request when its app polls. +Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa.md)). The server side of the phone API is implemented (`/api/v1/devices/…`, `/api/v1/mfa/challenges/…`). The browser sign-in and the Login Flow v2 page (connecting a Nextcloud app) have the phone step. Users add and revoke phones under Devices & security (the account password is asked again before the QR code is shown). Push delivery (FCM) is not built yet: a phone only sees a request when its app polls. The QR code carries the server address, taken from `server.public_url`; set it when Rostdav runs behind a TLS proxy, or the code says `http://`. | Key | Default | Meaning | |---|---|---| diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index 49dcd4a..411ee0f 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -48,7 +48,7 @@ Migration `0013_push_mfa.sql` with four `STRICT` tables, a plain index on every - `PushChannel` trait in `rostdav-app`; delivery adapters follow in the second step. **Implementation notes 2026-11-09:** `rostdav-mfa-api` exposes `router() -> Router` (merged into the server's router like the other protocol crates; the paths are complete so the signed path is the received one). It reads at most 64 KiB (413 `INVALID_REQUEST` beyond that; the contract has no 413), refuses unknown JSON fields, and sets `cache-control: no-store`. It has no dependency on `rostdav-meta`. Its integration tests use a `test-util` feature of `rostdav-app` (the `testkit` module: synthetic attestation chains, a fresh state); production builds do not enable it. The tests check responses against a JSON mirror of the OpenAPI file kept in the `rostdav-mfa` repository (a contract test keeps the mirror equal to the YAML), so Rostdav needs no YAML crate. Listing challenges supports `status=pending` only. - **Implementation notes 2026-11-10:** `AppState::has_second_factor` (TOTP on or at least one active phone) replaces the TOTP-only check at the three sign-in decisions (browser sign-in, Login Flow v2, WebDAV/OCS password). The TOTP management pages keep asking about TOTP alone. A challenge started for a sign-in carries a `login_ref`, a keyed hash of the sign-in's ticket and flow. Only the holder of that ticket can poll it (`mfa_login_status`) or turn it into a session (`mfa_login_release`). The release is one conditional update on `released_at` and needs the approving phone to be still active, so a phone revoked after it approved releases nothing. + **Implementation notes 2026-11-10:** `AppState::has_second_factor` (TOTP on or at least one active phone) replaces the TOTP-only check at the three sign-in decisions (browser sign-in, Login Flow v2, WebDAV/OCS password). The TOTP management pages keep asking about TOTP alone. A challenge started for a sign-in carries a `login_ref`, a keyed hash of the sign-in's ticket and flow. Only the holder of that ticket can poll it (`mfa_login_status`) or turn it into a session (`mfa_login_release`). The release is one conditional update on `released_at` and needs the approving phone to be still active, so a phone revoked after it approved releases nothing. The web interface asks for the account password before it shows an enrollment QR code (`web_mfa_enroll_start`), so a stolen session cookie alone cannot add a phone. The QR payload is `rustmfa://enroll?server=&token=` (written into the design spec, section 4.1). ### Dependencies (justification) diff --git a/docs/ui.md b/docs/ui.md index b2ddbac..ea6aabf 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -54,6 +54,7 @@ Avoid collisions with Nextcloud paths that the apps call: check the UI routes ag | `/admin/security`, `POST /admin/security/{maintenance,unlock}` | Administration: maintenance mode on/off; active sign-in lockouts (address, user, two-factor) with "Unlock" | | `/login/2fa`, `/settings/2fa[/disable\|/codes]` | Second sign-in step; set up, turn off and recovery codes of two-factor authentication (ADR 0007); admin reset at `/admin/users/two-factor-reset` | | (all sign-in pages) | Layout of `layout::bare`: a dark brand panel with the full logo (`assets/brand/rostdav-logo-dark.svg`, put into the page so the wordmark uses Inter; the file's own dark tile is cut out) beside the form; below 860 px the panel is a band above it. This replaces the single folder tile of `design/mockups/web-ui/Login.html`; the mockup was not updated. | +| `/devices` (card "Phones (RustMFA)"), `/devices/phones/enroll`, `/devices/phones/revoke` | The user's phones with revoke, the last five security events, and the enrollment page: password again, then a QR code (`rustmfa://enroll?server=…&token=…`, valid `mfa.enroll_ttl_seconds`, single use) and the same text for manual entry. The server address is `server.public_url`, else `http://`. | | `/login/2fa/phone`, `/login/2fa/phone/status`, `/login/2fa/phone/finish` | Phone step of the browser sign-in (ADR 0014): "Send to my phone" (POST, CSRF) shows the number, a script (`/static/phone.js`) polls the status (JSON without the number) and submits the finish form; without the script a "Continue" button does the same. All three need the ticket cookie of the password step. | | `/login/v2/flow/` | Connect device (Login Flow v2; second step on the same page, with the phone step as the form actions `phone` and `phone_finish`; `/login/v2/flow//phone-status?ticket=…&id=…` is the JSON status the page polls. The ticket is in the query because the page polls with a plain GET; the access log does not record queries) | | `/static/{app,login}.css` | Embedded stylesheets | From f4fa7fdeb232f248d23e50b040fcd115d8866b2e Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 21:58:58 +0100 Subject: [PATCH 22/24] feat(mfa): FCM push adapter A PushChannel that wakes a phone through the FCM HTTP v1 API with a data message holding the challenge id only, at high priority. Service-account JWT (RS256) for an access token that is cached until a minute before it ends. [mfa.fcm] turns it on; an unusable file stops start-up. --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- crates/rostdav-app/src/lib.rs | 8 +- crates/rostdav-app/src/push_mfa/challenges.rs | 9 +- crates/rostdav-app/src/push_mfa/fcm.rs | 285 ++++++++++++++ crates/rostdav-app/src/push_mfa/fcm_tests.rs | 365 ++++++++++++++++++ crates/rostdav-app/src/push_mfa/mod.rs | 4 + .../src/push_mfa/testdata/test_rsa_key.pem | 29 ++ crates/rostdav/src/config.rs | 57 +++ crates/rostdav/src/lib.rs | 25 +- crates/rostdav/tests/mfa_fcm.rs | 62 +++ docs/admin.md | 3 + docs/adr/0014-push-mfa.md | 2 +- docs/security.md | 1 + rostdav.example.toml | 8 + 15 files changed, 851 insertions(+), 10 deletions(-) create mode 100644 crates/rostdav-app/src/push_mfa/fcm.rs create mode 100644 crates/rostdav-app/src/push_mfa/fcm_tests.rs create mode 100644 crates/rostdav-app/src/push_mfa/testdata/test_rsa_key.pem create mode 100644 crates/rostdav/tests/mfa_fcm.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 83cfb61..b7b0f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https ### Added +- **Push delivery through FCM** (`[mfa.fcm]`: `project_id`, `service_account_file`): when a sign-in approval waits, Rostdav wakes the phone with a data message that holds the challenge id only. Off unless both keys are set; a bad service-account file stops the server at start-up. A failed push is logged and never blocks the sign-in. - **Phones in the web interface**: Devices & security lists the user's RustMFA phones (name, status, last seen) with revoke, and the last security events. "Add a phone" asks for the account password again and shows a single-use enrollment QR code (`rustmfa://enroll?server=…&token=…`). Five codes per user in 15 minutes. Revoking a phone ends its open sign-in approvals. - **Sign in with the RustMFA phone in the browser**: the second step offers "Send to my phone" (next to the code form when authenticator codes are on). The page shows a number, the user picks it on the phone, and the page signs in once the phone approved. A denied or expired request goes back to the second step. The Login Flow v2 page (connecting a Nextcloud app) has the same step. - **A sign-in approval belongs to one sign-in**: a challenge started for a sign-in can only be polled and turned into a session by the browser that passed the password step, and only once; a phone revoked after it approved releases nothing. Not reachable from the sign-in page yet. diff --git a/CLAUDE.md b/CLAUDE.md index 92951bb..21fd5b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ The binding working rules are in [AGENTS.md](AGENTS.md). The full project instru - M2 (WebDAV core) complete. M3: server side recorded (Nextcloud 35.0.2, synthetic requests), real-app capture open. M4 (sign-in) implemented; ADR 0003 (app passwords, Login Flow v2, rate limiting) is accepted (2026-10-23). M5 (Nextcloud WebDAV extensions, chunked upload v2) implemented. M7 (admin CLI, event log, fsck, import) implemented. M6 (real app on the Android emulator) is postponed to the end by the project owner. M8 (versioning), M9 (shares, ADR 0004 accepted) and M10 (trash, ADR 0005) implemented. M11 (web interface, ADR 0006 accepted): slice 11a implemented (sign-in, Files, Trash, Devices, Settings, public link page). 11b implemented (share dialog, versions panel, details, folder filter). Chunked browser upload done (JS not yet tested in a real browser). 11c implemented (administration pages). 11d done (profile, change password, data export, German/English). M11 complete. M12 (two-factor, ADR 0007 accepted) implemented. M13 (retention overview, `default_quota`, `upload_retention_hours`, streamed data export) done. Phase 5 done except coverage-guided fuzzing (authorization matrix, robustness tests, load tests, container image + Compose, backup/restore, operations docs). M6 complete for now (2026-10-23): verified with the real Android app (sign-in incl. 2FA, files, favorites, shares, public link, versions, trash, offline) and the desktop sync engine (`nextcloudcmd`); not tried: desktop GUI, iOS client. ADR 0003 accepted; cargo-fuzz targets in `fuzz/`. Web interface gaps from the mockups closed (maintenance mode, lockouts, shares, recent, sortable columns); left out: avatar upload, editable admin limits. - About page, release notes, opt-in version check and the updater (ADR 0009) are built. The updater needs the maintainer's minisign key (`CONTRIBUTING.md`, "Release signing key"): until `crates/rostdav-app/update-key.pub` holds a key, it reports "no update signing key". The signing step of the release workflow has not run yet. Documentation site in `site/` (GitHub Pages, `just site`). -- M14 (push sign-in approval with the RustMFA app, ADR 0014 accepted 2026-11-08): backend implemented on branch `feat/push-mfa` (`[mfa]` settings, migration 0013, attestation, enrollment, request authentication, challenges, device management, HTTP API in `rostdav-mfa-api`). Open: sign-in page integration, enroll-token web route, FCM adapter, web device pages (Plan 2b) and the Android app (Plan 3). `rustmfa-contract` is a git dependency pinned by revision (repository `pierrejochem/rostdav-mfa`); a contract change needs a commit there and a new pin here. +- M14 (push sign-in approval with the RustMFA app, ADR 0014 accepted 2026-11-08): backend implemented on branch `feat/push-mfa` (`[mfa]` settings, migration 0013, attestation, enrollment, request authentication, challenges, device management, HTTP API in `rostdav-mfa-api`). Plan 2b implemented: phone as second factor, browser and Login Flow v2 phone step, phone enrollment and revoke pages, FCM adapter (`[mfa.fcm]`). Open: the admin lockouts page does not list the MFA rate-limit blocks yet, and the Android app (Plan 3). `rustmfa-contract` is a git dependency pinned by revision (repository `pierrejochem/rostdav-mfa`); a contract change needs a commit there and a new pin here. ## Releases diff --git a/crates/rostdav-app/src/lib.rs b/crates/rostdav-app/src/lib.rs index ae9e516..48557bb 100644 --- a/crates/rostdav-app/src/lib.rs +++ b/crates/rostdav-app/src/lib.rs @@ -60,10 +60,10 @@ pub use password::{MIN_PASSWORD_CHARS, hash_password, verify_password}; pub use push_mfa::testkit; pub use push_mfa::{ AttestationPolicy, AttestationResult, ChallengeDetails, ChallengeStatus, DeviceInfo, - DevicePrincipal, EnrollQr, EnrollToken, LoginChallenge, MfaAuditEvent, MfaConfig, MfaError, - NewChallenge as MfaNewChallenge, NoopPush, ParsedAuth, PushChannel, PushFuture, RegisterInput, - Registered, SecondStepOptions, enroll_qr_payload, google_roots, parse_authorization, - verify_attestation, + DevicePrincipal, EnrollQr, EnrollToken, FcmPush, LoginChallenge, MfaAuditEvent, MfaConfig, + MfaError, NewChallenge as MfaNewChallenge, NoopPush, ParsedAuth, PushChannel, PushFuture, + RegisterInput, Registered, SecondStepOptions, enroll_qr_payload, google_roots, + parse_authorization, verify_attestation, }; pub use search::{KindFilter, MAX_VISITED, SearchHit, SearchPage, SearchQuery, SnippetPart}; pub use shares::{ diff --git a/crates/rostdav-app/src/push_mfa/challenges.rs b/crates/rostdav-app/src/push_mfa/challenges.rs index e0e6684..ebda167 100644 --- a/crates/rostdav-app/src/push_mfa/challenges.rs +++ b/crates/rostdav-app/src/push_mfa/challenges.rs @@ -513,10 +513,15 @@ impl AppState { } } - #[cfg(any(test, feature = "test-util"))] - pub fn set_push_channel_for_tests(&self, p: Arc) { + /// Replaces the push channel (the server installs FCM at start-up). + pub fn set_push_channel(&self, p: Arc) { if let Ok(mut g) = self.inner.mfa_push.write() { *g = p; } } + + #[cfg(any(test, feature = "test-util"))] + pub fn set_push_channel_for_tests(&self, p: Arc) { + self.set_push_channel(p); + } } diff --git a/crates/rostdav-app/src/push_mfa/fcm.rs b/crates/rostdav-app/src/push_mfa/fcm.rs new file mode 100644 index 0000000..16f3ff5 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/fcm.rs @@ -0,0 +1,285 @@ +//! Waking a phone through Firebase Cloud Messaging (ADR 0014, spec 4.8). The message is a data +//! message with the challenge id only, at high priority: nothing secret goes through Google. +//! Authentication is the OAuth2 service-account flow: a JWT signed with the account's RSA key is +//! exchanged for a short-lived access token, which is kept until a minute before it ends. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use ring::rand::SystemRandom; +use ring::signature::{RSA_PKCS1_SHA256, RsaKeyPair}; +use serde_json::{Value, json}; + +use super::push::{PushChannel, PushFuture}; + +const SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging"; +const DEFAULT_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; +const DEFAULT_SEND_BASE: &str = "https://fcm.googleapis.com"; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); +const JWT_LIFETIME_SECS: i64 = 3600; +/// An access token is replaced this long before it ends. +const TOKEN_MARGIN_SECS: i64 = 60; +const MAX_ANSWER: u64 = 64 * 1024; + +struct Inner { + project: String, + client_email: String, + key: RsaKeyPair, + token_url: String, + send_base: String, + timeout: Duration, + /// Access token and the second it expires at. + cached: Mutex>, +} + +/// The FCM channel. Cheap to clone; the access token is shared. +#[derive(Clone)] +pub struct FcmPush { + inner: Arc, +} + +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0)) +} + +/// A word of an error answer that is safe to log: capital letters, digits and `_`, short. +fn enum_word(v: &Value) -> Option { + let s = v.as_str()?; + (!s.is_empty() + && s.len() <= 40 + && s.bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')) + .then(|| s.to_owned()) +} + +/// What an error answer of Google says, reduced to enum words. Free text never gets through: it +/// can echo the push token. +fn error_words(body: &[u8]) -> String { + let Ok(v) = serde_json::from_slice::(body) else { + return String::new(); + }; + let mut out = Vec::new(); + out.extend(enum_word(&v["error"]["status"])); + if let Some(d) = v["error"]["details"].as_array() { + out.extend(d.iter().filter_map(|x| enum_word(&x["errorCode"]))); + } + if out.is_empty() { + String::new() + } else { + format!(" ({})", out.join(", ")) + } +} + +impl FcmPush { + /// Reads a Google service-account file. The error text never holds the key. + pub fn from_service_account(project_id: &str, json: &str) -> Result { + let v: Value = serde_json::from_str(json) + .map_err(|_| "the service account file is not JSON".to_owned())?; + let email = v["client_email"] + .as_str() + .filter(|s| !s.is_empty()) + .ok_or("the service account file has no client_email")?; + let pem = v["private_key"] + .as_str() + .ok_or("the service account file has no private_key")?; + let b64: String = pem + .lines() + .map(str::trim) + .filter(|l| !l.starts_with('#') && !l.starts_with("-----")) + .collect(); + let der = STANDARD + .decode(b64) + .map_err(|_| "the private key is not valid PEM".to_owned())?; + let key = RsaKeyPair::from_pkcs8(&der) + .map_err(|_| "the private key is not a PKCS#8 RSA key".to_owned())?; + let token_url = v["token_uri"] + .as_str() + .unwrap_or(DEFAULT_TOKEN_URL) + .to_owned(); + Ok(FcmPush { + inner: Arc::new(Inner { + project: project_id.to_owned(), + client_email: email.to_owned(), + key, + token_url, + send_base: DEFAULT_SEND_BASE.to_owned(), + timeout: DEFAULT_TIMEOUT, + cached: Mutex::new(None), + }), + }) + } + + /// Other addresses for the token and the send endpoint (tests talk to a local server). + #[must_use] + pub fn with_endpoints(self, token_url: String, send_base: String) -> FcmPush { + self.rebuild(|i| { + i.token_url = token_url; + i.send_base = send_base; + }) + } + + #[must_use] + pub fn with_timeout(self, timeout: Duration) -> FcmPush { + self.rebuild(|i| i.timeout = timeout) + } + + /// Changes the settings of a channel that is not shared yet; a shared one is left as it is. + fn rebuild(mut self, f: impl FnOnce(&mut Inner)) -> FcmPush { + if let Some(inner) = Arc::get_mut(&mut self.inner) { + f(inner); + } + self + } +} + +impl Inner { + fn agent(&self) -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_global(Some(self.timeout)) + .max_redirects(0) + .http_status_as_error(false) + .user_agent(format!("rostdav/{}", rostdav_core::VERSION)) + .build() + .into() + } + + fn jwt(&self, at: i64) -> Result { + let part = |v: &Value| URL_SAFE_NO_PAD.encode(v.to_string()); + let signing_input = format!( + "{}.{}", + part(&json!({"alg": "RS256", "typ": "JWT"})), + part(&json!({ + "iss": self.client_email, + "scope": SCOPE, + "aud": self.token_url, + "iat": at, + "exp": at + JWT_LIFETIME_SECS, + })) + ); + let mut sig = vec![0u8; self.key.public().modulus_len()]; + self.key + .sign( + &RSA_PKCS1_SHA256, + &SystemRandom::new(), + signing_input.as_bytes(), + &mut sig, + ) + .map_err(|_| "signing the token request failed".to_owned())?; + Ok(format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(sig))) + } + + /// A valid access token: the cached one, or a new one from the token endpoint. + fn access_token(&self) -> Result { + let at = now(); + if let Ok(g) = self.cached.lock() + && let Some((tok, exp)) = g.as_ref() + && *exp - TOKEN_MARGIN_SECS > at + { + return Ok(tok.clone()); + } + let form = format!( + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion={}", + self.jwt(at)? + ); + let mut resp = self + .agent() + .post(&self.token_url) + .header("Content-Type", "application/x-www-form-urlencoded") + .send(form) + .map_err(|e| format!("token request failed: {e}"))?; + let code = resp.status().as_u16(); + let body = resp + .body_mut() + .with_config() + .limit(MAX_ANSWER) + .read_to_vec() + .map_err(|_| format!("token request answered {code}, unreadable"))?; + let v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + if code != 200 { + // the OAuth error code is a short lowercase word; the description can say more + let word = v["error"] + .as_str() + .filter(|s| s.len() <= 40 && s.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')) + .map(|s| format!(" ({s})")) + .unwrap_or_default(); + return Err(format!("token request answered {code}{word}")); + } + let tok = v["access_token"] + .as_str() + .filter(|s| !s.is_empty()) + .ok_or("token answer has no access_token")? + .to_owned(); + let lifetime = v["expires_in"].as_i64().unwrap_or(0).clamp(0, 86_400); + if let Ok(mut g) = self.cached.lock() { + *g = Some((tok.clone(), at + lifetime)); + } + Ok(tok) + } + + fn send(&self, push_token: &str, challenge_id: &str) -> Result<(), String> { + let access = self.access_token()?; + let body = json!({"message": { + "token": push_token, + "data": {"challenge_id": challenge_id}, + "android": {"priority": "HIGH"}, + }}) + .to_string(); + let url = format!( + "{}/v1/projects/{}/messages:send", + self.send_base.trim_end_matches('/'), + self.project + ); + let mut resp = self + .agent() + .post(&url) + .header("Authorization", format!("Bearer {access}")) + .header("Content-Type", "application/json") + .send(body) + .map_err(|e| format!("FCM request failed: {e}"))?; + let code = resp.status().as_u16(); + if (200..300).contains(&code) { + return Ok(()); + } + let answer = resp + .body_mut() + .with_config() + .limit(MAX_ANSWER) + .read_to_vec() + .unwrap_or_default(); + // a rejected access token is dropped, the next message asks for a new one + if code == 401 + && let Ok(mut g) = self.cached.lock() + { + *g = None; + } + Err(format!("FCM answered {code}{}", error_words(&answer))) + } +} + +impl PushChannel for FcmPush { + fn notify<'a>( + &'a self, + channel: &'a str, + token: &'a str, + challenge_id: &'a str, + ) -> PushFuture<'a> { + Box::pin(async move { + if channel != "fcm" { + tracing::debug!(channel, challenge_id, "not an FCM phone; nothing sent"); + return Ok(()); + } + let (inner, token, id) = ( + self.inner.clone(), + token.to_owned(), + challenge_id.to_owned(), + ); + tokio::task::spawn_blocking(move || inner.send(&token, &id)) + .await + .map_err(|_| "the push task stopped".to_owned())? + }) + } +} diff --git a/crates/rostdav-app/src/push_mfa/fcm_tests.rs b/crates/rostdav-app/src/push_mfa/fcm_tests.rs new file mode 100644 index 0000000..a55cb57 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/fcm_tests.rs @@ -0,0 +1,365 @@ +//! The FCM adapter against a fake Google: one local server answers the token and the send +//! endpoint, and records what it received. + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ring::signature::{RSA_PKCS1_2048_8192_SHA256, RsaKeyPair, UnparsedPublicKey}; +use serde_json::{Value, json}; + +use super::fcm::FcmPush; +use super::push::PushChannel; + +const KEY_PEM: &str = include_str!("testdata/test_rsa_key.pem"); +const PUSH_TOKEN: &str = "phone-push-token-secret"; +const ACCESS_TOKEN: &str = "ya29.access-token-secret"; +const EMAIL: &str = "svc@demo-project.iam.gserviceaccount.com"; + +#[derive(Clone, Debug)] +struct Seen { + path: String, + authorization: String, + body: String, +} + +struct Behaviour { + token_status: u16, + token_body: String, + send_status: u16, + send_body: String, + delay: Duration, +} + +impl Default for Behaviour { + fn default() -> Self { + Self { + token_status: 200, + token_body: json!({"access_token": ACCESS_TOKEN, "expires_in": 3600}).to_string(), + send_status: 200, + send_body: json!({"name": "projects/demo-project/messages/1"}).to_string(), + delay: Duration::ZERO, + } + } +} + +struct Fake { + base: String, + seen: Arc>>, + behaviour: Arc>, +} + +impl Fake { + fn start() -> Fake { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let base = format!("http://{}", listener.local_addr().expect("addr")); + let seen = Arc::new(Mutex::new(Vec::new())); + let behaviour = Arc::new(Mutex::new(Behaviour::default())); + let (s2, b2) = (seen.clone(), behaviour.clone()); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let (s3, b3) = (s2.clone(), b2.clone()); + std::thread::spawn(move || { + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + let (head_end, len) = loop { + let n = stream.read(&mut chunk).unwrap_or(0); + if n == 0 { + return; + } + buf.extend_from_slice(&chunk[..n]); + if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&buf[..i]).to_lowercase(); + let len = head + .lines() + .find_map(|l| l.strip_prefix("content-length:")) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0); + break (i + 4, len); + } + }; + while buf.len() < head_end + len { + let n = stream.read(&mut chunk).unwrap_or(0); + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); + let path = head + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_owned(); + let authorization = head + .lines() + .find(|l| l.to_lowercase().starts_with("authorization:")) + .map(|l| l.split_once(':').map_or("", |x| x.1).trim().to_owned()) + .unwrap_or_default(); + let body = String::from_utf8_lossy(&buf[head_end..]).to_string(); + s3.lock().expect("seen").push(Seen { + path: path.clone(), + authorization, + body, + }); + let (status, answer, delay) = { + let b = b3.lock().expect("behaviour"); + if path == "/token" { + (b.token_status, b.token_body.clone(), b.delay) + } else { + (b.send_status, b.send_body.clone(), b.delay) + } + }; + std::thread::sleep(delay); + let _ = write!( + stream, + "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{answer}", + answer.len() + ); + }); + } + }); + Fake { + base, + seen, + behaviour, + } + } + + fn requests(&self, path_prefix: &str) -> Vec { + self.seen + .lock() + .expect("seen") + .iter() + .filter(|s| s.path.starts_with(path_prefix)) + .cloned() + .collect() + } + + fn push(&self) -> FcmPush { + let json = json!({ + "type": "service_account", + "client_email": EMAIL, + "private_key": KEY_PEM, + "token_uri": format!("{}/token", self.base), + }) + .to_string(); + FcmPush::from_service_account("demo-project", &json) + .expect("service account") + .with_endpoints(format!("{}/token", self.base), self.base.clone()) + } +} + +#[tokio::test] +async fn sends_a_data_message_with_the_challenge_id_only() { + let f = Fake::start(); + f.push() + .notify("fcm", PUSH_TOKEN, "ch_abc") + .await + .expect("sent"); + let sent = f.requests("/v1/projects/demo-project/messages:send"); + assert_eq!(sent.len(), 1, "{:?}", f.seen.lock().expect("seen")); + assert_eq!(sent[0].authorization, format!("Bearer {ACCESS_TOKEN}")); + let m: Value = serde_json::from_str(&sent[0].body).expect("json"); + assert_eq!(m["message"]["token"], PUSH_TOKEN); + assert_eq!(m["message"]["data"], json!({"challenge_id": "ch_abc"})); + assert!(m["message"].get("notification").is_none(), "{m}"); + assert!(!sent[0].body.contains("number"), "no number in the message"); +} + +#[tokio::test] +async fn the_message_has_high_priority() { + let f = Fake::start(); + f.push() + .notify("fcm", PUSH_TOKEN, "ch_1") + .await + .expect("sent"); + let m: Value = serde_json::from_str(&f.requests("/v1/")[0].body).expect("json"); + assert_eq!(m["message"]["android"]["priority"], "HIGH"); +} + +#[tokio::test] +async fn the_jwt_is_rs256_with_the_right_claims() { + let f = Fake::start(); + f.push() + .notify("fcm", PUSH_TOKEN, "ch_1") + .await + .expect("sent"); + let t = f.requests("/token"); + assert_eq!(t.len(), 1); + let form = &t[0].body; + assert!( + form.starts_with( + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=" + ), + "{form}" + ); + let jwt = form.split("assertion=").nth(1).expect("assertion"); + let parts: Vec<&str> = jwt.split('.').collect(); + assert_eq!(parts.len(), 3); + let dec = |s: &str| -> Value { + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(s).expect("b64")).expect("json") + }; + assert_eq!(dec(parts[0]), json!({"alg": "RS256", "typ": "JWT"})); + let claims = dec(parts[1]); + assert_eq!(claims["iss"], EMAIL); + assert_eq!( + claims["scope"], + "https://www.googleapis.com/auth/firebase.messaging" + ); + assert_eq!(claims["aud"], format!("{}/token", f.base)); + let (iat, exp) = ( + claims["iat"].as_i64().expect("iat"), + claims["exp"].as_i64().expect("exp"), + ); + assert_eq!(exp - iat, 3600); + // the signature verifies with the public half of the key + let der = { + let b64: String = KEY_PEM + .lines() + .filter(|l| !l.starts_with('#') && !l.starts_with("-----")) + .collect(); + base64::engine::general_purpose::STANDARD + .decode(b64) + .expect("pem") + }; + let pair = RsaKeyPair::from_pkcs8(&der).expect("key"); + let sig = URL_SAFE_NO_PAD.decode(parts[2]).expect("sig"); + UnparsedPublicKey::new(&RSA_PKCS1_2048_8192_SHA256, pair.public().as_ref()) + .verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &sig) + .expect("signature"); +} + +#[tokio::test] +async fn the_access_token_is_reused_until_it_expires() { + let f = Fake::start(); + let p = f.push(); + p.notify("fcm", PUSH_TOKEN, "ch_1").await.expect("1"); + p.notify("fcm", PUSH_TOKEN, "ch_2").await.expect("2"); + assert_eq!(f.requests("/token").len(), 1); + assert_eq!(f.requests("/v1/").len(), 2); +} + +#[tokio::test] +async fn a_short_lived_access_token_is_fetched_again() { + let f = Fake::start(); + f.behaviour.lock().expect("b").token_body = + json!({"access_token": ACCESS_TOKEN, "expires_in": 30}).to_string(); + let p = f.push(); + p.notify("fcm", PUSH_TOKEN, "ch_1").await.expect("1"); + p.notify("fcm", PUSH_TOKEN, "ch_2").await.expect("2"); + assert_eq!( + f.requests("/token").len(), + 2, + "30 s is inside the 60 s margin" + ); +} + +#[tokio::test] +async fn an_error_answer_gives_a_short_error_without_secrets() { + let f = Fake::start(); + { + let mut b = f.behaviour.lock().expect("b"); + b.send_status = 400; + b.send_body = json!({"error": {"code": 400, + "message": format!("bad token {PUSH_TOKEN} for {ACCESS_TOKEN}"), + "status": "INVALID_ARGUMENT"}}) + .to_string(); + } + let e = f + .push() + .notify("fcm", PUSH_TOKEN, "ch_1") + .await + .expect_err("refused"); + assert!(e.contains("400") && e.contains("INVALID_ARGUMENT"), "{e}"); + for secret in [PUSH_TOKEN, ACCESS_TOKEN, "bad token"] { + assert!(!e.contains(secret), "{e}"); + } +} + +#[tokio::test] +async fn an_unregistered_phone_is_an_error_text_not_a_panic() { + let f = Fake::start(); + { + let mut b = f.behaviour.lock().expect("b"); + b.send_status = 404; + b.send_body = json!({"error": {"code": 404, "status": "NOT_FOUND", + "details": [{"errorCode": "UNREGISTERED"}]}}) + .to_string(); + } + let e = f + .push() + .notify("fcm", PUSH_TOKEN, "ch_1") + .await + .expect_err("refused"); + assert!(e.contains("404") && e.contains("UNREGISTERED"), "{e}"); +} + +#[tokio::test] +async fn a_failing_token_endpoint_is_an_error_without_the_assertion() { + let f = Fake::start(); + { + let mut b = f.behaviour.lock().expect("b"); + b.token_status = 401; + b.token_body = + json!({"error": "invalid_grant", "error_description": "private key rejected"}) + .to_string(); + } + let e = f + .push() + .notify("fcm", PUSH_TOKEN, "ch_1") + .await + .expect_err("refused"); + assert!(e.contains("401"), "{e}"); + let jwt = f.requests("/token")[0] + .body + .split("assertion=") + .nth(1) + .expect("a") + .to_owned(); + assert!(!e.contains(&jwt) && !e.contains("private key"), "{e}"); + assert!( + f.requests("/v1/").is_empty(), + "nothing is sent without a token" + ); +} + +#[tokio::test] +async fn a_slow_server_times_out() { + let f = Fake::start(); + f.behaviour.lock().expect("b").delay = Duration::from_millis(1500); + let p = f.push().with_timeout(Duration::from_millis(300)); + let started = std::time::Instant::now(); + let e = p + .notify("fcm", PUSH_TOKEN, "ch_1") + .await + .expect_err("timeout"); + assert!(started.elapsed() < Duration::from_secs(1), "{e}"); + assert!(!e.contains(PUSH_TOKEN), "{e}"); +} + +#[tokio::test] +async fn the_unifiedpush_channel_is_left_alone() { + let f = Fake::start(); + f.push() + .notify("unifiedpush", "https://up.example/x", "ch_1") + .await + .expect("noop"); + assert!(f.seen.lock().expect("seen").is_empty()); +} + +#[test] +fn a_bad_service_account_is_refused_without_echoing_the_key() { + for json in [ + "not json".to_owned(), + json!({"client_email": EMAIL}).to_string(), + json!({"client_email": EMAIL, "private_key": "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----"}).to_string(), + ] { + let e = FcmPush::from_service_account("demo-project", &json).err().expect("refused"); + assert!(!e.contains("AAAA"), "{e}"); + } +} diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs index d00c7dd..c74b745 100644 --- a/crates/rostdav-app/src/push_mfa/mod.rs +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -18,6 +18,9 @@ mod enroll_tests; mod error; #[cfg(test)] mod factor_tests; +mod fcm; +#[cfg(test)] +mod fcm_tests; mod google_roots; mod login; #[cfg(test)] @@ -32,6 +35,7 @@ pub use devauth::{DevicePrincipal, ParsedAuth, parse_authorization}; pub use devices::{DeviceInfo, MfaAuditEvent}; pub use enroll::{EnrollQr, EnrollToken, RegisterInput, Registered, enroll_qr_payload}; pub use error::MfaError; +pub use fcm::FcmPush; pub use google_roots::google_roots; pub use login::{LoginChallenge, SecondStepOptions}; pub use push::{NoopPush, PushChannel, PushFuture}; diff --git a/crates/rostdav-app/src/push_mfa/testdata/test_rsa_key.pem b/crates/rostdav-app/src/push_mfa/testdata/test_rsa_key.pem new file mode 100644 index 0000000..808ddd0 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/testdata/test_rsa_key.pem @@ -0,0 +1,29 @@ +# Throwaway RSA key for the FCM adapter tests. It signs nothing real and is used nowhere else. +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCmFtkFkyhNnrxM +pUojzf4c3EzgI+CCKvLMfcl371ip//Kw95CbUp5EkWose8eDoei6BTCjrhocznuc +KkMQDfR5mjvBZo3FbuCozeod39Sd7CkyL8BjOlfbZH6+7Bs7/RHfSLKzOJhrYMkL +cw91UOKasAsD08dKkRLg7MeJAeGGJCqXtZVAxekGJMi/MG4k3XOM0tMZnYDj9gSG +Ah6dy/CDHq+cc1WT2ahizJ2rtiw/9E/WC4F/WIfv50ke0b4qRSTWXh9y9EGMd+J9 +U15ZLTEas705nowdckqGkVb7zeW5veNk3EBxayzHgNUoaD9vL4cmxn/TDXD58kDk +4+P2jWZLAgMBAAECggEALjbCSTpSpxJpi5wcXo0VTXuBCocBE8cWiw7fDbe0AI1+ +vn4MJyPcdPDPTbRdZgOZqZUDHTA3BNfxbhkWUvPHGETxppU+EncRHPc60vgyhYSu +S9AcpJ+ZzaqPV9mDlHQM0oe8bYDsbEhgeIaaAQcyHl+omPaHB/kI+zv3I2i6E2Qv +D7r+3ZSNuYsYwsoTjSNTeebO6QzLlYYzkAkubkdFymWuW+2Mp85uihodk2Cotcn7 +SEs4M6W5iGZuy4rfqH7JUWJDymDvOhZm96YpAGlAJR9sTeT6kJ9HQd/lsdXUrTjG +vFRgV/u3eGSV9L6qCJ6v3iOa6sdVjHXEJ51Fx7bpLQKBgQDRfGMfCL1VpHOoV9TW +45ZDYZrIyK+An7wPXlWzDk5jTrYNE7lGCf3nTyAvpQWAfjN0ORrwcIw4T7EU7p3B +QrI9R3PiZTfcMSY33CnTl1Hi4vbc9SWMLYW9NF0n0rkcyl1D+LvZyA9wfeZvkIDV +BJ2qiwLOp/PTILeVXpCZEbBC/QKBgQDK97ScaOG72KvPB+epqBHwNoetNuY6lAKv +x7dyqiH78gjsaRI28JH72Uj4nzAHBa7mzEN+Dvl/pJedWCsgc7iy7ONNDjsraYuW +vHcot9DIaTrRISQCzLXlBln0N2LYmfJNJ6xK8cSyBEtDfwFBuoQnPWXNK31XhK9C +MfRmg9kE5wKBgEgf73Cr7aQi7r8zHlCuIvooDMa8cdBzLqiL7i2r60k9vGhcNhnr +LsQI9ifxRSesS8LkNmY6Gcz5gxqV3TLA5+U5SKjV+JmTQnlREAWNv9/CDQSeUsMk +mTs/YCNp5doHwny4ktS0kLxGXKRkuDafVDl3iwvjv4sdZJfE6zwIXwtxAoGBAKcN +QUYKvOAS8TWw1+c3Qaxn9TuHl+5Wq+9pfX+Or/Hhv550zF0Tm9OhfwHCY5EkCFRF +grdaM9fu7CrAyp4nILmDZ4GgWOreL9mcvKZyxBBvK828bRswyfT6SUdL3rtP2bHP +GWuFmdK63gdrnOkwTlzNl+BKDxvdRTJCU1mc4x25AoGAMzSr5Pq3glTzzWdX9Ojv +jM6cxdQn5mc9eXqmuoVeRXp9Boev1XELTG9cRWfcfg97x9E1MHyYi7dTJuGF6Ncr +xIZPTox2OOhTNP5Dfxvvya2j15w6eNaEG9DPY8qYSAOrZLReLz3aMyK7P8/oOTho +qD2v1x2jnyF3eeUyyBbPqDM= +-----END PRIVATE KEY----- diff --git a/crates/rostdav/src/config.rs b/crates/rostdav/src/config.rs index 2786eed..095b827 100644 --- a/crates/rostdav/src/config.rs +++ b/crates/rostdav/src/config.rs @@ -82,6 +82,18 @@ pub struct Mfa { pub audience: String, /// Whether phones must attest a hardware-backed key. pub attestation: MfaAttestation, + /// Waking phones through Firebase Cloud Messaging. Off unless both keys are set. + pub fcm: MfaFcm, +} + +/// `[mfa.fcm]`: the Firebase project and the service-account file that may send to it. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct MfaFcm { + /// Firebase project id. + pub project_id: Option, + /// Google service-account JSON file (the key never goes into the configuration itself). + pub service_account_file: Option, } impl Default for Mfa { @@ -91,6 +103,7 @@ impl Default for Mfa { enroll_ttl_seconds: 300, audience: "rostdav".into(), attestation: MfaAttestation::Required, + fcm: MfaFcm::default(), } } } @@ -113,6 +126,19 @@ impl Mfa { .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')), "mfa.audience must be 1 to 64 characters of A-Z a-z 0-9 . _ -" ); + anyhow::ensure!( + self.fcm.project_id.is_some() == self.fcm.service_account_file.is_some(), + "mfa.fcm needs both project_id and service_account_file, or neither" + ); + if let Some(id) = &self.fcm.project_id { + anyhow::ensure!( + (1..=128).contains(&id.len()) + && id.bytes().all( + |b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b':') + ), + "mfa.fcm.project_id must be 1 to 128 characters of A-Z a-z 0-9 . _ - :" + ); + } Ok(()) } } @@ -408,6 +434,37 @@ mod tests { } } + #[test] + fn fcm_settings_are_off_by_default_and_need_both_keys_or_none() { + assert_eq!(Config::default().mfa.fcm, MfaFcm::default()); + figment::Jail::expect_with(|jail| { + jail.create_file( + "m.toml", + "[mfa.fcm]\nproject_id = \"demo-project\"\nservice_account_file = \"/etc/rostdav/fcm.json\"\n", + )?; + let cfg = Config::load(Some(Path::new("m.toml"))).map_err(|e| e.to_string())?; + assert_eq!(cfg.mfa.fcm.project_id.as_deref(), Some("demo-project")); + assert_eq!( + cfg.mfa.fcm.service_account_file, + Some(PathBuf::from("/etc/rostdav/fcm.json")) + ); + Ok(()) + }); + for line in [ + "project_id = \"demo-project\"", + "service_account_file = \"/etc/rostdav/fcm.json\"", + "project_id = \"bad id!\"\nservice_account_file = \"/x\"", + "project_id = \"\"\nservice_account_file = \"/x\"", + ] { + figment::Jail::expect_with(|jail| { + jail.create_file("m.toml", &format!("[mfa.fcm]\n{line}\n"))?; + let err = Config::load(Some(Path::new("m.toml"))).expect_err(line); + assert!(err.to_string().contains("mfa.fcm"), "{line}: {err}"); + Ok(()) + }); + } + } + #[test] fn mfa_accepts_the_range_limits() { figment::Jail::expect_with(|jail| { diff --git a/crates/rostdav/src/lib.rs b/crates/rostdav/src/lib.rs index 5c045df..a892754 100644 --- a/crates/rostdav/src/lib.rs +++ b/crates/rostdav/src/lib.rs @@ -26,7 +26,7 @@ pub async fn build_state(cfg: &config::Config) -> anyhow::Result { let state = AppState::new(db, store) .await .context("initializing the application state failed")?; - Ok(state + let state = state .with_limits(rostdav_app::Limits { max_upload_bytes: cfg.limits.max_upload_bytes, max_chunk_bytes: cfg.limits.max_chunk_bytes, @@ -74,7 +74,28 @@ pub async fn build_state(cfg: &config::Config) -> anyhow::Result { // what the file and the environment leave open can be changed in the web interface .with_locked_settings(cfg.set_explicitly.iter().cloned()) .load_setting_overrides() - .await?) + .await?; + install_push_channel(&state, cfg).await?; + Ok(state) +} + +/// FCM, if `[mfa.fcm]` is set. A file that cannot be used stops the server: a push channel that +/// silently does nothing would leave phones unwoken with no hint why. +async fn install_push_channel(state: &AppState, cfg: &config::Config) -> anyhow::Result<()> { + let (Some(project), Some(file)) = (&cfg.mfa.fcm.project_id, &cfg.mfa.fcm.service_account_file) + else { + return Ok(()); + }; + let json = tokio::fs::read_to_string(file).await.with_context(|| { + format!( + "mfa.fcm.service_account_file: cannot read {}", + file.display() + ) + })?; + let fcm = rostdav_app::FcmPush::from_service_account(project, &json) + .map_err(|e| anyhow::anyhow!("mfa.fcm.service_account_file: {e}"))?; + state.set_push_channel(std::sync::Arc::new(fcm)); + Ok(()) } /// Where the key for the two-factor secrets comes from (ADR 0007). diff --git a/crates/rostdav/tests/mfa_fcm.rs b/crates/rostdav/tests/mfa_fcm.rs new file mode 100644 index 0000000..f4c4486 --- /dev/null +++ b/crates/rostdav/tests/mfa_fcm.rs @@ -0,0 +1,62 @@ +//! The FCM push channel is built from the settings at start-up. A wrong setting stops the +//! server with a message that names the key and does not echo the file. + +use rostdav::config::Config; + +const KEY_PEM: &str = include_str!("../../rostdav-app/src/push_mfa/testdata/test_rsa_key.pem"); + +fn config(dir: &tempfile::TempDir, file: &str) -> Config { + let mut cfg = Config::default(); + cfg.storage.data_dir = dir.path().join("data"); + cfg.mfa.fcm.project_id = Some("demo-project".into()); + cfg.mfa.fcm.service_account_file = Some(dir.path().join(file)); + cfg +} + +async fn start(cfg: &Config) -> Result<(), String> { + rostdav::build_state(cfg) + .await + .map(|_| ()) + .map_err(|e| format!("{e:#}")) +} + +#[tokio::test] +async fn a_missing_service_account_file_stops_start_up() { + let dir = tempfile::tempdir().expect("tempdir"); + let e = start(&config(&dir, "nope.json")) + .await + .expect_err("refused"); + assert!(e.contains("mfa.fcm.service_account_file"), "{e}"); +} + +#[tokio::test] +async fn a_file_that_is_no_service_account_stops_start_up_without_echoing_it() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("bad.json"), + r#"{"private_key": "SECRET-GARBAGE"}"#, + ) + .expect("write"); + let e = start(&config(&dir, "bad.json")).await.expect_err("refused"); + assert!(e.contains("mfa.fcm.service_account_file"), "{e}"); + assert!(!e.contains("SECRET-GARBAGE"), "{e}"); +} + +#[tokio::test] +async fn a_valid_service_account_file_starts() { + let dir = tempfile::tempdir().expect("tempdir"); + let json = serde_json::json!({ + "client_email": "svc@demo-project.iam.gserviceaccount.com", + "private_key": KEY_PEM, + }); + std::fs::write(dir.path().join("ok.json"), json.to_string()).expect("write"); + start(&config(&dir, "ok.json")).await.expect("starts"); +} + +#[tokio::test] +async fn without_fcm_settings_the_server_starts_with_no_push() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut cfg = Config::default(); + cfg.storage.data_dir = dir.path().join("data"); + start(&cfg).await.expect("starts"); +} diff --git a/docs/admin.md b/docs/admin.md index 21e4062..3bb7a22 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -139,9 +139,12 @@ Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa | `mfa.enroll_ttl_seconds` | 300 | Seconds an enrollment QR code stays valid (60 to 900). | | `mfa.audience` | `rostdav` | Service identifier signed into every challenge: 1 to 64 characters of `A-Z a-z 0-9 . _ -`. Changing it invalidates challenges in flight. | | `mfa.attestation` | `required` | `required`: a phone must prove a hardware-backed key at registration. `off`: no check, for custom ROMs without Google key attestation. With `off`, a stolen enrollment QR code is enough to register software keys, so keep the enrollment time short. | +| `mfa.fcm.project_id`, `mfa.fcm.service_account_file` | unset | Wake phones through Firebase Cloud Messaging. Both or neither. See below. | A value outside its range stops the server at start-up with a message naming the key. +**Push delivery (FCM).** Without it a phone sees a sign-in request only when its app polls. To turn it on: create a Firebase project (or use the one of your RustMFA build), add a service account with the role "Firebase Cloud Messaging API Admin", download its JSON key, store it outside the data directory with mode 0600, and set `mfa.fcm.project_id` and `mfa.fcm.service_account_file`. A file that cannot be read or is not a service account stops the server at start-up (the message names the key and never shows the file). Rostdav signs a token request with the key, keeps the access token for about an hour and sends one data message per challenge. The message holds only the challenge id; Google learns that a request is pending for a phone, nothing else. A push that fails (Google unreachable, phone unregistered) is logged with the challenge id and never blocks the sign-in: the phone still finds the request by polling. + **Limits.** A user can create five enrollment tokens in 15 minutes. A client address that sends five wrong or unusable enrollment tokens, or five attestation chains that fail, is blocked from registering for 15 minutes (these blocks are not listed on the lockouts page yet). Malformed requests do not count. Device names are shortened to 128 characters and stripped of control characters. A phone whose signed request fails five times in 15 minutes (wrong signature, unknown device, replayed nonce, bad timestamp) is blocked for 15 minutes; this block is not listed on the lockouts page yet. Requests of a revoked phone, or of a disabled account, are refused. diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index 411ee0f..7b00a54 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -48,7 +48,7 @@ Migration `0013_push_mfa.sql` with four `STRICT` tables, a plain index on every - `PushChannel` trait in `rostdav-app`; delivery adapters follow in the second step. **Implementation notes 2026-11-09:** `rostdav-mfa-api` exposes `router() -> Router` (merged into the server's router like the other protocol crates; the paths are complete so the signed path is the received one). It reads at most 64 KiB (413 `INVALID_REQUEST` beyond that; the contract has no 413), refuses unknown JSON fields, and sets `cache-control: no-store`. It has no dependency on `rostdav-meta`. Its integration tests use a `test-util` feature of `rostdav-app` (the `testkit` module: synthetic attestation chains, a fresh state); production builds do not enable it. The tests check responses against a JSON mirror of the OpenAPI file kept in the `rostdav-mfa` repository (a contract test keeps the mirror equal to the YAML), so Rostdav needs no YAML crate. Listing challenges supports `status=pending` only. - **Implementation notes 2026-11-10:** `AppState::has_second_factor` (TOTP on or at least one active phone) replaces the TOTP-only check at the three sign-in decisions (browser sign-in, Login Flow v2, WebDAV/OCS password). The TOTP management pages keep asking about TOTP alone. A challenge started for a sign-in carries a `login_ref`, a keyed hash of the sign-in's ticket and flow. Only the holder of that ticket can poll it (`mfa_login_status`) or turn it into a session (`mfa_login_release`). The release is one conditional update on `released_at` and needs the approving phone to be still active, so a phone revoked after it approved releases nothing. The web interface asks for the account password before it shows an enrollment QR code (`web_mfa_enroll_start`), so a stolen session cookie alone cannot add a phone. The QR payload is `rustmfa://enroll?server=&token=` (written into the design spec, section 4.1). + **Implementation notes 2026-11-10:** `AppState::has_second_factor` (TOTP on or at least one active phone) replaces the TOTP-only check at the three sign-in decisions (browser sign-in, Login Flow v2, WebDAV/OCS password). The TOTP management pages keep asking about TOTP alone. A challenge started for a sign-in carries a `login_ref`, a keyed hash of the sign-in's ticket and flow. Only the holder of that ticket can poll it (`mfa_login_status`) or turn it into a session (`mfa_login_release`). The release is one conditional update on `released_at` and needs the approving phone to be still active, so a phone revoked after it approved releases nothing. The web interface asks for the account password before it shows an enrollment QR code (`web_mfa_enroll_start`), so a stolen session cookie alone cannot add a phone. The QR payload is `rustmfa://enroll?server=&token=` (written into the design spec, section 4.1). The FCM adapter (`push_mfa/fcm.rs`) uses the HTTP v1 API: a JWT (RS256, signed with `ring`) from the service-account file is exchanged for an access token that is kept until a minute before it expires; the call runs in `spawn_blocking` with `ureq` and a five-second timeout. Error texts carry the HTTP status and Google's status words (`UNREGISTERED` and the like), never free text, which can echo the push token. ### Dependencies (justification) diff --git a/docs/security.md b/docs/security.md index 3f70cca..95c8e2e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -32,6 +32,7 @@ Binding requirements: section 5 of the [instructions](instructions.md). Short ve | E1 | User calls an admin function | Role check in the service, not only in the UI | open (M7) | | E2 | XSS via file names | Automatic escaping (askama), strict CSP | open (M11) | | S4 | Approval of a sign-in by an attacker (prompt bombing, a tap without looking, a stolen enrollment QR code) | Push is only a hint: the phone fetches the challenge from the server and shows the sign-in device; the user must pick the two-digit number shown in the browser (number matching), a wrong number denies the challenge. The answer is signed with a biometric-gated key; the server checks that signature before the number. At most five challenges per user in 15 minutes. Enrollment tokens are single use, short lived and stored only as a hash; the phone proves a hardware-backed key (attestation `required` by default, `off` is an admin decision with a stated trade-off). | M14 backend and sign-in steps (browser, Login Flow v2) implemented (ADR 0014) | +| S6 | Push channel leaks sign-in data to Google | The FCM message is a data message with the challenge id only: no number, no user name, no service name. Google sees that a request is pending for a phone. The service-account key is a file outside the database and the configuration, never logged; error texts of the adapter hold HTTP status and Google's status word only | M14 implemented (ADR 0014) | | S5 | Replay or forgery of phone requests | Every request carries a timestamp (60 seconds), a nonce (used once in 5 minutes) and a signature by the phone's request key over method, path, body hash and audience; DER signatures are rejected; five failures per phone in 15 minutes block it for 15 minutes; revoked phones and disabled accounts are refused. | M14 backend implemented | ## Cryptography diff --git a/rostdav.example.toml b/rostdav.example.toml index 7e23c29..bbaedfc 100644 --- a/rostdav.example.toml +++ b/rostdav.example.toml @@ -71,6 +71,14 @@ data_dir = "./data" # "off": no check, for custom ROMs without Google key attestation. # attestation = "required" +[mfa.fcm] +# Wake phones through Firebase Cloud Messaging when a sign-in approval waits. Off unless both keys +# are set. The file is a Google service-account key (JSON) with the role "Firebase Cloud +# Messaging API Admin"; keep it readable for the Rostdav user only. Without FCM a phone sees a +# request when its app polls. +# project_id = "my-firebase-project" +# service_account_file = "/etc/rostdav/fcm-service-account.json" + [limits] # Maximum size of a single upload in bytes (default: 16 GiB). # max_upload_bytes = 17179869184 From 10322b84c0ec400f4a6e58c36093059f9405f1e9 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 22:13:02 +0100 Subject: [PATCH 23/24] feat(mfa): phone blocks on the lockouts page The rate-limit blocks of the phone features (enrollment per address and per user, signed requests per phone, approvals per user) are listed on Administration > Security and can be lifted there. --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- crates/rostdav-app/src/admin.rs | 18 ++- .../rostdav-app/src/push_mfa/lockout_tests.rs | 113 ++++++++++++++++++ crates/rostdav-app/src/push_mfa/mod.rs | 2 + crates/rostdav-app/src/push_mfa/testkit.rs | 11 ++ crates/rostdav-web/i18n/de.tsv | 4 + crates/rostdav-web/src/admin.rs | 20 ++-- crates/rostdav/tests/web.rs | 52 ++++++++ docs/admin.md | 6 +- docs/plan.md | 2 +- 11 files changed, 216 insertions(+), 15 deletions(-) create mode 100644 crates/rostdav-app/src/push_mfa/lockout_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b0f09..43ef05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes are listed here. The format follows [Keep a Changelog](https ### Added +- **Phone blocks on the lockouts page**: the rate-limit blocks of the phone features (enrollment per address and per user, signed requests per phone, sign-in approvals per user) are listed on Administration › Security and can be lifted there. - **Push delivery through FCM** (`[mfa.fcm]`: `project_id`, `service_account_file`): when a sign-in approval waits, Rostdav wakes the phone with a data message that holds the challenge id only. Off unless both keys are set; a bad service-account file stops the server at start-up. A failed push is logged and never blocks the sign-in. - **Phones in the web interface**: Devices & security lists the user's RustMFA phones (name, status, last seen) with revoke, and the last security events. "Add a phone" asks for the account password again and shows a single-use enrollment QR code (`rustmfa://enroll?server=…&token=…`). Five codes per user in 15 minutes. Revoking a phone ends its open sign-in approvals. - **Sign in with the RustMFA phone in the browser**: the second step offers "Send to my phone" (next to the code form when authenticator codes are on). The page shows a number, the user picks it on the phone, and the page signs in once the phone approved. A denied or expired request goes back to the second step. The Login Flow v2 page (connecting a Nextcloud app) has the same step. diff --git a/CLAUDE.md b/CLAUDE.md index 21fd5b0..71ac1a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ The binding working rules are in [AGENTS.md](AGENTS.md). The full project instru - M2 (WebDAV core) complete. M3: server side recorded (Nextcloud 35.0.2, synthetic requests), real-app capture open. M4 (sign-in) implemented; ADR 0003 (app passwords, Login Flow v2, rate limiting) is accepted (2026-10-23). M5 (Nextcloud WebDAV extensions, chunked upload v2) implemented. M7 (admin CLI, event log, fsck, import) implemented. M6 (real app on the Android emulator) is postponed to the end by the project owner. M8 (versioning), M9 (shares, ADR 0004 accepted) and M10 (trash, ADR 0005) implemented. M11 (web interface, ADR 0006 accepted): slice 11a implemented (sign-in, Files, Trash, Devices, Settings, public link page). 11b implemented (share dialog, versions panel, details, folder filter). Chunked browser upload done (JS not yet tested in a real browser). 11c implemented (administration pages). 11d done (profile, change password, data export, German/English). M11 complete. M12 (two-factor, ADR 0007 accepted) implemented. M13 (retention overview, `default_quota`, `upload_retention_hours`, streamed data export) done. Phase 5 done except coverage-guided fuzzing (authorization matrix, robustness tests, load tests, container image + Compose, backup/restore, operations docs). M6 complete for now (2026-10-23): verified with the real Android app (sign-in incl. 2FA, files, favorites, shares, public link, versions, trash, offline) and the desktop sync engine (`nextcloudcmd`); not tried: desktop GUI, iOS client. ADR 0003 accepted; cargo-fuzz targets in `fuzz/`. Web interface gaps from the mockups closed (maintenance mode, lockouts, shares, recent, sortable columns); left out: avatar upload, editable admin limits. - About page, release notes, opt-in version check and the updater (ADR 0009) are built. The updater needs the maintainer's minisign key (`CONTRIBUTING.md`, "Release signing key"): until `crates/rostdav-app/update-key.pub` holds a key, it reports "no update signing key". The signing step of the release workflow has not run yet. Documentation site in `site/` (GitHub Pages, `just site`). -- M14 (push sign-in approval with the RustMFA app, ADR 0014 accepted 2026-11-08): backend implemented on branch `feat/push-mfa` (`[mfa]` settings, migration 0013, attestation, enrollment, request authentication, challenges, device management, HTTP API in `rostdav-mfa-api`). Plan 2b implemented: phone as second factor, browser and Login Flow v2 phone step, phone enrollment and revoke pages, FCM adapter (`[mfa.fcm]`). Open: the admin lockouts page does not list the MFA rate-limit blocks yet, and the Android app (Plan 3). `rustmfa-contract` is a git dependency pinned by revision (repository `pierrejochem/rostdav-mfa`); a contract change needs a commit there and a new pin here. +- M14 (push sign-in approval with the RustMFA app, ADR 0014 accepted 2026-11-08): backend implemented on branch `feat/push-mfa` (`[mfa]` settings, migration 0013, attestation, enrollment, request authentication, challenges, device management, HTTP API in `rostdav-mfa-api`). Plan 2b implemented: phone as second factor, browser and Login Flow v2 phone step, phone enrollment and revoke pages, FCM adapter (`[mfa.fcm]`). The admin lockouts page lists the MFA rate-limit blocks. Open: the Android app (Plan 3). `rustmfa-contract` is a git dependency pinned by revision (repository `pierrejochem/rostdav-mfa`); a contract change needs a commit there and a new pin here. ## Releases diff --git a/crates/rostdav-app/src/admin.rs b/crates/rostdav-app/src/admin.rs index 3ef4c9a..c06f93a 100644 --- a/crates/rostdav-app/src/admin.rs +++ b/crates/rostdav-app/src/admin.rs @@ -51,6 +51,8 @@ pub enum LockoutKind { Password, /// Wrong two-factor codes. SecondFactor, + /// The phone features (ADR 0014): enrollment, signed requests, sign-in approvals. + Phone, } /// A client address or user that is blocked for now. @@ -60,6 +62,8 @@ pub struct Lockout { pub by_address: bool, pub who: String, pub kind: LockoutKind, + /// What the key counts: the part before the colon (`ip`, `user`, `totp`, `mfa-enroll`, ...). + pub scope: String, pub secs_left: u64, } @@ -322,18 +326,25 @@ impl AppState { for (list, kind) in [ (self.inner.limiter.blocked(), LockoutKind::Password), (self.inner.totp_limiter.blocked(), LockoutKind::SecondFactor), + (self.inner.mfa_limiter.blocked(), LockoutKind::Phone), ] { for (key, secs_left) in list { if let Some((scope, who)) = key.split_once(':') { out.push(Lockout { key: key.clone(), - by_address: scope == "ip", - // Second-factor keys hold the user ID, password keys the user name. + by_address: scope == "ip" || scope == "mfa-register", + // Second-factor and phone keys hold the user ID, password keys the user + // name; a phone key can also hold a device id or an address. who: match names.get(who) { - Some(name) if scope == "totp" => name.clone(), + Some(name) + if matches!(scope, "totp" | "mfa-enroll" | "mfa-challenge") => + { + name.clone() + } _ => who.to_owned(), }, kind, + scope: scope.to_owned(), secs_left, }); } @@ -348,6 +359,7 @@ impl AppState { match kind { LockoutKind::Password => self.inner.limiter.clear(key), LockoutKind::SecondFactor => self.inner.totp_limiter.clear(key), + LockoutKind::Phone => self.inner.mfa_limiter.clear(key), } self.audit(&actor.name(), "admin.unlocked", key, None, json!({})) .await; diff --git a/crates/rostdav-app/src/push_mfa/lockout_tests.rs b/crates/rostdav-app/src/push_mfa/lockout_tests.rs new file mode 100644 index 0000000..154ea96 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/lockout_tests.rs @@ -0,0 +1,113 @@ +//! The blocks of the phone features are listed on the lockouts page and can be lifted there. + +use super::testkit::{add_user, principal, state}; +use crate::{Actor, LockoutKind}; + +async fn admin(st: &crate::AppState) -> crate::Principal { + st.add_user("root", "correct-horse-battery", true) + .await + .expect("admin"); + principal(st, "root", true).await +} + +#[tokio::test] +async fn an_enrollment_block_is_listed_with_the_user_name() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + for _ in 0..5 { + st.mfa_create_enroll_token(&anna).await.expect("token"); + } + assert!(st.mfa_create_enroll_token(&anna).await.is_err(), "blocked"); + let root = admin(&st).await; + let locks = st.active_lockouts(&Actor::User(&root)).await.expect("list"); + let l = locks + .iter() + .find(|l| l.key == format!("mfa-enroll:{}", anna.user_id)) + .unwrap_or_else(|| panic!("{locks:?}")); + assert_eq!( + (l.kind, l.by_address, l.who.as_str()), + (LockoutKind::Phone, false, "anna") + ); + assert_eq!(l.scope, "mfa-enroll"); +} + +#[tokio::test] +async fn a_blocked_phone_and_a_blocked_address_are_listed() { + let (_d, st) = state().await; + for _ in 0..5 { + st.inner.mfa_limiter.record_failure("mfa-auth:dev_a"); + st.inner + .mfa_limiter + .record_failure("mfa-register:192.0.2.7"); + st.inner.mfa_limiter.record_failure("mfa-challenge:99"); + } + let root = admin(&st).await; + let locks = st.active_lockouts(&Actor::Cli).await.expect("list"); + let find = |k: &str| { + locks + .iter() + .find(|l| l.key == k) + .unwrap_or_else(|| panic!("{k}: {locks:?}")) + }; + let dev = find("mfa-auth:dev_a"); + assert_eq!( + (dev.kind, dev.by_address, dev.who.as_str()), + (LockoutKind::Phone, false, "dev_a") + ); + let addr = find("mfa-register:192.0.2.7"); + assert_eq!( + (addr.kind, addr.by_address, addr.who.as_str()), + (LockoutKind::Phone, true, "192.0.2.7") + ); + let ch = find("mfa-challenge:99"); + assert_eq!(ch.kind, LockoutKind::Phone); + drop(root); +} + +#[tokio::test] +async fn unlocking_lifts_an_mfa_block_and_audits_it() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + for _ in 0..5 { + st.mfa_create_enroll_token(&anna).await.expect("token"); + } + let root = admin(&st).await; + let key = format!("mfa-enroll:{}", anna.user_id); + st.unlock(&Actor::User(&root), &key, LockoutKind::Phone) + .await + .expect("unlock"); + assert!(st.mfa_create_enroll_token(&anna).await.is_ok()); + let events = st.inner.db.events_since(0).await.expect("events"); + assert!( + events + .iter() + .any(|e| e.action == "admin.unlocked" && e.target == key) + ); +} + +#[tokio::test] +async fn unlocking_with_the_phone_kind_leaves_other_blocks_alone() { + let (_d, st) = state().await; + for _ in 0..10 { + st.inner.limiter.record_failure("user:bob"); + } + let root = admin(&st).await; + st.unlock(&Actor::User(&root), "user:bob", LockoutKind::Phone) + .await + .expect("noop"); + let locks = st.active_lockouts(&Actor::Cli).await.expect("list"); + assert!(locks.iter().any(|l| l.key == "user:bob"), "{locks:?}"); +} + +#[tokio::test] +async fn a_user_who_is_no_administrator_cannot_list_or_unlock() { + let (_d, st) = state().await; + let anna = add_user(&st, "anna").await; + let who = Actor::User(&anna); + assert!(st.active_lockouts(&who).await.is_err()); + assert!( + st.unlock(&who, "mfa-enroll:1", LockoutKind::Phone) + .await + .is_err() + ); +} diff --git a/crates/rostdav-app/src/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs index c74b745..e57ea01 100644 --- a/crates/rostdav-app/src/push_mfa/mod.rs +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -22,6 +22,8 @@ mod fcm; #[cfg(test)] mod fcm_tests; mod google_roots; +#[cfg(test)] +mod lockout_tests; mod login; #[cfg(test)] mod login_tests; diff --git a/crates/rostdav-app/src/push_mfa/testkit.rs b/crates/rostdav-app/src/push_mfa/testkit.rs index a5d9b3b..4981aa2 100644 --- a/crates/rostdav-app/src/push_mfa/testkit.rs +++ b/crates/rostdav-app/src/push_mfa/testkit.rs @@ -370,3 +370,14 @@ pub async fn user_id(st: &AppState, name: &str) -> i64 { .expect("user exists") .id } + +/// An existing user as the web interface sees them after the password step. +pub async fn principal(st: &AppState, name: &str, is_admin: bool) -> Principal { + Principal { + user_id: user_id(st, name).await, + name: name.to_owned(), + is_admin, + via: Via::AccountPassword, + scope: Scope::Full, + } +} diff --git a/crates/rostdav-web/i18n/de.tsv b/crates/rostdav-web/i18n/de.tsv index eb9a671..d3c9e63 100644 --- a/crates/rostdav-web/i18n/de.tsv +++ b/crates/rostdav-web/i18n/de.tsv @@ -496,3 +496,7 @@ Server address: {0} Serveradresse: {0} Scan with the RustMFA app Mit der RustMFA-App scannen Anyone who scans this code before it expires can add a phone to your account. Keep it to yourself. Wer diesen Code vor Ablauf scannt, kann ein Handy zu deinem Konto hinzufügen. Behalte ihn für dich. Enrollment code Einrichtungscode +Address (phone enrollment) Adresse (Handy-Einrichtung) +User (phone enrollment) Benutzer (Handy-Einrichtung) +User (sign-in approvals) Benutzer (Anmelde-Bestätigungen) +Phone (signed requests) Handy (signierte Anfragen) diff --git a/crates/rostdav-web/src/admin.rs b/crates/rostdav-web/src/admin.rs index 22c24dd..3462851 100644 --- a/crates/rostdav-web/src/admin.rs +++ b/crates/rostdav-web/src/admin.rs @@ -1311,10 +1311,14 @@ async fn security_page(state: &AppState, a: &Authed) -> Response { let rows: String = locks .iter() .map(|l| { - let (what, kind) = match (l.by_address, l.kind) { - (true, _) => (t(lang, "Address"), "password"), - (false, LockoutKind::Password) => (t(lang, "User"), "password"), - (false, LockoutKind::SecondFactor) => (t(lang, "User (two-factor code)"), "2fa"), + let (what, kind) = match (l.kind, l.scope.as_str()) { + (LockoutKind::Phone, "mfa-register") => (t(lang, "Address (phone enrollment)"), "mfa"), + (LockoutKind::Phone, "mfa-enroll") => (t(lang, "User (phone enrollment)"), "mfa"), + (LockoutKind::Phone, "mfa-challenge") => (t(lang, "User (sign-in approvals)"), "mfa"), + (LockoutKind::Phone, _) => (t(lang, "Phone (signed requests)"), "mfa"), + (_, _) if l.by_address => (t(lang, "Address"), "password"), + (LockoutKind::Password, _) => (t(lang, "User"), "password"), + (LockoutKind::SecondFactor, _) => (t(lang, "User (two-factor code)"), "2fa"), }; format!( "{}{}{}\ @@ -1395,10 +1399,10 @@ pub async fn unlock( if !csrf_ok(&state, &a, &f.csrf) { return forbidden(); } - let kind = if f.kind == "2fa" { - LockoutKind::SecondFactor - } else { - LockoutKind::Password + let kind = match f.kind.as_str() { + "2fa" => LockoutKind::SecondFactor, + "mfa" => LockoutKind::Phone, + _ => LockoutKind::Password, }; match state .unlock(&Actor::User(&a.session.principal), &f.key, kind) diff --git a/crates/rostdav/tests/web.rs b/crates/rostdav/tests/web.rs index 9bb08e3..49f55f7 100644 --- a/crates/rostdav/tests/web.rs +++ b/crates/rostdav/tests/web.rs @@ -6189,3 +6189,55 @@ async fn revoking_needs_csrf_and_cannot_touch_another_users_phone() { "bob's phone is untouched" ); } + +#[tokio::test] +async fn admin_sees_and_lifts_phone_blocks() { + let e = env().await; + let root = login(&e, "root").await; + let csrf = page_csrf(&e, &root).await; + let bob = testkit::principal(&e.state, "bob", false).await; + for _ in 0..5 { + e.state.mfa_create_enroll_token(&bob).await.expect("token"); + } + let key = format!("mfa-enroll:{}", bob.user_id); + let page = get(&e, "/admin/security", &root).await.text(); + assert!( + page.contains("User (phone enrollment)") && page.contains("bob"), + "{page}" + ); + assert!(page.contains(&format!("name=\"key\" value=\"{key}\""))); + assert!(page.contains("name=\"kind\" value=\"mfa\"")); + // the wrong kind clears nothing + let r = post_form( + &e, + "/admin/security/unlock", + &root, + &[("csrf", &csrf), ("key", &key), ("kind", "password")], + ) + .await; + assert_eq!(r.status, StatusCode::SEE_OTHER); + assert!( + get(&e, "/admin/security", &root) + .await + .text() + .contains(&key) + ); + let r = post_form( + &e, + "/admin/security/unlock", + &root, + &[("csrf", &csrf), ("key", &key), ("kind", "mfa")], + ) + .await; + assert_eq!(r.status, StatusCode::SEE_OTHER); + assert!( + !get(&e, "/admin/security", &root) + .await + .text() + .contains(&key) + ); + assert!(e.state.mfa_create_enroll_token(&bob).await.is_ok()); + // a user who is no administrator gets no lockouts page + let b = login(&e, "bob").await; + assert_ne!(get(&e, "/admin/security", &b).await.status, StatusCode::OK); +} diff --git a/docs/admin.md b/docs/admin.md index 3bb7a22..05d4589 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -145,9 +145,11 @@ A value outside its range stops the server at start-up with a message naming the **Push delivery (FCM).** Without it a phone sees a sign-in request only when its app polls. To turn it on: create a Firebase project (or use the one of your RustMFA build), add a service account with the role "Firebase Cloud Messaging API Admin", download its JSON key, store it outside the data directory with mode 0600, and set `mfa.fcm.project_id` and `mfa.fcm.service_account_file`. A file that cannot be read or is not a service account stops the server at start-up (the message names the key and never shows the file). Rostdav signs a token request with the key, keeps the access token for about an hour and sends one data message per challenge. The message holds only the challenge id; Google learns that a request is pending for a phone, nothing else. A push that fails (Google unreachable, phone unregistered) is logged with the challenge id and never blocks the sign-in: the phone still finds the request by polling. -**Limits.** A user can create five enrollment tokens in 15 minutes. A client address that sends five wrong or unusable enrollment tokens, or five attestation chains that fail, is blocked from registering for 15 minutes (these blocks are not listed on the lockouts page yet). Malformed requests do not count. Device names are shortened to 128 characters and stripped of control characters. +**Limits.** A user can create five enrollment tokens in 15 minutes. A client address that sends five wrong or unusable enrollment tokens, or five attestation chains that fail, is blocked from registering for 15 minutes. Malformed requests do not count. Device names are shortened to 128 characters and stripped of control characters. -A phone whose signed request fails five times in 15 minutes (wrong signature, unknown device, replayed nonce, bad timestamp) is blocked for 15 minutes; this block is not listed on the lockouts page yet. Requests of a revoked phone, or of a disabled account, are refused. +A phone whose signed request fails five times in 15 minutes (wrong signature, unknown device, replayed nonce, bad timestamp) is blocked for 15 minutes. Requests of a revoked phone, or of a disabled account, are refused. + +All of these blocks are listed on Administration › Security ("Address (phone enrollment)", "User (phone enrollment)", "User (sign-in approvals)", "Phone (signed requests)") and can be lifted there. A user can have five sign-in approvals started in 15 minutes (this keeps a stolen password from flooding a phone with prompts); further attempts are refused until the window ends. The approval screen shows the sign-in device name but no location: Rostdav has no IP geolocation. diff --git a/docs/plan.md b/docs/plan.md index 4c33459..262ae78 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -71,7 +71,7 @@ Prioritized by smallest testable benefit. Every milestone ends with green tests | M11 | Web interface: files, devices, settings, administration, light/dark ([ADR 0006](adr/0006-web-interface.md)). **11a ✅** (sign-in, Files, Trash, Devices, Settings, public link page); **11b (share dialog, versions panel, details, folder filter, chunked browser upload) ✅**; **11c administration (users, event log, status/fsck, server settings) ✅**, **11d: profile, change password, data export, languages (de/en) ✅** | M7–M10 | | M12 ✅ | Optional two-factor authentication (TOTP + recovery codes; [ADR 0007](adr/0007-two-factor-authentication.md), 2026-10-22) | M11 | | M13 ✅ | Retention rules, data export (2026-10-22; the export is streamed as a tar archive instead of a background job with a download link, see [ui.md](ui.md)) | M8, M10 | -| M14 (backend ✅, 2026-11-09; sign-in page, FCM, web device pages, Android app open) | Push sign-in approval with the RustMFA app: device enrollment, signed challenges, device API ([ADR 0014](adr/0014-push-mfa.md), accepted 2026-11-08, approval point 3 granted) | M12 | +| M14 (server side ✅, 2026-11-10: backend, sign-in steps, web device pages, FCM, lockouts page; Android app open) | Push sign-in approval with the RustMFA app: device enrollment, signed challenges, device API ([ADR 0014](adr/0014-push-mfa.md), accepted 2026-11-08, approval point 3 granted) | M12 | ## Phase 5: Hardening and delivery From c35a8a09e48535d5658abdff0362294f699e983c Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Tue, 10 Nov 2026 22:39:38 +0100 Subject: [PATCH 24/24] fix(mfa): recovery of phone-only users, stronger checks on the only factor, one approval per sign-in Final review findings: the admin reset revokes phones (a lost only-phone no longer locks the user out for good); enrolling a phone also needs a TOTP code when codes are on; revoking the only second factor needs the password; a second start of the same sign-in shows the open approval instead of creating another. --- CHANGELOG.md | 6 + crates/rostdav-app/src/push_mfa/devices.rs | 30 +++++ crates/rostdav-app/src/push_mfa/enroll.rs | 18 ++- .../src/push_mfa/enroll_qr_tests.rs | 10 +- .../rostdav-app/src/push_mfa/factor_tests.rs | 99 ++++++++++++++ crates/rostdav-app/src/push_mfa/login.rs | 25 +++- .../rostdav-app/src/push_mfa/login_tests.rs | 54 ++++++++ crates/rostdav-app/src/two_factor.rs | 50 +++++++- crates/rostdav-meta/src/push_mfa.rs | 23 ++++ crates/rostdav-web/i18n/de.tsv | 2 + crates/rostdav-web/src/phones.rs | 98 ++++++++++++-- crates/rostdav/tests/web.rs | 121 +++++++++++++++++- docs/admin.md | 6 +- docs/adr/0014-push-mfa.md | 2 +- 14 files changed, 507 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ef05c..5eedbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes are listed here. The format follows [Keep a Changelog](https - **An enrolled RustMFA phone counts as a second factor**, like an authenticator app (ADR 0014). Once a user has an active phone, WebDAV and OCS no longer accept their account password (app passwords still work) and the browser sign-in asks for a second step. +### Fixed + +- **Recovery of a user whose only second factor is a phone**: `reset-2fa` (command line and administration page) now revokes the user's RustMFA phones too, and the administration page sees a phone as a second factor. Before, such a user who lost the phone could not sign in and nobody could help. +- **Adding or removing the only second factor from the web needs more than a session**: enrolling a phone asks for a current authenticator code as well when codes are on, and revoking the phone that is the only second factor asks for the password. +- **Reloading the number page does not start a second approval**: the open approval of a sign-in is shown again (browser and Login Flow v2), so the phone gets one prompt. + ### Added - **Phone blocks on the lockouts page**: the rate-limit blocks of the phone features (enrollment per address and per user, signed requests per phone, sign-in approvals per user) are listed on Administration › Security and can be lifted there. diff --git a/crates/rostdav-app/src/push_mfa/devices.rs b/crates/rostdav-app/src/push_mfa/devices.rs index 0946d7f..8a6f49a 100644 --- a/crates/rostdav-app/src/push_mfa/devices.rs +++ b/crates/rostdav-app/src/push_mfa/devices.rs @@ -106,6 +106,36 @@ impl AppState { Ok(()) } + /// Revoking from the web interface. Revoking the phone that is the account's only second + /// factor takes the factor away (browser sign-in and WebDAV fall back to the password), so it + /// needs the account password, like turning TOTP off: a stolen session cookie alone must not + /// be able to do it. Another user's phone is `NotFound` whatever the password. + pub async fn web_mfa_revoke( + &self, + s: &crate::WebSession, + device_id: &str, + password: &str, + ip: std::net::IpAddr, + ) -> Result<(), MfaError> { + let p = &s.principal; + let devices = self.inner.db.mfa_devices_of(p.user_id).await?; + let own_active = devices + .iter() + .any(|d| d.id == device_id && d.status == "active"); + let active = devices.iter().filter(|d| d.status == "active").count(); + if own_active && active == 1 && !self.inner.db.totp_enabled(p.user_id).await? { + match self.authenticate_with(&p.name, password, ip, false).await { + Ok(_) => {} + Err(rostdav_core::Error::Unauthenticated) => return Err(MfaError::Unauthenticated), + Err(rostdav_core::Error::RateLimited { retry_after_secs }) => { + return Err(MfaError::RateLimited { retry_after_secs }); + } + Err(e) => return Err(e.into()), + } + } + self.mfa_revoke_device(p.user_id, device_id, &p.name).await + } + /// A phone revokes a phone of its own account (itself or another one). pub async fn mfa_revoke_device_as( &self, diff --git a/crates/rostdav-app/src/push_mfa/enroll.rs b/crates/rostdav-app/src/push_mfa/enroll.rs index 42b2bb1..3de7a41 100644 --- a/crates/rostdav-app/src/push_mfa/enroll.rs +++ b/crates/rostdav-app/src/push_mfa/enroll.rs @@ -136,12 +136,13 @@ impl AppState { }) } - /// The web interface asks for the account password again before it shows an enrollment QR - /// code: a stolen session cookie alone cannot add a phone. + /// The web interface asks for the account password again (and a current code when TOTP is + /// on) before it shows an enrollment QR code: a stolen session cookie alone cannot add a phone. pub async fn web_mfa_enroll_start( &self, s: &crate::WebSession, password: &str, + code: &str, server_url: &str, ip: IpAddr, ) -> Result { @@ -149,8 +150,17 @@ impl AppState { if p.via != Via::AccountPassword || p.scope != Scope::Full { return Err(MfaError::Unauthenticated); } - match self.authenticate_with(&p.name, password, ip, false).await { - Ok(_) => {} + // adding a second factor is as sensitive as turning one off: with TOTP on, the password + // and a current code are asked for (a stolen session and a phished password are not enough) + let checked = if self.inner.db.totp_enabled(p.user_id).await? { + self.reauthenticate(p, password, code, ip).await + } else { + self.authenticate_with(&p.name, password, ip, false) + .await + .map(|_| ()) + }; + match checked { + Ok(()) => {} Err(rostdav_core::Error::Unauthenticated) => return Err(MfaError::Unauthenticated), Err(rostdav_core::Error::RateLimited { retry_after_secs }) => { return Err(MfaError::RateLimited { retry_after_secs }); diff --git a/crates/rostdav-app/src/push_mfa/enroll_qr_tests.rs b/crates/rostdav-app/src/push_mfa/enroll_qr_tests.rs index ae1b13f..d0891d4 100644 --- a/crates/rostdav-app/src/push_mfa/enroll_qr_tests.rs +++ b/crates/rostdav-app/src/push_mfa/enroll_qr_tests.rs @@ -26,7 +26,7 @@ async fn enrolling_needs_the_account_password() { principal: p, }; let r = st - .web_mfa_enroll_start(&s, "wrong-password", "http://h", IP) + .web_mfa_enroll_start(&s, "wrong-password", "", "http://h", IP) .await; assert!(matches!(r, Err(MfaError::Unauthenticated)), "{r:?}"); } @@ -40,7 +40,7 @@ async fn enrolling_gives_a_token_a_payload_and_a_qr_code() { principal: p, }; let q = st - .web_mfa_enroll_start(&s, "correct-horse-battery", "http://cloud.test", IP) + .web_mfa_enroll_start(&s, "correct-horse-battery", "", "http://cloud.test", IP) .await .expect("enroll"); assert_eq!(q.token.expires_in_seconds, 300); @@ -78,7 +78,9 @@ async fn a_wrong_password_creates_no_token() { id: 1, principal: p, }; - let _ = st.web_mfa_enroll_start(&s, "nope", "http://h", IP).await; + let _ = st + .web_mfa_enroll_start(&s, "nope", "", "http://h", IP) + .await; let events = st.inner.db.events_since(0).await.expect("events"); assert!( !events @@ -98,7 +100,7 @@ async fn an_app_password_session_cannot_enroll() { principal: p, }; let r = st - .web_mfa_enroll_start(&s, "correct-horse-battery", "http://h", IP) + .web_mfa_enroll_start(&s, "correct-horse-battery", "", "http://h", IP) .await; assert!(r.is_err(), "{r:?}"); } diff --git a/crates/rostdav-app/src/push_mfa/factor_tests.rs b/crates/rostdav-app/src/push_mfa/factor_tests.rs index 7e91429..2c9288b 100644 --- a/crates/rostdav-app/src/push_mfa/factor_tests.rs +++ b/crates/rostdav-app/src/push_mfa/factor_tests.rs @@ -82,3 +82,102 @@ async fn without_a_factor_webdav_takes_the_account_password() { add_user(&st, "ben").await; assert!(st.authenticate_with("ben", PW, IP, true).await.is_ok()); } + +// ---- recovery of a user whose only second factor is a phone ------------------------------------- + +use crate::Actor; + +#[tokio::test] +async fn an_admin_reset_revokes_the_phones_of_a_phone_only_user() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let c = st + .mfa_create_challenge(p.user_id, "dev_a", "ref_1", "Firefox", IP) + .await + .expect("challenge"); + assert!( + st.user_two_factor_enabled(&Actor::Cli, "anna") + .await + .expect("q"), + "the admin page must see the phone as a second factor" + ); + let had = st + .user_reset_two_factor(&Actor::Cli, "anna") + .await + .expect("reset"); + assert!(had, "the reset reports that something was removed"); + assert!(!st.has_second_factor(p.user_id).await.expect("q")); + assert_eq!( + st.mfa_challenge_status(&c.challenge_id).await.expect("s"), + crate::ChallengeStatus::Expired, + "open approvals end with the phone" + ); + let r = st.web_sign_in("anna", PW, IP, "ua").await.expect("sign in"); + assert!(matches!(r, SignIn::Done { .. }), "{r:?}"); + let events = st.inner.db.events_since(0).await.expect("events"); + assert!(events.iter().any(|e| e.action == "mfa.device_revoked")); +} + +#[tokio::test] +async fn an_admin_reset_for_a_user_without_any_factor_reports_nothing_removed() { + let (_d, st) = state().await; + add_user(&st, "ben").await; + assert!( + !st.user_reset_two_factor(&Actor::Cli, "ben") + .await + .expect("reset") + ); +} + +// ---- revoking the last factor from the web needs the password ----------------------------------- + +use super::MfaError; +use super::testkit::{enroll_existing, principal}; +use crate::WebSession; + +async fn session(st: &crate::AppState, name: &str) -> WebSession { + WebSession { + id: 1, + principal: principal(st, name, false).await, + } +} + +#[tokio::test] +async fn revoking_the_phone_that_is_the_only_factor_needs_the_password() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let s = session(&st, "anna").await; + for pw in ["", "wrong-password"] { + let r = st.web_mfa_revoke(&s, "dev_a", pw, IP).await; + assert!(matches!(r, Err(MfaError::Unauthenticated)), "{pw:?}: {r:?}"); + assert!( + st.has_second_factor(p.user_id).await.expect("q"), + "still active" + ); + } + st.web_mfa_revoke(&s, "dev_a", PW, IP) + .await + .expect("revoke"); + assert!(!st.has_second_factor(p.user_id).await.expect("q")); +} + +#[tokio::test] +async fn revoking_one_of_two_phones_needs_no_password() { + let (_d, st) = state().await; + enrolled(&st, "anna", "dev_a").await; + enroll_existing(&st, "anna", "dev_b").await; + let s = session(&st, "anna").await; + st.web_mfa_revoke(&s, "dev_a", "", IP) + .await + .expect("revoke"); +} + +#[tokio::test] +async fn revoking_another_users_phone_is_not_found_whatever_the_password() { + let (_d, st) = state().await; + enrolled(&st, "anna", "dev_a").await; + enrolled(&st, "ben", "dev_b").await; + let s = session(&st, "anna").await; + let r = st.web_mfa_revoke(&s, "dev_b", PW, IP).await; + assert!(matches!(r, Err(MfaError::NotFound)), "{r:?}"); +} diff --git a/crates/rostdav-app/src/push_mfa/login.rs b/crates/rostdav-app/src/push_mfa/login.rs index 286b978..b08f113 100644 --- a/crates/rostdav-app/src/push_mfa/login.rs +++ b/crates/rostdav-app/src/push_mfa/login.rs @@ -91,6 +91,23 @@ impl AppState { ip: IpAddr, ) -> Result { let user_id = self.login_user(ticket, flow)?; + let login_ref = self.login_ref_for(ticket, flow); + // a reload of the number page or a double click must not start a second approval (a + // second prompt on the phone, another of the five per 15 minutes): the open one is shown + // again until it is answered or ends + let now = now_secs(); + if let Some(open) = self + .inner + .db + .mfa_challenge_open_for_login(user_id, &login_ref, now) + .await? + { + return Ok(LoginChallenge { + challenge_id: open.id, + number: open.number, + expires_in_seconds: open.expires_at - now, + }); + } let devices = self.inner.db.mfa_devices_of(user_id).await?; let device = devices .iter() @@ -98,13 +115,7 @@ impl AppState { .max_by_key(|d| d.last_seen_at.unwrap_or(d.created_at)) .ok_or(MfaError::NotFound)?; let c = self - .mfa_create_challenge( - user_id, - &device.id, - &self.login_ref_for(ticket, flow), - login_device, - ip, - ) + .mfa_create_challenge(user_id, &device.id, &login_ref, login_device, ip) .await?; Ok(LoginChallenge { challenge_id: c.challenge_id, diff --git a/crates/rostdav-app/src/push_mfa/login_tests.rs b/crates/rostdav-app/src/push_mfa/login_tests.rs index ac741ad..e3c893e 100644 --- a/crates/rostdav-app/src/push_mfa/login_tests.rs +++ b/crates/rostdav-app/src/push_mfa/login_tests.rs @@ -343,3 +343,57 @@ async fn web_phone_finish_before_approval_opens_no_session() { let r = st.web_phone_finish(&t, &id, IP, "ua").await; assert!(matches!(r, Err(MfaError::InvalidRequest(_))), "{r:?}"); } + +#[tokio::test] +async fn starting_twice_for_one_sign_in_reuses_the_open_challenge() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let t = ticket(&st, p.user_id, ""); + let first = st.mfa_login_start(&t, "", "Firefox", IP).await.expect("1"); + // a reload of the number page, or a double click + let second = st.mfa_login_start(&t, "", "Firefox", IP).await.expect("2"); + assert_eq!(first.challenge_id, second.challenge_id); + assert_eq!(first.number, second.number); + assert!(second.expires_in_seconds <= first.expires_in_seconds); + assert_eq!( + super::testkit::pending_challenge_ids(&st, "dev_a") + .await + .len(), + 1, + "one prompt on the phone" + ); +} + +#[tokio::test] +async fn a_finished_challenge_is_not_reused() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let t = ticket(&st, p.user_id, ""); + let first = st.mfa_login_start(&t, "", "Firefox", IP).await.expect("1"); + assert!( + st.inner + .db + .mfa_challenge_resolve(&first.challenge_id, "denied", now_secs()) + .await + .expect("resolve") + ); + let second = st.mfa_login_start(&t, "", "Firefox", IP).await.expect("2"); + assert_ne!(first.challenge_id, second.challenge_id); +} + +#[tokio::test] +async fn another_sign_in_of_the_same_user_gets_its_own_challenge() { + let (_d, st) = state().await; + let p = enrolled(&st, "anna", "dev_a").await; + let web = ticket(&st, p.user_id, ""); + let flow = ticket(&st, p.user_id, "flowtoken"); + let a = st + .mfa_login_start(&web, "", "Firefox", IP) + .await + .expect("web"); + let b = st + .mfa_login_start(&flow, "flowtoken", "Android", IP) + .await + .expect("flow"); + assert_ne!(a.challenge_id, b.challenge_id); +} diff --git a/crates/rostdav-app/src/two_factor.rs b/crates/rostdav-app/src/two_factor.rs index 969a3fe..76d9a12 100644 --- a/crates/rostdav-app/src/two_factor.rs +++ b/crates/rostdav-app/src/two_factor.rs @@ -329,7 +329,7 @@ impl AppState { Ok(codes) } - async fn reauthenticate( + pub(crate) async fn reauthenticate( &self, p: &Principal, password: &str, @@ -353,7 +353,8 @@ impl AppState { } } - /// Administrator reset for a locked-out user: removes secret and codes, ends browser sessions. + /// Administrator reset for a locked-out user: removes secret and codes, revokes their RustMFA + /// phones, ends browser sessions. pub async fn user_reset_two_factor( &self, actor: &crate::admin::Actor<'_>, @@ -361,14 +362,30 @@ impl AppState { ) -> Result { actor.require_admin()?; let u = self.target_user(name).await?; - let was = self.inner.db.totp_delete(u.id).await?; + let totp = self.inner.db.totp_delete(u.id).await?; + // a lost phone is a lost second factor too: revoke every phone (this also ends their + // open sign-in approvals), or the user could never sign in again + let mut phones = 0u32; + for d in self.inner.db.mfa_devices_of(u.id).await? { + if d.status == "active" { + // a phone revoked in the meantime is not an error here + if self + .mfa_revoke_device(u.id, &d.id, &actor.name()) + .await + .is_ok() + { + phones += 1; + } + } + } self.inner.db.delete_user_sessions(u.id).await?; + let was = totp || phones > 0; self.audit( &actor.name(), "admin.2fa_reset", &u.name, None, - json!({"was_on": was}), + json!({"was_on": was, "totp": totp, "phones_revoked": phones}), ) .await; Ok(was) @@ -413,7 +430,7 @@ impl AppState { ) -> Result { actor.require_admin()?; let u = self.target_user(name).await?; - self.inner.db.totp_enabled(u.id).await + self.has_second_factor(u.id).await } /// Second step of a browser sign-in: the ticket and a code (or recovery code). @@ -793,6 +810,29 @@ mod tests { (s, setup, codes) } + #[tokio::test] + async fn enrolling_a_phone_needs_a_current_code_when_totp_is_on() { + let (_d, st) = state(TotpKeySource::File(PathBuf::new())).await; + let (s, setup, _codes) = enabled(&st, "anna").await; + let ip = ip(); + for code in ["", "000000"] { + let r = st.web_mfa_enroll_start(&s, PW, code, "http://h", ip).await; + assert!( + matches!(r, Err(crate::MfaError::Unauthenticated)), + "code {code:?}: {r:?}" + ); + } + // the password alone is not enough, and a wrong password with a right code is not either + let r = st + .web_mfa_enroll_start(&s, "nope", ¤t_code(&setup, 1), "http://h", ip) + .await; + assert!(matches!(r, Err(crate::MfaError::Unauthenticated)), "{r:?}"); + let ok = st + .web_mfa_enroll_start(&s, PW, ¤t_code(&setup, 1), "http://h", ip) + .await; + assert!(ok.is_ok(), "{ok:?}"); + } + #[tokio::test] async fn without_a_key_two_factor_is_unavailable() { let (_d, st) = state(TotpKeySource::None).await; diff --git a/crates/rostdav-meta/src/push_mfa.rs b/crates/rostdav-meta/src/push_mfa.rs index af1030e..53ecc99 100644 --- a/crates/rostdav-meta/src/push_mfa.rs +++ b/crates/rostdav-meta/src/push_mfa.rs @@ -413,6 +413,29 @@ impl Db { Ok(r.rows_affected() > 0) } + /// The newest challenge of one sign-in that can still be answered: pending, not expired, and + /// its phone still active. + pub async fn mfa_challenge_open_for_login( + &self, + user_id: i64, + login_ref: &str, + now: i64, + ) -> Result> { + let row: Option = sql::query_as(format!( + "SELECT {CHALLENGE_COLUMNS} FROM mfa_challenges + WHERE user_id = ? AND login_ref = ? AND status = 'pending' AND expires_at > ? + AND device_id IN (SELECT id FROM mfa_devices WHERE status = 'active') + ORDER BY created_at DESC, id LIMIT 1" + )) + .bind(user_id) + .bind(login_ref) + .bind(now) + .fetch_optional(self.pool()) + .await + .map_err(err)?; + Ok(row.map(ChallengeRow::from)) + } + /// Hands an approved challenge to the sign-in that started it, once. True for exactly one /// caller, and only while the approving phone is still active (a phone revoked after it /// approved releases nothing). diff --git a/crates/rostdav-web/i18n/de.tsv b/crates/rostdav-web/i18n/de.tsv index d3c9e63..a3e2589 100644 --- a/crates/rostdav-web/i18n/de.tsv +++ b/crates/rostdav-web/i18n/de.tsv @@ -500,3 +500,5 @@ Address (phone enrollment) Adresse (Handy-Einrichtung) User (phone enrollment) Benutzer (Handy-Einrichtung) User (sign-in approvals) Benutzer (Anmelde-Bestätigungen) Phone (signed requests) Handy (signierte Anfragen) +Revoke your only second factor? Deinen einzigen zweiten Faktor widerrufen? +This phone is your only second factor. Without it, signing in needs only the password, and WebDAV accepts it again. Enter your password to confirm. Dieses Handy ist dein einziger zweiter Faktor. Ohne es genügt für die Anmeldung das Passwort, und WebDAV akzeptiert es wieder. Gib zur Bestätigung dein Passwort ein. diff --git a/crates/rostdav-web/src/phones.rs b/crates/rostdav-web/src/phones.rs index 9995530..8da4301 100644 --- a/crates/rostdav-web/src/phones.rs +++ b/crates/rostdav-web/src/phones.rs @@ -146,15 +146,26 @@ pub async fn phones_card(state: &AppState, a: &Authed) -> String { // ---- enrollment --------------------------------------------------------------------------------- -fn enroll_form(a: &Authed, error: Option<&str>, status: StatusCode) -> Response { +fn enroll_form(a: &Authed, totp: bool, error: Option<&str>, status: StatusCode) -> Response { let lang = a.lang; let err = error .map(|e| format!("
    {}
    ", esc(e))) .unwrap_or_default(); + let code = if totp { + format!( + "", + esc(&t( + lang, + "Code from your authenticator app, or a recovery code" + )) + ) + } else { + String::new() + }; let form = format!( "{err}
    \ \ - \ + {code}\
    {}
    ", esc(&a.csrf), esc(&t(lang, "Password")), @@ -174,14 +185,25 @@ fn enroll_form(a: &Authed, error: Option<&str>, status: StatusCode) -> Response no_store(ui::page(a, status, "Add a phone", "devices", &body)) } -pub async fn enroll_page(a: Authed) -> Response { - enroll_form(&a, None, StatusCode::OK) +/// Whether the account has TOTP on (then enrolling asks for a code as well). +async fn totp_on(state: &AppState, a: &Authed) -> bool { + state + .two_factor_status(&a.session.principal) + .await + .is_ok_and(|s| s.enabled) +} + +pub async fn enroll_page(State(state): State, a: Authed) -> Response { + let totp = totp_on(&state, &a).await; + enroll_form(&a, totp, None, StatusCode::OK) } #[derive(Deserialize)] pub struct EnrollForm { csrf: String, password: String, + #[serde(default)] + code: String, } /// The address the phone should talk to: `server.public_url`, else the `Host` of this request. @@ -217,7 +239,7 @@ pub async fn enroll_submit( ); }; match state - .web_mfa_enroll_start(&a.session, &f.password, &server, client_ip(&parts)) + .web_mfa_enroll_start(&a.session, &f.password, &f.code, &server, client_ip(&parts)) .await { Ok(q) => { @@ -256,14 +278,19 @@ pub async fn enroll_submit( &body, )) } - Err(MfaError::Unauthenticated) => enroll_form( - &a, - Some(&t(lang, "Wrong password.")), - StatusCode::UNAUTHORIZED, - ), + Err(MfaError::Unauthenticated) => { + let totp = totp_on(&state, &a).await; + let text = if totp { + "The password or the code is wrong or has been used." + } else { + "Wrong password." + }; + enroll_form(&a, totp, Some(&t(lang, text)), StatusCode::UNAUTHORIZED) + } Err(MfaError::RateLimited { retry_after_secs }) => { let mut r = enroll_form( &a, + totp_on(&state, &a).await, Some(&tf( lang, "Too many attempts. Try again in {0} minutes.", @@ -287,22 +314,69 @@ pub async fn enroll_submit( pub struct RevokeForm { csrf: String, device_id: String, + #[serde(default)] + password: String, +} + +/// Revoking the only second factor asks for the password first. +fn revoke_confirm( + a: &Authed, + device_id: &str, + error: Option<&str>, + status: StatusCode, +) -> Response { + let lang = a.lang; + let err = error + .map(|e| format!("
    {}
    ", esc(e))) + .unwrap_or_default(); + let form = format!( + "{err}
    \ + \ + \ +
    {}
    ", + esc(&a.csrf), + esc(device_id), + esc(&t(lang, "Password")), + esc(&t(lang, "Revoke")), + esc(&t(lang, "Cancel")), + ); + let body = format!( + "{}{}", + ui::page_head( + lang, + "Revoke your only second factor?", + "This phone is your only second factor. Without it, signing in needs only the password, and WebDAV accepts it again. Enter your password to confirm.", + "" + ), + ui::card(lang, "Confirm with your password", "", &form) + ); + no_store(ui::page(a, status, "Devices", "devices", &body)) } pub async fn revoke( State(state): State, a: Authed, + parts: Parts, Form(f): Form, ) -> Response { if !csrf_ok(&state, &a, &f.csrf) { return StatusCode::FORBIDDEN.into_response(); } - let p = &a.session.principal; + let lang = a.lang; match state - .mfa_revoke_device(p.user_id, &f.device_id, &p.name) + .web_mfa_revoke(&a.session, &f.device_id, &f.password, client_ip(&parts)) .await { Ok(()) => Redirect::to("/devices").into_response(), + Err(MfaError::Unauthenticated) if f.password.is_empty() => { + revoke_confirm(&a, &f.device_id, None, StatusCode::OK) + } + Err(MfaError::Unauthenticated) => revoke_confirm( + &a, + &f.device_id, + Some(&t(lang, "Wrong password.")), + StatusCode::UNAUTHORIZED, + ), Err(MfaError::NotFound) => error_response_to(&a, &Error::NotFound, "/devices", "devices"), Err(MfaError::Internal(e)) => error_response_to(&a, &e, "/devices", "devices"), Err(_) => error_response_to(&a, &Error::Unavailable, "/devices", "devices"), diff --git a/crates/rostdav/tests/web.rs b/crates/rostdav/tests/web.rs index 49f55f7..781d7f5 100644 --- a/crates/rostdav/tests/web.rs +++ b/crates/rostdav/tests/web.rs @@ -6143,7 +6143,7 @@ async fn revoking_a_phone_ends_its_open_challenges() { &e, "/devices/phones/revoke", &c, - &[("csrf", &csrf), ("device_id", "dev_a")], + &[("csrf", &csrf), ("device_id", "dev_a"), ("password", PW)], ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.text()); @@ -6241,3 +6241,122 @@ async fn admin_sees_and_lifts_phone_blocks() { let b = login(&e, "bob").await; assert_ne!(get(&e, "/admin/security", &b).await.status, StatusCode::OK); } + +#[tokio::test] +async fn enrolling_with_two_factor_on_needs_a_current_code() { + let e = env().await; + let (c, secret, _codes) = enable_2fa(&e, "anna").await; + let csrf = page_csrf(&e, &c).await; + assert!( + get(&e, "/devices/phones/enroll", &c) + .await + .text() + .contains("name=\"code\"") + ); + let r = enroll_post(&e, &c, &csrf, PW).await; + assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text()); + assert!(!r.text().contains("rustmfa://")); + let r = post_form( + &e, + "/devices/phones/enroll", + &c, + &[ + ("csrf", &csrf), + ("password", PW), + ("code", &totp_now(&secret, 1)), + ], + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.text()); + assert!(r.text().contains("rustmfa://enroll")); + // without TOTP the page has no code field + let b = login(&e, "bob").await; + assert!( + !get(&e, "/devices/phones/enroll", &b) + .await + .text() + .contains("name=\"code\"") + ); +} + +#[tokio::test] +async fn revoking_the_only_factor_asks_for_the_password() { + let e = env().await; + let c = login(&e, "anna").await; + phone_user(&e, "anna", "dev_a").await; + let csrf = page_csrf(&e, &c).await; + let r = post_form( + &e, + "/devices/phones/revoke", + &c, + &[("csrf", &csrf), ("device_id", "dev_a")], + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.text()); + let html = r.text(); + assert!( + html.contains("name=\"password\"") && html.contains("value=\"dev_a\""), + "{html}" + ); + assert!( + get(&e, "/devices", &c) + .await + .text() + .contains("value=\"dev_a\""), + "still active" + ); + let r = post_form( + &e, + "/devices/phones/revoke", + &c, + &[ + ("csrf", &csrf), + ("device_id", "dev_a"), + ("password", "wrong-password"), + ], + ) + .await; + assert_eq!(r.status, StatusCode::UNAUTHORIZED); + let r = post_form( + &e, + "/devices/phones/revoke", + &c, + &[("csrf", &csrf), ("device_id", "dev_a"), ("password", PW)], + ) + .await; + assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.text()); + assert!(get(&e, "/devices", &c).await.text().contains("Revoked")); +} + +#[tokio::test] +async fn reloading_the_flow_number_page_does_not_start_a_second_approval() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = flow_second_step(&e, "bob").await; + let first = challenge_id_of(&flow_send_to_phone(&e, &s).await.text()); + // F5 on the result of the POST sends the same form again + let again = challenge_id_of(&flow_send_to_phone(&e, &s).await.text()); + assert_eq!(first, again); + assert_eq!( + testkit::pending_challenge_ids(&e.state, "dev_b") + .await + .len(), + 1 + ); +} + +#[tokio::test] +async fn pressing_send_twice_in_the_browser_does_not_start_a_second_approval() { + let e = env().await; + phone_user(&e, "bob", "dev_b").await; + let s = second_step(&e, "bob").await; + let first = challenge_id_of(&send_to_phone(&e, &s).await.text()); + let again = challenge_id_of(&send_to_phone(&e, &s).await.text()); + assert_eq!(first, again); + assert_eq!( + testkit::pending_challenge_ids(&e.state, "dev_b") + .await + .len(), + 1 + ); +} diff --git a/docs/admin.md b/docs/admin.md index 05d4589..b0d87f7 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -34,7 +34,7 @@ The CLI works on the data directory directly, next to a running server. It is tr | `rostdav admin import --user [--dest ] [--overwrite]` | Copy a host directory into a user's files | | `rostdav admin backup [--include-key]` | Consistent copy of database and blobs while the server runs (hard links on the same file system), see [deployment.md](deployment.md) | | `rostdav admin audit export [--since ]` | Event log as JSON Lines | -| `rostdav admin user reset-2fa ` | Remove the two-factor secret and recovery codes of a locked-out user and end their browser sessions (app passwords stay) | +| `rostdav admin user reset-2fa ` | Remove the two-factor secret and recovery codes of a locked-out user, revoke their RustMFA phones and end their browser sessions (app passwords stay) | Rules enforced by the service (CLI and later the web UI alike): an administrator cannot disable or delete themselves; the last enabled administrator cannot be disabled, demoted or deleted; a user can only be deleted with `--yes`; data can only be transferred to an existing, enabled, different user. @@ -126,12 +126,12 @@ Users turn on TOTP (authenticator app) themselves under Devices & security; see - **Key file.** The secrets are encrypted with a key that is *not* in the database: `totp.key` in the data directory (32 random bytes, mode 0600, created when the first user starts a setup). Back it up with the data, but keep it apart from copies of the database if you want a stolen database dump to be useless. Without the key, existing secrets cannot be read: reset 2FA for the affected users. Alternatives: `[security] totp_key_file = "/path"` or `ROSTDAV_SECURITY__TOTP_KEY` (base64 of 32 bytes). - **WebDAV and OCS.** While 2FA is on for a user, or while they have an enrolled RustMFA phone, their *account password* no longer works there (401); app passwords do. Tell users who still use the account password in a WebDAV client to create an app password first. -- **Lockout.** Five wrong codes lock the second step for that user for 15 minutes. A user who lost the authenticator and the recovery codes needs `rostdav admin user reset-2fa ` or Administration › Users › Manage › Reset two-factor authentication. +- **Lockout.** Five wrong codes lock the second step for that user for 15 minutes. A user who lost the authenticator and the recovery codes, or whose only second factor was a lost phone, needs `rostdav admin user reset-2fa ` (it also revokes their phones) or Administration › Users › Manage › Reset two-factor authentication. - **Event log:** `auth.2fa_enabled`, `auth.2fa_disabled`, `auth.2fa_codes_regenerated`, `admin.2fa_reset`, failed codes as `auth.login_failed` with `second_factor`. Codes and secrets are never logged. ## Push sign-in approval (RustMFA) -Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa.md)). The server side of the phone API is implemented (`/api/v1/devices/…`, `/api/v1/mfa/challenges/…`). The browser sign-in and the Login Flow v2 page (connecting a Nextcloud app) have the phone step. Users add and revoke phones under Devices & security (the account password is asked again before the QR code is shown). Push delivery (FCM) is not built yet: a phone only sees a request when its app polls. The QR code carries the server address, taken from `server.public_url`; set it when Rostdav runs behind a TLS proxy, or the code says `http://`. +Settings for approving sign-ins in the RustMFA app ([ADR 0014](adr/0014-push-mfa.md)). The server side of the phone API is implemented (`/api/v1/devices/…`, `/api/v1/mfa/challenges/…`). The browser sign-in and the Login Flow v2 page (connecting a Nextcloud app) have the phone step. Users add and revoke phones under Devices & security. The account password is asked again before the QR code is shown, and a current authenticator code as well when two-factor codes are on. Revoking the phone that is the only second factor asks for the password too, because it takes the second step away. Without push delivery (FCM, below) a phone only sees a request when its app polls. The QR code carries the server address, taken from `server.public_url`; set it when Rostdav runs behind a TLS proxy, or the code says `http://`. | Key | Default | Meaning | |---|---|---| diff --git a/docs/adr/0014-push-mfa.md b/docs/adr/0014-push-mfa.md index 7b00a54..0c9bf61 100644 --- a/docs/adr/0014-push-mfa.md +++ b/docs/adr/0014-push-mfa.md @@ -48,7 +48,7 @@ Migration `0013_push_mfa.sql` with four `STRICT` tables, a plain index on every - `PushChannel` trait in `rostdav-app`; delivery adapters follow in the second step. **Implementation notes 2026-11-09:** `rostdav-mfa-api` exposes `router() -> Router` (merged into the server's router like the other protocol crates; the paths are complete so the signed path is the received one). It reads at most 64 KiB (413 `INVALID_REQUEST` beyond that; the contract has no 413), refuses unknown JSON fields, and sets `cache-control: no-store`. It has no dependency on `rostdav-meta`. Its integration tests use a `test-util` feature of `rostdav-app` (the `testkit` module: synthetic attestation chains, a fresh state); production builds do not enable it. The tests check responses against a JSON mirror of the OpenAPI file kept in the `rostdav-mfa` repository (a contract test keeps the mirror equal to the YAML), so Rostdav needs no YAML crate. Listing challenges supports `status=pending` only. - **Implementation notes 2026-11-10:** `AppState::has_second_factor` (TOTP on or at least one active phone) replaces the TOTP-only check at the three sign-in decisions (browser sign-in, Login Flow v2, WebDAV/OCS password). The TOTP management pages keep asking about TOTP alone. A challenge started for a sign-in carries a `login_ref`, a keyed hash of the sign-in's ticket and flow. Only the holder of that ticket can poll it (`mfa_login_status`) or turn it into a session (`mfa_login_release`). The release is one conditional update on `released_at` and needs the approving phone to be still active, so a phone revoked after it approved releases nothing. The web interface asks for the account password before it shows an enrollment QR code (`web_mfa_enroll_start`), so a stolen session cookie alone cannot add a phone. The QR payload is `rustmfa://enroll?server=&token=` (written into the design spec, section 4.1). The FCM adapter (`push_mfa/fcm.rs`) uses the HTTP v1 API: a JWT (RS256, signed with `ring`) from the service-account file is exchanged for an access token that is kept until a minute before it expires; the call runs in `spawn_blocking` with `ureq` and a five-second timeout. Error texts carry the HTTP status and Google's status words (`UNREGISTERED` and the like), never free text, which can echo the push token. + **Implementation notes 2026-11-10:** `AppState::has_second_factor` (TOTP on or at least one active phone) replaces the TOTP-only check at the three sign-in decisions (browser sign-in, Login Flow v2, WebDAV/OCS password). The TOTP management pages keep asking about TOTP alone. A challenge started for a sign-in carries a `login_ref`, a keyed hash of the sign-in's ticket and flow. Only the holder of that ticket can poll it (`mfa_login_status`) or turn it into a session (`mfa_login_release`). The release is one conditional update on `released_at` and needs the approving phone to be still active, so a phone revoked after it approved releases nothing. The web interface asks for the account password before it shows an enrollment QR code (`web_mfa_enroll_start`), so a stolen session cookie alone cannot add a phone; with TOTP on a current code is asked for as well. Revoking the phone that is the account's only second factor needs the password (`web_mfa_revoke`). The administrator reset revokes all phones. A sign-in's open challenge is reused by a second start (`mfa_challenge_open_for_login`), so a reload creates no second prompt. The QR payload is `rustmfa://enroll?server=&token=` (written into the design spec, section 4.1). The FCM adapter (`push_mfa/fcm.rs`) uses the HTTP v1 API: a JWT (RS256, signed with `ring`) from the service-account file is exchanged for an access token that is kept until a minute before it expires; the call runs in `spawn_blocking` with `ureq` and a five-second timeout. Error texts carry the HTTP status and Google's status words (`UNREGISTERED` and the like), never free text, which can echo the push token. ### Dependencies (justification)