Repository navigation
feat(mfa): push sign-in approval with the RustMFA app (backend) - #13
Open
pierrejochem wants to merge 24 commits into
Open
pierrejochem wants to merge 24 commits into
pierrejochem wants to merge 24 commits into
Conversation
Add the enrollment service for push-MFA (ADR 0014): a user creates a single-use enroll token (5 per user per 15 minutes), and the phone registers with it after its Android key attestation and public keys validate (5 failures per address per 15 minutes). Share a test kit between the attestation and enrollment tests, and inject test attestation roots through AppState. The rustmfa-contract dependency is a temporary path dependency; ADR 0014 records that it must become a pinned git dependency before merge.
Verify the signed Authorization header of a RustMFA phone (ADR 0014): strict header grammar, 60 second timestamp skew, ES256 signature by the request key, nonce used once per device in a 5 minute window, phone and account active. The nonce is recorded after the signature verifies, and a revoked phone is named as such only to a caller who holds its key. Five failures per device in 15 minutes block it for 15 minutes.
Add sign-in challenges for RustMFA phones (ADR 0014): create one for an explicit phone (two-digit number, two decoys at a random place, 128-bit nonce), list pending ones, and approve or deny with a signature by the biometric-gated approval key over the canonical message. The signature is checked before the number, so only the approval key can end a challenge; a wrong number denies it. One conditional update decides between concurrent answers. Expiry is recorded and audited once. Five challenges per user in 15 minutes. Push delivery goes through a channel trait with a no-op default; a failure is logged without the token. Migration 0013 gains login_device (the branch is not merged yet).
Add the phone management service for RustMFA (ADR 0014): list a user's phones, revoke one (which ends its open challenges), change a phone's push token, rotate both keys, and read the user's own mfa events. A challenge can now only be resolved while its phone is active, inside the single conditional update, so a revoke that lands between the checks and the update wins. Key rotation needs a fresh attestation under the default policy, bound to the device id and the new request key. Migration 0013 gains an events(actor, action) index for the own-events view.
Add the rostdav-mfa-api crate with the ten device routes of the RustMFA contract under /api/v1: registration, device list, revoke, push token, key rotation, challenge list and details, approve, deny, and the user's own audit events. Requests are verified by the signed Authorization header in rostdav-app; this crate reads at most 64 KiB, refuses unknown JSON fields, maps errors to the contract's bodies and statuses, and never touches the database. The router is mounted without the cookie and CSRF layers. rostdav-app gains a test-util feature that exposes its test kit to the integration tests; production builds do not enable it.
Fixes RUSTSEC-2026-0391, RUSTSEC-2026-0398 and RUSTSEC-2026-0403 reported by cargo deny.
PUT into a missing parent answers 404 like Nextcloud; litmus expects 409. Any other litmus failure still fails the script.
expires_at comes from the caller's clock and created_at from the database's, so the span can be 29 or 30 seconds at a second boundary.
Threat rows S4 and S5, admin note on what is built, M14 state in plan and CLAUDE.md, implementation notes in ADR 0014.
Sign-in decisions (browser, Login Flow v2, WebDAV/OCS account password) now ask has_second_factor: TOTP on or at least one active phone.
The rostdav-mfa repository is published. The path dependency is gone, cargo-deny allows the one git source, and the HTTP tests read the OpenAPI JSON the crate exports instead of a sibling checkout.
The contract crate got a licence (MIT OR Apache-2.0), so cargo-deny licenses passes. crypto-bigint 0.5 (p256 0.13) has RUSTSEC-2026-0387; the server only verifies public data with it, so the advisory is ignored with the reason in deny.toml.
A challenge started for a sign-in carries a keyed hash of the ticket and flow. Only the ticket holder can poll it or turn it into a session, once; a phone revoked after it approved releases nothing (migration 0013 gets released_at).
The second step offers "Send to my phone" (POST with CSRF, never a GET), shows the number, polls the status (JSON without the number) and signs in once the phone approved. Tied to the ticket of the password step.
The flow page keeps its state in the form: the phone step is the form actions phone and phone_finish, the status poll is a GET with the flow's ticket. A ticket of another flow cannot finish this one.
Devices & security lists the user's RustMFA phones with revoke and the last security events. Add a phone asks for the account password again and shows a single-use enrollment QR code. server.public_url (else the Host header) is the address the code carries.
A PushChannel that wakes a phone through the FCM HTTP v1 API with a data message holding the challenge id only, at high priority. Service-account JWT (RS256) for an access token that is cached until a minute before it ends. [mfa.fcm] turns it on; an unusable file stops start-up.
The rate-limit blocks of the phone features (enrollment per address and per user, signed requests per phone, approvals per user) are listed on Administration > Security and can be lifted there.
…actor, one approval per sign-in Final review findings: the admin reset revokes phones (a lost only-phone no longer locks the user out for good); enrolling a phone also needs a TOTP code when codes are on; revoking the only second factor needs the password; a second start of the same sign-in shows the open approval instead of creating another.
pierrejochem
marked this pull request as ready for review
November 10, 2026 21:42
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Push sign-in approval with the RustMFA Android app (ADR 0014, milestone M14). This is the server side; the Android app is a separate piece of work.
What is in
[mfa]settings, migration 0013 (phones, enrollment tokens, challenges, request nonces), key attestation against two pinned Google roots.rostdav-mfa-apiwith the device HTTP API (/api/v1/devices/…,/api/v1/mfa/challenges/…,/api/v1/audit).[mfa.fcm]): data message with the challenge id only.rustmfa-contractcrate (pierrejochem/rostdav-mfa), pinned by revision.Points to look at
reset-2faalso revokes phones, so a lost only-phone is recoverable.deny.toml: RUSTSEC-2026-0387 (crypto-bigint 0.5 via p256 0.13) is ignored with a reason: the contract only verifies public data. Revisit when p256 0.14 is released.released_at,login_device, an index). A database that ran an earlier version of this branch must be recreated.Checks run
cargo fmt,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace(766 passed),cargo deny check advisories licenses bans sources. A fresh-context review of the whole branch found no bypass or injection; its Important findings are fixed. Deferred minors: login cookie rotation across tabs,phone.jspolling after ticket expiry, QR scheme behind TLS withoutserver.public_url, a database error hiding the phone option.