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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e4c1f..5eedbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ 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. + +### 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. +- **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. +- **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. +- **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 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 31769d3..71ac1a1 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`). 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/Cargo.lock b/Cargo.lock index 3ea94cc..0176379 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" @@ -758,6 +764,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" @@ -858,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" @@ -976,6 +1000,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" @@ -992,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", ] @@ -1003,7 +1052,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", ] @@ -1025,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" @@ -1155,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" @@ -1177,6 +1270,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" @@ -1314,6 +1413,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1373,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" @@ -1419,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" @@ -2356,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" @@ -2437,7 +2569,7 @@ dependencies = [ "regex", "serde", "serde_json", - "sha2", + "sha2 0.11.1", "smallvec", "stringprep", "subsetter", @@ -2474,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" @@ -2529,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" @@ -2644,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" @@ -2906,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" @@ -2940,7 +3110,7 @@ dependencies = [ "clap", "figment", "futures-core", - "hmac", + "hmac 0.13.0", "http-body-util", "httpdate", "percent-encoding", @@ -2950,6 +3120,7 @@ dependencies = [ "rostdav-core", "rostdav-dav", "rostdav-meta", + "rostdav-mfa-api", "rostdav-storage", "rostdav-web", "serde", @@ -2972,22 +3143,27 @@ 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", "tempfile", "tokio", "tracing", + "tracing-subscriber", "ureq", + "x509-cert", "zip", ] @@ -3046,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" @@ -3177,6 +3371,18 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustmfa-contract" +version = "0.1.0" +source = "git+https://github.com/pierrejochem/rostdav-mfa?rev=31e5218c2ad473257ee048691b9017637cbe20c1#31e5218c2ad473257ee048691b9017637cbe20c1" +dependencies = [ + "base64 0.22.1", + "p256", + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3201,6 +3407,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" @@ -3299,6 +3519,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" @@ -3361,6 +3592,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" @@ -3450,6 +3691,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" @@ -3731,7 +3982,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", @@ -4796,6 +5047,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" @@ -4950,18 +5212,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", ] diff --git a/Cargo.toml b/Cargo.toml index 5845333..99baa1a 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"] } @@ -58,6 +60,12 @@ argon2 = { version = "0.5", features = ["std"] } ulid = "3" 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 = "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"] } 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..9f74196 100644 --- a/crates/rostdav-app/Cargo.toml +++ b/crates/rostdav-app/Cargo.toml @@ -8,6 +8,10 @@ rust-version.workspace = true publish.workspace = true [dependencies] +rustmfa-contract.workspace = true +x509-cert.workspace = true +der.workspace = true +ring.workspace = true rostdav-core.workspace = true rostdav-meta.workspace = true rostdav-storage.workspace = true @@ -28,8 +32,15 @@ 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 tempfile.workspace = true serde_json.workspace = true 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/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/lib.rs b/crates/rostdav-app/src/lib.rs index ca4ed21..48557bb 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,15 @@ 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, 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::{ LINK_TOKEN_LEN, ShareChange, ShareCreate, ShareError, ShareFilter, ShareView, Viewer, @@ -90,6 +100,13 @@ 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>>>, + /// 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`). @@ -124,6 +141,7 @@ struct AppConfig { extractor: Option, limits: Limits, audit: AuditOptions, + mfa: MfaConfig, versioning: VersioningOptions, trash: TrashOptions, web: WebOptions, @@ -215,6 +233,12 @@ 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), + mfa_push: std::sync::RwLock::new(Arc::new(push_mfa::NoopPush)), instance_id, server_key, maintenance: maintenance.into(), @@ -252,6 +276,16 @@ 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), + 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 @@ -363,6 +397,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/login_flow.rs b/crates/rostdav-app/src/login_flow.rs index 4e6aa7a..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; @@ -115,7 +116,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), }); @@ -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-app/src/push_mfa/attestation.rs b/crates/rostdav-app/src/push_mfa/attestation.rs new file mode 100644 index 0000000..50f8216 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/attestation.rs @@ -0,0 +1,573 @@ +//! 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 crate::push_mfa::testkit::*; + use base64::{Engine, engine::general_purpose::STANDARD}; + + 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/challenges.rs b/crates/rostdav-app/src/push_mfa/challenges.rs new file mode 100644 index 0000000..ebda167 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/challenges.rs @@ -0,0 +1,527 @@ +//! 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 { + /// The word the contract uses (`pending`, `approved`, `denied`, `expired`). + pub 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?)?; + // 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(()) + } + + /// `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), + } + } + + /// 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/challenges_tests.rs b/crates/rostdav-app/src/push_mfa/challenges_tests.rs new file mode 100644 index 0000000..bf104ca --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/challenges_tests.rs @@ -0,0 +1,1002 @@ +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; + // `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] +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"); +} + +#[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/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..6ec8a07 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/devauth_tests.rs @@ -0,0 +1,421 @@ +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ring::rand::SystemRandom; +use ring::signature::EcdsaKeyPair; + +use super::devauth::parse_authorization; +use crate::AppState; +use crate::push_mfa::MfaError; +use crate::push_mfa::testkit::{NOW, Phone, captured, enrolled, fixed_key, spki_b64, state}; + +const PATH: &str = "/mfa/v1/challenges?status=pending"; + +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)); +} + +#[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/devices.rs b/crates/rostdav-app/src/push_mfa/devices.rs new file mode 100644 index 0000000..8a6f49a --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/devices.rs @@ -0,0 +1,316 @@ +//! 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(()) + } + + /// 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, + 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 new file mode 100644 index 0000000..3de7a41 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/enroll.rs @@ -0,0 +1,282 @@ +//! 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) +} + +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 { + 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")); + } + 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")); + } + 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. + 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, + }) + } + + /// 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 { + let p = &s.principal; + if p.via != Via::AccountPassword || p.scope != Scope::Full { + return Err(MfaError::Unauthenticated); + } + // 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 }); + } + 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. + 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. + pub(super) 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(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/enroll_qr_tests.rs b/crates/rostdav-app/src/push_mfa/enroll_qr_tests.rs new file mode 100644 index 0000000..d0891d4 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/enroll_qr_tests.rs @@ -0,0 +1,106 @@ +//! 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 { + 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/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/factor_tests.rs b/crates/rostdav-app/src/push_mfa/factor_tests.rs new file mode 100644 index 0000000..2c9288b --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/factor_tests.rs @@ -0,0 +1,183 @@ +//! 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()); +} + +// ---- 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/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/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/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/login.rs b/crates/rostdav-app/src/push_mfa/login.rs new file mode 100644 index 0000000..b08f113 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/login.rs @@ -0,0 +1,201 @@ +//! 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, Principal, Scope, Via, 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, +} + +/// 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) + .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 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() + .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, &login_ref, 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..e3c893e --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/login_tests.rs @@ -0,0 +1,399 @@ +//! 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"); +} + +#[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:?}"); +} + +#[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/push_mfa/mod.rs b/crates/rostdav-app/src/push_mfa/mod.rs new file mode 100644 index 0000000..e57ea01 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/mod.rs @@ -0,0 +1,141 @@ +//! 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; +mod devices; +#[cfg(test)] +mod devices_tests; +mod enroll; +#[cfg(test)] +mod enroll_qr_tests; +#[cfg(test)] +mod enroll_tests; +mod error; +#[cfg(test)] +mod factor_tests; +mod fcm; +#[cfg(test)] +mod fcm_tests; +mod google_roots; +#[cfg(test)] +mod lockout_tests; +mod login; +#[cfg(test)] +mod login_tests; +mod push; +#[cfg(any(test, feature = "test-util"))] +pub 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::{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}; + +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 + } + + /// 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)] +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}; + 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-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/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-app/src/push_mfa/testkit.rs b/crates/rostdav-app/src/push_mfa/testkit.rs new file mode 100644 index 0000000..4981aa2 --- /dev/null +++ b/crates/rostdav-app/src/push_mfa/testkit.rs @@ -0,0 +1,383 @@ +//! 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}; +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 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 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 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 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 fn spki_b64(kp: &EcdsaKeyPair) -> String { + STANDARD.encode(spki(kp).to_der().expect("der")) +} + +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 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 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 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 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 base: i64, + pub 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 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 approval_kp: EcdsaKeyPair, +} + +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( + "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 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 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, + } +} + +/// A key whose signatures are raw R||S, as the phone's are. +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 struct Phone { + pub id: String, + pub request: EcdsaKeyPair, + pub approval: EcdsaKeyPair, + pub user_id: i64, +} + +pub async fn enrolled(st: &AppState, user: &str, id: &str) -> Phone { + 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, + 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, + } +} + +/// 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>>); + +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 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 + }) +} + +/// Attestation options whose certificates are valid now: the service reads the real clock. +pub fn live_opts() -> Opts { + let now = crate::now_secs(); + Opts { + base: now, + leaf_validity: (now - 1000, now + 1000), + ..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() +} + +/// The id of an existing user. +pub async fn user_id(st: &AppState, name: &str) -> i64 { + st.inner + .db + .find_user(name) + .await + .expect("query") + .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-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/crates/rostdav-app/src/two_factor.rs b/crates/rostdav-app/src/two_factor.rs index efeffe0..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). @@ -588,7 +605,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 +617,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::() @@ -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-app/src/web.rs b/crates/rostdav-app/src/web.rs index 7d8b4f3..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( @@ -76,7 +85,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/migrations/0013_push_mfa.sql b/crates/rostdav-meta/migrations/0013_push_mfa.sql new file mode 100644 index 0000000..326ac9c --- /dev/null +++ b/crates/rostdav-meta/migrations/0013_push_mfa.sql @@ -0,0 +1,68 @@ +-- 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; +-- `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, + 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, + 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); +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); + +-- 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/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..53ecc99 --- /dev/null +++ b/crates/rostdav-meta/src/push_mfa.rs @@ -0,0 +1,1014 @@ +//! 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, + /// 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, +} + +#[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, + 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, login_device"; + +type ChallengeTuple = ( + String, + i64, + String, + String, + String, + i64, + String, + String, + i64, + i64, + String, + Option, + String, +); + +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, + login_device: t.12, + } + } +} + +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)) + } + + /// 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" + )) + .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, + hardware_backed: bool, + strongbox: bool, + ) -> Result { + let r = sql::query( + "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 + .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, login_device) + 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) + .bind(c.login_device) + .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 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 > ? + AND device_id IN (SELECT id FROM mfa_devices WHERE status = 'active')", + ) + .bind(to) + .bind(now) + .bind(id) + .bind(now) + .execute(self.pool()) + .await + .map_err(err)?; + 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). + 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 { + 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 { + 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)] +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", + login_device: "Firefox on Linux", + expires_at, + } + } + + #[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; + 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; + 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", false, true) + .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_eq!((d.hardware_backed, d.strongbox), (false, true)); + 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", + ), + ( + "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)> = + 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/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..d4ea6b8 --- /dev/null +++ b/crates/rostdav-mfa-api/tests/flow.rs @@ -0,0 +1,1145 @@ +//! 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 { + serde_json::from_str(rustmfa_contract::OPENAPI_JSON).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-web/i18n/de.tsv b/crates/rostdav-web/i18n/de.tsv index 235bf91..a3e2589 100644 --- a/crates/rostdav-web/i18n/de.tsv +++ b/crates/rostdav-web/i18n/de.tsv @@ -456,3 +456,49 @@ 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 +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 +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/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-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..e8b3b98 100644 --- a/crates/rostdav-web/src/lib.rs +++ b/crates/rostdav-web/src/lib.rs @@ -12,6 +12,8 @@ mod login_page; mod more; mod pages; mod pages_more; +mod phone_step; +mod phones; mod session; mod sharing; mod twofactor; @@ -130,6 +132,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), @@ -143,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( @@ -153,6 +163,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/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/phone_step.rs b/crates/rostdav-web/src/phone_step.rs new file mode 100644 index 0000000..2e0e6d4 --- /dev/null +++ b/crates/rostdav-web/src/phone_step.rs @@ -0,0 +1,291 @@ +//! 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; + +pub(crate) 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 + } + } +} + +/// 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, + n: &NumberCard<'_>, +) -> String { + let id = esc(&c.challenge_id); + 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(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, + 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/phones.rs b/crates/rostdav-web/src/phones.rs new file mode 100644 index 0000000..8da4301 --- /dev/null +++ b/crates/rostdav-web/src/phones.rs @@ -0,0 +1,384 @@ +//! 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, 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")), + 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)) +} + +/// 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. +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, &f.code, &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) => { + 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.", + &[&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, + #[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 lang = a.lang; + match state + .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-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..2510043 --- /dev/null +++ b/crates/rostdav-web/static/phone.js @@ -0,0 +1,25 @@ +// 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 back = box.getAttribute("data-back-url") || "/login/2fa"; + 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 = back; } + }) + .catch(function () {}); + } + setInterval(poll, 2000); +})(); diff --git a/crates/rostdav/Cargo.toml b/crates/rostdav/Cargo.toml index c13b8de..8c0794d 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 @@ -47,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/src/config.rs b/crates/rostdav/src/config.rs index 3f22f22..095b827 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,87 @@ 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, + /// 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 { + fn default() -> Self { + Self { + challenge_ttl_seconds: 90, + enroll_ttl_seconds: 300, + audience: "rostdav".into(), + attestation: MfaAttestation::Required, + fcm: MfaFcm::default(), + } + } +} + +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 . _ -" + ); + 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(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, deny_unknown_fields)] pub struct Web { @@ -276,6 +358,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 +389,94 @@ 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 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| { + 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/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/src/lib.rs b/crates/rostdav/src/lib.rs index 8c3aca5..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, @@ -56,15 +56,46 @@ 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, 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() - .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_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/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/crates/rostdav/tests/web.rs b/crates/rostdav/tests/web.rs index 9bd6db0..781d7f5 100644 --- a/crates/rostdav/tests/web.rs +++ b/crates/rostdav/tests/web.rs @@ -5524,3 +5524,839 @@ 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); +} + +// ---- 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 + ); +} + +// ---- 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"), ("password", PW)], + ) + .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" + ); +} + +#[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); +} + +#[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/deny.toml b/deny.toml index 92be18b..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] @@ -36,3 +41,4 @@ allow-wildcard-paths = true [sources] unknown-registry = "deny" unknown-git = "deny" +allow-git = ["https://github.com/pierrejochem/rostdav-mfa"] diff --git a/docs/admin.md b/docs/admin.md index f474753..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. @@ -125,10 +125,36 @@ 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. -- **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. +- **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, 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, 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 | +|---|---|---| +| `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. | +| `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. 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. 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. + +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 new file mode 100644 index 0000000..0c9bf61 --- /dev/null +++ b/docs/adr/0014-push-mfa.md @@ -0,0 +1,88 @@ +# 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). 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`. +- **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. + **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; 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) + +- `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: + - 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 + +- 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`. + +## 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. +- 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/api.md b/docs/api.md index 2f19132..5477484 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 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 + +```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 4392b93..4b94ba6 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. @@ -103,6 +106,12 @@ 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, 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. | `nodes` | see ADR 0002 | | `blobs` | id, size, checksum, created_at | | `versions` | id, node_id, blob_id, size, mtime, author_app_password_id, created_at | 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/docs/plan.md b/docs/plan.md index 592319a..262ae78 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 (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 diff --git a/docs/security.md b/docs/security.md index 8adb06a..95c8e2e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -31,6 +31,9 @@ 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 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/docs/ui.md b/docs/ui.md index b854cde..ea6aabf 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -54,7 +54,9 @@ 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/v2/flow/` | Connect device (Login Flow v2; second step on the same page) | +| `/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 | 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. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index f1d7e4b..af53853 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,18 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustmfa-contract" +version = "0.1.0" +source = "git+https://github.com/pierrejochem/rostdav-mfa?rev=31e5218c2ad473257ee048691b9017637cbe20c1#31e5218c2ad473257ee048691b9017637cbe20c1" +dependencies = [ + "base64 0.22.1", + "p256", + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3075,6 +3261,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 +3364,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 +3437,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 +3536,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 +4808,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" diff --git a/rostdav.example.toml b/rostdav.example.toml index 8cc4a4d..bbaedfc 100644 --- a/rostdav.example.toml +++ b/rostdav.example.toml @@ -59,6 +59,26 @@ 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" + +[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 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"