Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
7b3f28c
Add `mfa-recovery-controller` package with initial implementation
ieow Aug 31, 2026
0b4047c
Update `mfa-recovery-controller` with breaking changes and new features
ieow Aug 31, 2026
91964b3
Enhance `MfaRecoveryController` with additional test cases and author…
ieow Sep 1, 2026
2a3b74e
Merge branch 'main' into cw/mfa-recovery-controller
ieow Sep 2, 2026
57f621b
Implement architecture documentation and update mutation handling in …
ieow Sep 7, 2026
2fb251c
Enhance cryptographic functionality in MFA recovery controller
ieow Sep 8, 2026
427d70b
Update MFA recovery controller to return epoch with recovery secret
ieow Sep 8, 2026
1bbaf7a
Update TypeScript configuration for MFA recovery controller
ieow Sep 8, 2026
f0e26e9
fix: Cursor comment
huggingbot Sep 14, 2026
9492bad
feat(mfa-recovery-controller): align wrap, receipts, and auth with cu…
huggingbot Sep 15, 2026
0c0b7d9
feat(mfa-recovery-controller): hash logical pending payloads and keep…
huggingbot Sep 16, 2026
c96e9ee
chore: update changelog
huggingbot Sep 16, 2026
ac92cfc
Merge remote-tracking branch 'origin/main' into cw/mfa-recovery-contr…
huggingbot Sep 16, 2026
97c2c6a
feat(mfa-recovery-controller): refuse overlapping mutations and abort…
huggingbot Sep 16, 2026
d9eb380
feat(mfa-recovery-controller): enforce mutation state checks in getRe…
huggingbot Sep 16, 2026
bd63ecb
feat(mfa-recovery-controller): add distinct identifier counting and u…
huggingbot Sep 16, 2026
b3061cb
fix(mfa-recovery): prevent aborting writing mutations
huggingbot Sep 17, 2026
1ba2045
fix(mfa-recovery-controller): update getRecoverySecret behavior to re…
huggingbot Sep 23, 2026
c642e40
feat(mfa-recovery-controller): integrate Cubist escrow commands and a…
ieow Sep 24, 2026
ac91259
feat(mfa-recovery-controller): implement identifier authentication an…
ieow Sep 25, 2026
21968ae
refactor(mfa-recovery-controller): update architecture and changelog …
ieow Sep 25, 2026
a2f6118
feat(mfa-recovery-controller): implement identifier authentication an…
ieow Sep 25, 2026
eeefb2c
refactor(mfa-recovery-controller): update key-bound identifier handli…
huggingbot Sep 28, 2026
73b7b8d
docs(mfa-recovery-controller): add detailed Cubist store sequence and…
ieow Sep 30, 2026
916250a
Merge remote-tracking branch 'origin/cw/test-cubist-recovery-controll…
huggingbot Oct 1, 2026
de6199e
refactor(mfa-recovery-controller): treat AuthController tokens as Hyd…
huggingbot Oct 5, 2026
016b36e
Merge remote-tracking branch 'origin/cw/mfa-recovery-controller' into…
huggingbot Oct 5, 2026
f9a12ea
docs(mfa-recovery-controller): expand CUBIST_TEST_SEQUENCE.md with de…
ieow Oct 6, 2026
3ca9dfb
docs(mfa-recovery-controller): refine CUBIST_TEST_SEQUENCE.md for cla…
ieow Oct 6, 2026
8616570
docs(mfa-recovery-controller): update CUBIST_TEST_SEQUENCE.md with ne…
ieow Oct 6, 2026
de9ac36
fix(mfa-recovery-controller): sort identifiers by UTF-8 bytes
huggingbot Oct 6, 2026
bc5a8c9
Merge remote-tracking branch 'origin/cw/mfa-recovery-controller' into…
huggingbot Oct 6, 2026
8e94755
feat(mfa-recovery-controller): request AuthController tokens for escr…
huggingbot Oct 7, 2026
91712a1
docs(mfa-recovery-controller): refine CUBIST_TEST_SEQUENCE.md for mob…
ieow Oct 8, 2026
c29b284
feat(mfa-recovery-controller): update CubistEscrowProvider to use pol…
huggingbot Oct 9, 2026
021a486
Merge branch 'main' into cw/mfa-recovery-controller
ieow Oct 13, 2026
4d1203d
fix: update hex handling in crypto functions
ieow Oct 13, 2026
cff5361
chore: update linting scripts and dependencies in mfa-recovery-contro…
ieow Oct 13, 2026
4793eb9
fix: handle mutation rejection in mfa-recovery-controller
ieow Oct 13, 2026
9d862db
chore: update dependencies in yarn.lock
ieow Oct 14, 2026
f175c7c
feat: add unit tests for auth controller token decoding
ieow Oct 14, 2026
aafff9f
fix: update jest configuration and improve type safety in tests
ieow Oct 14, 2026
5171d88
fix: lint:misc
ieow Oct 14, 2026
840cc50
Merge branch 'main' into cw/mfa-recovery-controller
ieow Oct 15, 2026
c5fdaa9
Enhance MfaRecoveryController to assert escrow availability
ieow Oct 15, 2026
251cbc0
chore: Initialize `mfa-recovery-controller` package
ieow Oct 15, 2026
f0b5cd6
Merge branch 'cw/init-mfa-recovery-controller' into cw/mfa-recovery-c…
ieow Oct 15, 2026
ab0d6aa
feat: integrate @metamask/cryptography and @metamask/superstruct for …
ieow Oct 15, 2026
a141908
feat: add @metamask/superstruct for structured data validation in mfa…
ieow Oct 15, 2026
1e6a2ac
feat: integrate json-stable-stringify for enhanced JSON canonicalization
ieow Oct 15, 2026
62d9629
chore: update CHANGELOG for initial release of MfaRecoveryController
ieow Oct 15, 2026
891b9f7
chore: update yarn.lock to consolidate zod dependency versions
ieow Oct 15, 2026
61ddf80
docs: enhance architecture documentation for mfa-recovery-controller
ieow Oct 15, 2026
604d44f
Merge branch 'main' into cw/mfa-recovery-controller
ieow Oct 19, 2026
150fc06
chore: update CHANGELOG for `mfa-recovery-controller` package
ieow Oct 19, 2026
ff4cb3c
refactor(mfa-recovery-controller): migrate tests from Jest to Vitest …
ieow Oct 20, 2026
221e8a1
feat: align MFA recovery with Auth API approvals
huggingbot Oct 19, 2026
c48527d
feat: add Auth API recovery approval provider
huggingbot Oct 19, 2026
094d048
fix: sync recovery mutations with Auth-approved identifiers
huggingbot Oct 20, 2026
88338db
test: align MFA recovery tests with Vitest
huggingbot Oct 20, 2026
7862a22
fix: require AAL2 approvals for recovery reads and writes
huggingbot Oct 22, 2026
099ac09
Merge remote-tracking branch 'origin/main' into feat/mfa-recovery-aut…
huggingbot Oct 28, 2026
293109e
feat: send approved identifiers with recovery mutations
huggingbot Oct 28, 2026
ce0d6ce
refactor: request recovery approvals through AuthenticationController
huggingbot Oct 28, 2026
6099a34
fix: reject unapproved identifiers before writing and refresh the can…
huggingbot Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 35 additions & 90 deletions packages/mfa-recovery-controller/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,18 @@ flowchart LR
| --------------------------- | --------------------------------------------------------------------------- |
| `pendingOperation` | Only persisted field: encrypted `authorizing` / `writing` blob, or `null` |
| `escrows[]` | Build-time replica set; non-empty, unique `id`s; mutations require **all** |
| `authProvider` | Profile id + request-bound AuthController token (Hydra JWT) |
| `authProvider` | Profile id + request-bound Auth API approval |
| `identifierAuthProvider` | Key-bound IdP assertion (passkey / OIDC / SIWE) |
| `pendingOperationEncryptor` | Encrypts / decrypts the pending blob with wallet secure storage |
| `now` | Clock (Unix seconds) for AuthController token expiry; defaults to `unixNow` |
| `#withLock` | Serializes every public method except `authenticateIdentifier` |

Each escrow has two identities: `id` is the replica id used in mutation
`audiences` and receipts; `authAudience` is the audience the AuthController
token must carry for that escrow. They are independent (Cubist uses `"cubist"`
for both).
`id` identifies the replica in mutations and receipts; `authAudience` identifies
the approval audience. Cubist uses `id: "cubist"` and an `authAudience` matching
`AUTH_CONTROLLER_AUDIENCE`.

Identifier types pick the auth mode from a trusted table, not a client flag:
`passkey` / `oidc` / `siwe` → key-bound. Any other type is rejected with
`passkey` / `social` / `siwe` → key-bound. Any other type is rejected with
`unknown_identifier_type`. `emailOtp` / `smsOtp` escrow-challenge is not wired
yet (MFA-605 / MFA-606 / MFA-568).

Expand Down Expand Up @@ -113,10 +112,13 @@ Anything that fails is rejected rather than acted on:
must match the mutation fields.
- `register` has `expectedVersion` `0` and `identifier: null`; updates carry
an identifier.
- `writing` only: the AuthController token's `sub` is the mutation
`profileId`, its `ext.request_hash` is the mutation `requestHash`, updates
have `aal >= 2`, `register` / `updateIdentifiers` carry `identifiers_hash`,
and receipts are well-formed with at most one per escrow.
- `writing` only: the approval binds `sub` to `profileId` and `ext.request_hash`
to `requestHash`, with `aal >= 2`. `register` / `updateIdentifiers` also need
`identifiers_hash` to match `approvedIdentifiers`, and every payload
identifier to equal one of them exactly. This runs before `writing` is
persisted: an escrow rejection is not a `Deny`, so a mismatch found there
would leave an unabortable write. Receipts must be well-formed with at most
one per escrow.

## Mutation sequence (`register` / updates)

Expand Down Expand Up @@ -164,15 +166,20 @@ sequenceDiagram
end
```

AuthController token request:

- `requestHash` is the mutation `requestHash`; `audiences` is every escrow's
`authAudience`.
- Updates set `requireTwoFactor`. `register` and `updateIdentifiers` pass the
payload `identifiers` so the token is bound to them.
- The token is a compact Hydra JWT. The controller decodes its claims without
verifying the signature (escrows verify it) and only uses them for
pending-state checks and the `exp` refresh.
Auth approvals:

- The provider receives the operation's `requestHash` and each target escrow's
`authAudience`. All writes and reads set `requireTwoFactor` for AAL2.
- The issuer controls approval lifetime. AAL2 requires recent MFA.
- The approval's `identifiers` are persisted with its token as
`approvedIdentifiers` and refreshed with it. `register` and
`updateIdentifiers` send them to every escrow, which checks them against
`identifiers_hash` and rejects payload identifiers outside them. Hashing
sorts object keys, not arrays.
- Missing or stale MFA rejects with `AuthenticationController`'s
`StepUpRequiredError`.
- Escrows verify signatures. The controller decodes claims only for pending-state
validation and expiry-based refresh.

Receipts:

Expand Down Expand Up @@ -204,11 +211,11 @@ retries only escrows that do not already have a stored receipt. Updates
re-run identifier auth (a new key-bound token and PoP) for those escrows on
every attempt, so the identifier provider must be able to authenticate again.

## Cubist store sequence
## Cubist integration

The controller does not call CubeSigner, the registration API, or
`KeyringController`. A Cubist deploy injects one `CubistEscrowProvider`
(`id` and `authAudience`: `"cubist"`). The caller proves OIDC, calls
`KeyringController`. A Cubist deploy injects one `CubistEscrowProvider`.
The caller proves OIDC, calls
`ensure-user`, opens the session, and constructs the provider with the
authenticated `CubeSignerClient` and the pinned wrap and receipt public keys
before `register`.
Expand All @@ -223,53 +230,7 @@ the mutation id, request hash, escrow id, `version === newVersion`,
`receiptKeyId` (SHA-256 of the pinned receipt key), and the Ed25519 signature
locally.

`register(recoverySecret, identifiers)` takes an identifier **array** of at
least two distinct key-bound identifiers. One `siwe` identifier is rejected.
`applyMutation` is the store: the controller wraps the secret to the escrow
wrap key, and the Cubist C2F persists that payload.

`authenticateIdentifier` asks the injected identifier provider for a key-bound
token. A SIWE provider signs with `KeyringController.signPersonalMessage`.
The controller does not. `getRecoverySecret` then challenges the escrow, signs
the proof of possession locally, calls `getSecret`, and unwraps. `epoch` is
the replica `version`.

```mermaid
sequenceDiagram
participant Caller as Caller
participant Auth as RecoveryAuthProvider
participant IdP as SIWE identifier provider
participant Ctrl as MfaRecoveryController
participant Escrow as Cubist escrow

Note over Caller,Escrow: proveOidcIdentity, ensure-user, createOidcSession, then new CubistEscrowProvider

Caller->>Ctrl: register(secret, identifiers)
Ctrl->>Escrow: isAvailable (ping)
Ctrl->>Auth: getAuthenticatedProfileId
Ctrl->>Ctrl: persist authorizing
Ctrl->>Auth: authorizeRecoveryRequest
Auth-->>Ctrl: AuthController token (Hydra JWT)
Ctrl->>Ctrl: wrap secret, persist writing
Ctrl->>Escrow: applyMutation
Escrow-->>Ctrl: receipt
Ctrl->>Ctrl: verify receipt, clear pending

Caller->>Ctrl: authenticateIdentifier(siwe)
Ctrl->>IdP: getKeyBoundIdentifierToken
Note over IdP: signPersonalMessage
IdP-->>Ctrl: token
Ctrl-->>Caller: identifierSession

Caller->>Ctrl: getRecoverySecret(identifierSession)
Ctrl->>Escrow: isAvailable (ping)
Ctrl->>Escrow: generateChallenge
Ctrl->>Ctrl: sign proof of possession
Ctrl->>Escrow: getSecret
Escrow-->>Ctrl: wrapped secret + version + wrapKeyId
Ctrl->>Ctrl: check wrapKeyId, unwrap
Ctrl-->>Caller: secret bytes + epoch
```
For a single-escrow walkthrough, see [Cubist test sequence](CUBIST_TEST_SEQUENCE.md).

## Read sequence (`getRecoverySecret`)

Expand All @@ -283,6 +244,7 @@ sequenceDiagram
participant C as Client
participant M as MfaRecoveryController
participant I as IdentifierAuthProvider
participant A as RecoveryAuthProvider
participant E as Available escrows

C->>M: authenticateIdentifier(identifier)
Expand All @@ -296,9 +258,11 @@ sequenceDiagram
alt none available
M-->>C: no_available_escrow
end
M->>A: authorizeRecoveryRequest (read hash, AAL2)
A-->>M: request-bound approval
M->>E: generateChallenge + PoP (partial OK)
par read
M->>E: getSecret
M->>E: getSecret(approval, authorization, requestId, pkE)
M->>M: check wrapKeyId, unwrap with ephemeral key
end
M->>M: selectHighestConsistentVersion
Expand Down Expand Up @@ -330,7 +294,7 @@ the highest one. The next mutation still requires `resume()` first.
```mermaid
flowchart TD
Start[identifier + requestHash] --> Mode{trusted type?}
Mode -->|passkey / oidc / siwe| KB[fresh proof key, IdP key-bound token]
Mode -->|passkey / social / siwe| KB[fresh proof key, IdP key-bound token]
KB --> Chal[each escrow generateChallenge]
Chal --> PoP[sign PoP over hash of token + challengeId + requestHash]
Mode -->|emailOtp / smsOtp| Todo[TODO MFA-605 / MFA-606 / MFA-568]
Expand All @@ -341,22 +305,3 @@ On **mutation**, every remaining target escrow must authorize or the write
does not start (`identifier_auth_failed`). On **read**, escrows whose
challenge fails are skipped. `register` sends no identifier authorization
(`null`).

## Crash / repair

```mermaid
flowchart TD
Crash[crash or partial apply] --> Phase{pending phase?}
Phase -->|none| Idle[idle]
Phase -->|authorizing| AbortOrResume{client choice}
AbortOrResume -->|abort| Idle
AbortOrResume -->|resume| Auth[re-issue AuthController token]
Auth --> Write[replicate]
Phase -->|writing| ResumeOnly[resume only]
ResumeOnly --> Skip[skip escrows that already have verified receipts]
Skip --> Refresh[re-issue token only if expired]
Refresh --> Write
Write --> All{receipts for every configured escrow?}
All -->|yes| Clear[clear pending]
All -->|no| PendingErr[IncompleteMutationError]
```
3 changes: 3 additions & 0 deletions packages/mfa-recovery-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Initial release of `MfaRecoveryController`, which manages MFA recovery flows and persists in-flight operations in its state ([#10022](https://github.com/MetaMask/core/pull/10022))
- Requires recent, request-bound AAL2 approvals for writes and reads.
- Sends the approval's identifiers to escrows as `approvedIdentifiers` with `register` and `updateIdentifiers` mutations, and rejects payload identifiers that are not exactly among them before writing ([#10883](https://github.com/MetaMask/core/pull/10883))
- Exports `MfaRecoveryController`, `getDefaultMfaRecoveryControllerState`, and the controller's action, event, messenger, state, and options types.
- Exports `AuthApiRecoveryAuthProvider`, `AuthApiRecoveryAuthProviderOptions` and `AuthApiRecoveryAuthProviderMessenger` for requesting approvals through `AuthenticationController:requestMfaApproval`, using the profile id from `AuthenticationController:refreshCanonicalProfileId` ([#10883](https://github.com/MetaMask/core/pull/10883))
- Exports `CubistEscrowProvider` and `CubistEscrowProviderOptions` for storing recovery secrets with CubeSigner.
- Exports `MfaRecoveryError`, `IncompleteMutationError`, and `MutationRejectedError`.
- Exports `MIN_IDENTIFIERS`, `IDENTIFIER_AUTH_MODES`, and `getIdentifierAuthMode` for identifier authentication.
Expand Down
116 changes: 30 additions & 86 deletions packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,47 +7,14 @@ each one. Error branches, pending-state repair, and multi-escrow behavior are
in [ARCHITECTURE.md](./ARCHITECTURE.md); treat the TypeScript in `src/` as the
source of truth if a diagram drifts.

| Step | Client call | Version |
| ---- | ------------------------------------------------------------ | ------- |
| 1 | `register(secret, [siwe, passkey])` | `0 → 1` |
| 2 | `updateRecoverySecret(passkey, newSecret, epoch)` | `1 → 2` |
| 3 | `updateIdentifiers(siwe, [siwe, replacementPasskey], epoch)` | `2 → 3` |
| Step | Client call | Read with | Version |
| ---- | ------------------------------------------------------------ | -------------------- | ------- |
| 1 | `register(secret, [siwe, passkey])` | `siwe` | `0 → 1` |
| 2 | `updateRecoverySecret(passkey, newSecret, epoch)` | `passkey` | `1 → 2` |
| 3 | `updateIdentifiers(siwe, [siwe, replacementPasskey], epoch)` | `replacementPasskey` | `2 → 3` |

Each update passes the `epoch` returned by the previous read.

## Overview

```mermaid
sequenceDiagram
autonumber
participant Mobile as Mobile client
participant C as MfaRecoveryController
participant Cubist as Cubist escrow

Note over Mobile,Cubist: Setup: sign in, open Cubist session, build controller

Mobile->>C: register(secret, [siwe, passkey])
C->>Cubist: applyMutation (version 0 → 1)
Cubist-->>C: signed receipt
Mobile->>C: read with siwe
C->>Cubist: getSecret
C-->>Mobile: secret, epoch 1

Mobile->>C: updateRecoverySecret(passkey, newSecret, 1)
C->>Cubist: applyMutation (version 1 → 2)
Cubist-->>C: signed receipt
Mobile->>C: read with passkey
C->>Cubist: getSecret
C-->>Mobile: newSecret, epoch 2

Mobile->>C: updateIdentifiers(siwe, [siwe, replacementPasskey], 2)
C->>Cubist: applyMutation (version 2 → 3)
Cubist-->>C: signed receipt
Mobile->>C: read with replacementPasskey
C->>Cubist: getSecret
C-->>Mobile: same secret, epoch 3
```

## Setup

The mobile client does this before it creates the controller. The controller
Expand Down Expand Up @@ -83,7 +50,9 @@ sequenceDiagram

## Write: `register`, `updateRecoverySecret`, `updateIdentifiers`

All three mutations share one path.
All mutations require a request-bound AAL2 approval. `register` and
`updateIdentifiers` also send the approval's identifiers as
`approvedIdentifiers`, bound by `identifiers_hash`.

```mermaid
sequenceDiagram
Expand All @@ -98,7 +67,7 @@ sequenceDiagram
C->>Auth: getAuthenticatedProfileId()
Auth-->>C: profileId
Note over C: Build the mutation (version epoch → epoch + 1)<br/>and its requestHash
C->>Auth: authorizeRecoveryRequest(requestHash, ...)
C->>Auth: authorizeRecoveryRequest(requestHash, requireTwoFactor: true, ...)
Auth-->>C: JWT bound to requestHash
C->>Cubist: isAvailable

Expand All @@ -107,17 +76,17 @@ sequenceDiagram
end

Note over C: Encrypt the secret to the Cubist wrap key
C->>Cubist: applyMutation(mutation, JWT, { token, proof } or null, payload)
C->>Cubist: applyMutation(mutation, JWT, { token, proof } or null, payload, approvedIdentifiers)
Cubist-->>C: signed receipt
Note over C: Verify the receipt with the pinned receipt key
C-->>Mobile: done
```

| Operation | Identifier proof | JWT requires | Payload |
| ---------------------- | ----------------------- | ---------------- | -------------------- |
| `register` | none | identifiers | identifiers + secret |
| `updateRecoverySecret` | the identifier argument | 2FA | secret |
| `updateIdentifiers` | the identifier argument | 2FA, identifiers | identifiers |
| Operation | Identifier proof | Payload |
| ---------------------- | ----------------------- | -------------------- |
| `register` | none | identifiers + secret |
| `updateRecoverySecret` | the identifier argument | secret |
| `updateIdentifiers` | the identifier argument | identifiers |

If an escrow refuses the mutation (`MutationRejectedError`) and every replica
does the same, the controller clears the pending mutation. A transport failure
Expand All @@ -126,47 +95,19 @@ to finish that write.

## Proof of possession

Updates and reads prove control of a registered identifier in two parts. The
identifier provider signs a token that names a fresh proof key and the
`requestHash`. Then the controller signs a Cubist challenge with that proof
key. Code: `requestKeyBoundIdentifierToken` and `authorizeEscrowsWithToken` in
`src/identifier-auth.ts`.

```mermaid
sequenceDiagram
autonumber
participant C as MfaRecoveryController
participant Id as identifierAuthProvider
participant Cubist as Cubist escrow

rect rgba(128, 128, 128, 0.1)
Note over C,Id: Part 1: proof key + identifier token
Note over C: generateSigningKey() → Ed25519 proof key pair
C->>Id: getKeyBoundIdentifierToken(identifier, proofPublicKey, requestHash)
Note over Id: binding = hash([proofPublicKey, requestHash])<br/>SIWE: personal_sign a SIWE message, Nonce = binding<br/>Passkey: WebAuthn assertion, challenge = binding
Id-->>C: token { identifier, proofPublicKey, requestHash, providerAssertion }
end
Updates and reads prove control of a registered identifier:

rect rgba(128, 128, 128, 0.1)
Note over C,Cubist: Part 2: challenge + signature
C->>Cubist: generateChallenge()
Cubist-->>C: challenge { id, escrowId, expiresAt }
Note over C: signature = sign(proofPrivateKey,<br/>hash([token, challenge.id, requestHash]))
Note over C: authorization = { kind: key-bound, token,<br/>proof: { challengeId, requestHash, signature } }
C->>Cubist: applyMutation or getSecret(authorization, ...)
Note over Cubist: providerAssertion is valid for the identifier<br/>and binds proofPublicKey + requestHash<br/>identifier is registered, challenge id is its own<br/>signature verifies under proofPublicKey
end
```
1. The controller generates an Ed25519 proof key and requests an identifier token
binding `hash([proofPublicKey, requestHash])`: the SIWE nonce or WebAuthn challenge.
2. The proof key signs `hash([token, challenge.id, requestHash])` for each escrow.
The controller sends the approval and `{ kind: 'key-bound', token, proof }`
with `applyMutation` or `getSecret`.
3. The escrow C2F verifies the provider assertion, registered identifier, request
binding, challenge, and proof signature.

- The identifier provider signs once, over the proof key and `requestHash`.
The proof key then signs each escrow challenge, so the token works only for
this request.
- The proof private key is never sent to the identifier provider or Cubist.
For reads it lives only in the session returned by `authenticateIdentifier`.
- The Cubist-side checks happen in the escrow C2F (`cubist_secret_escrow`),
not in this package.
- `register` sends no identifier proof (`null`); the JWT from `authProvider`
is the only authorization.
The proof private key never leaves the controller/caller; read sessions keep it
in memory. `register` sends `null` instead of an identifier proof. Implementation:
`requestKeyBoundIdentifierToken` and `authorizeEscrowsWithToken` in `src/identifier-auth.ts`.

## Read: `authenticateIdentifier`, `getRecoverySecret`

Expand All @@ -175,6 +116,7 @@ sequenceDiagram
autonumber
participant Mobile as Mobile client
participant C as MfaRecoveryController
participant Auth as authProvider
participant Id as identifierAuthProvider
participant Cubist as Cubist escrow

Expand All @@ -186,8 +128,10 @@ sequenceDiagram
Mobile->>C: getRecoverySecret(session)
Note over C: Recompute requestHash, must equal token.requestHash
C->>Cubist: isAvailable
C->>Auth: authorizeRecoveryRequest(requestHash, requireTwoFactor: true, ...)
Auth-->>C: request-bound AAL2 approval
Note over C,Cubist: Proof of possession, part 2: challenge + signature
C->>Cubist: getSecret({ token, proof }, requestId, pkE)
C->>Cubist: getSecret(approval, { token, proof }, requestId, pkE)
Cubist-->>C: secret encrypted to the read key, version
Note over C: Decrypt the secret
C-->>Mobile: { recoverySecret, epoch: version }
Expand Down
Loading
Loading