Skip to content

feat(mfa): push sign-in approval with the RustMFA app (backend) - #13

Open
pierrejochem wants to merge 24 commits into
mainfrom
feat/push-mfa
Open

pierrejochem wants to merge 24 commits into
mainfrom
feat/push-mfa

Conversation

@pierrejochem

@pierrejochem pierrejochem commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

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.
  • Enrollment, signed request authentication, number-matched challenges with signed approve and deny, phone management and an own-events view.
  • New crate rostdav-mfa-api with the device HTTP API (/api/v1/devices/…, /api/v1/mfa/challenges/…, /api/v1/audit).
  • An enrolled phone counts as a second factor next to TOTP. The browser sign-in and the Login Flow v2 page have a "Send to my phone" step; a challenge is bound to the sign-in that started it and released once.
  • Devices & security: list and revoke phones, enrollment QR code (password, and a current code when TOTP is on).
  • FCM push adapter ([mfa.fcm]): data message with the challenge id only.
  • Phone rate-limit blocks on the admin lockouts page.
  • Signing formats and test vectors come from the separate rustmfa-contract crate (pierrejochem/rostdav-mfa), pinned by revision.

Points to look at

  • Authentication change (approval point 3, granted with ADR 0014): once a user has an active phone, WebDAV and OCS reject the account password (app passwords still work) and browser sign-in asks a second step. reset-2fa also 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.
  • Migration 0013 was amended in place during the branch (released_at, login_device, an index). A database that ran an earlier version of this branch must be recreated.
  • Attestation revocation list is not consulted (documented in the ADR).
  • The real FCM service has not been called; the adapter is tested against a local fake server.

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.js polling after ticket expiry, QR scheme behind TLS without server.public_url, a database error hiding the phone option.

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
pierrejochem marked this pull request as ready for review November 10, 2026 21:42

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant