Skip to content

identity: implement ADR 0009 — MFA and bound device approval #1262

Description

@FSM1

Umbrella for implementing ADR 0009 — device approval is a bound rendezvous, and the recovery phrase is the guaranteed path (FSM1/cipher-box-next#65).

Work is tracked as sub-issues of this one — this body carries no checkbox list.

The gap

v2 has no MFA and no device approval. The entire surface is one refusal in apps/web/src/auth/coreKit.ts: when the Core Kit returns REQUIRED_SHARE, the client logs out, clears its store and throws, with a comment saying recovery and device approval "are not built yet". There is no API endpoint, no engine surface, and no UI.

Nothing tracked it until now, and the blueprint is why: web-client.md, desktop.md and testing.md all describe MFA and device approval as chrome-side Core Kit UX. It is not. An existing device approving a new one needs a server-mediated rendezvous, and v1 built an API module with its own table and migration for it. The mislabel concealed an API slice.

This surfaces at the staging cutover, not before: it needs a real account with a factor policy and a second device.

What v1 got right, and what it did not

The shape holds — the API is a bulletin board that relays ciphertext, and the scoped pre-reconstruction token is properly limited and fails closed on undecorated routes. Four properties did not hold:

  • The API relays the requester's ephemeralPublicKey and the approver seals to whatever comes back, so a substituted key delivers the factor key to the server. Under ADR 0008 the same operator mints the identity token that yields the verifier share, so both halves of the threshold sit with one party.
  • Neither half of the exchange is signed; deviceId and respondedByDeviceId are self-reported. v1's per-device Ed25519 key signed nothing, ever.
  • The approver decides on an attacker-supplied device name that passes validation as Chrome on macOS.
  • The approver clones its own live factor key, and removing a factor rotates nothing.

What the ADR decides

  • D1 — an API slice, and the blueprint says so.
  • D2 — the recovery phrase is the guaranteed path on every platform; approval is additive. If D3/D4 cannot be met, approval does not ship.
  • D3 — both devices display a comparison value derived from the ephemeral key; a substitution becomes visible to the person authorising it.
  • D4 — both halves signed by device identity keys.
  • D5 — a fresh factor per approval; the approver's own is never transferred.

Sequencing

The recovery path (D2) is the floor and does not depend on the rendezvous, so it can land first and independently. Everything else carries dependency edges rather than an order in prose.

A trap worth naming up front

Every v1 cross-device UAT case is recorded as skipped, for want of two authenticated devices — and that is exactly how its desktop half reached a verified status while being incapable of working: it sent a 33-byte compressed public key where the DTO required 65 uncompressed, a guaranteed rejection on every attempt. A harness that drives two sessions is part of this work, not a follow-up to it.

Part of #993

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    comp:apiapps/api — NestJS residual surface, registry, mailbox, republishercomp:webpackages/client + apps/web — WASM host, browser seams, React UIv2-buildv2 rewrite build slice

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions