diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 6d81e27de31..18f22a4627f 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -67,7 +67,11 @@ matches the hash computed over its hex-encoded form. Public surface (all exposed as messenger actions): `register`, `updateRecoverySecret`, `updateIdentifiers`, `authenticateIdentifier`, -`getRecoverySecret`, `resume`, `abort`, `getPhase`. +`getRecoverySecret`, `getIdentifiers`, `resume`, `abort`, `getPhase`. + +`getIdentifiers` takes an AAL1 approval and returns the enrolled identifiers +at the highest consistent escrow version as `epoch`, plus Auth's identifiers. +A new device builds the `updateIdentifiers` set and `expectedEpoch` from it. ## State machine @@ -222,8 +226,10 @@ before `register`. `CubistEscrowProvider` sends every command with `policyInvoke` on the C2F (default `cubist_secret_escrow`, version `latest`) as `{ cmd, ...fields }`: -`ping` (`isAvailable`), `generateChallenge`, `getSecret`, and -`applyMutation`. Escrow JSON is parsed from C2F stdout. A policy `Deny` on +`ping` (`isAvailable`), `generateChallenge`, `getIdentifiers`, `getSecret`, +and `applyMutation`. `generateChallenge` and `getIdentifiers` take the +operation's `authControllerToken`; the escrow keys the challenge to its +profile. Escrow JSON is parsed from C2F stdout. A policy `Deny` on `applyMutation` becomes `MutationRejectedError`; any other failure is `escrow_request_failed` / `escrow_response_invalid`. `verifyReceipt` checks the mutation id, request hash, escrow id, `version === newVersion`, diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index deff0452d99..2a3be6bdb05 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -12,12 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) + - Adds `getIdentifiers`, which returns the enrolled identifiers at the highest escrow version as `epoch` along with Auth's identifiers, for building `updateIdentifiers` calls ([#11149](https://github.com/MetaMask/core/pull/11149)) + - Passes the operation's AuthController token to `RecoveryEscrowProvider.generateChallenge`, and requires `RecoveryEscrowProvider.getIdentifiers` ([#11149](https://github.com/MetaMask/core/pull/11149)) - 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. - - Exports types for the recovery providers (`RecoveryAuthProvider`, `RecoveryIdentifierAuthProvider`, `RecoveryEscrowProvider`) and the pending operation encryptor (`PendingOperationEncryptor`), along with the related identifier, mutation, and recovery phase types. + - Exports types for the recovery providers (`RecoveryAuthProvider`, `RecoveryIdentifierAuthProvider`, `RecoveryEscrowProvider`) and the pending operation encryptor (`PendingOperationEncryptor`), along with the related identifier, mutation, and recovery phase types, including `GetIdentifiersResponse` and `RecoveryIdentifiers` ([#11149](https://github.com/MetaMask/core/pull/11149)) - Add support for Node.js 26 ([#10981](https://github.com/MetaMask/core/pull/10981)) ### Changed diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md index d1b3e5dd761..47af0960dbc 100644 --- a/packages/mfa-recovery-controller/README.md +++ b/packages/mfa-recovery-controller/README.md @@ -22,6 +22,10 @@ included): the controller rejects any other identifier before writing, and the mutation can then be aborted. Set `CubistEscrowProviderOptions.authAudience` to the escrow's `AUTH_CONTROLLER_AUDIENCE`. +Before `updateIdentifiers`, call `getIdentifiers()` (AAL1) for the enrolled +identifiers, their `epoch` (pass as `expectedEpoch`), and Auth's identifiers; +build the new set from Auth's entries. + ## Installation `yarn add @metamask/mfa-recovery-controller` diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts index 59e046054a5..b5863cae094 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts @@ -67,6 +67,18 @@ export type MfaRecoveryControllerGetRecoverySecretAction = { handler: MfaRecoveryController['getRecoverySecret']; }; +/** + * Reads the enrolled identifiers from available escrows at the highest + * consistent version, with Auth's current identifiers. Needs no recent MFA, + * so a new device can build an {@link updateIdentifiers} call from it. + * + * @returns Enrolled identifiers, `epoch`, and Auth's identifiers. + */ +export type MfaRecoveryControllerGetIdentifiersAction = { + type: `MfaRecoveryController:getIdentifiers`; + handler: MfaRecoveryController['getIdentifiers']; +}; + /** * Completes a persisted pending mutation, if any. */ @@ -100,6 +112,7 @@ export type MfaRecoveryControllerMethodActions = | MfaRecoveryControllerUpdateIdentifiersAction | MfaRecoveryControllerAuthenticateIdentifierAction | MfaRecoveryControllerGetRecoverySecretAction + | MfaRecoveryControllerGetIdentifiersAction | MfaRecoveryControllerResumeAction | MfaRecoveryControllerAbortAction | MfaRecoveryControllerGetPhaseAction; diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index dfeff2f26e6..94f63c754c1 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -393,9 +393,10 @@ describe('MfaRecoveryController', () => { describe('getRecoverySecret', () => { it('requests a request-bound AAL2 approval for each read', async () => { - await withController(async ({ controller, authProvider }) => { + await withController(async ({ controller, authProvider, escrowA }) => { await controller.register(SECRET, IDENTIFIERS); const authorize = vi.spyOn(authProvider, 'authorizeRecoveryRequest'); + const generateChallenge = vi.spyOn(escrowA, 'generateChallenge'); const session = await controller.authenticateIdentifier(PASSKEY); await controller.getRecoverySecret(session); expect(authorize).toHaveBeenCalledExactlyOnceWith({ @@ -403,6 +404,8 @@ describe('MfaRecoveryController', () => { audiences: ['stub-audience', 'stub-audience'], requireTwoFactor: true, }); + const { approval } = await authorize.mock.results[0].value; + expect(generateChallenge).toHaveBeenCalledExactlyOnceWith(approval); }); }); @@ -552,12 +555,15 @@ describe('MfaRecoveryController', () => { await withController(async ({ controller, escrowA }) => { const generateChallenge = vi.spyOn(escrowA, 'generateChallenge'); await controller.register(SECRET, IDENTIFIERS); + const apply = vi.spyOn(escrowA, 'applyMutation'); await controller.updateRecoverySecret( PASSKEY, SECRET_2, REGISTERED_EPOCH, ); - expect(generateChallenge).toHaveBeenCalledWith(); + expect(generateChallenge).toHaveBeenCalledExactlyOnceWith( + apply.mock.calls[0][1], + ); const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); }); @@ -759,6 +765,75 @@ describe('MfaRecoveryController', () => { }); }); + describe('getIdentifiers', () => { + it('returns the enrolled identifiers, epoch and Auth identifiers', async () => { + await withController(async ({ controller, authProvider }) => { + await controller.register(SECRET, IDENTIFIERS); + authProvider.identifiers = [...IDENTIFIERS, OTHER_PASSKEY]; + const authorize = vi.spyOn(authProvider, 'authorizeRecoveryRequest'); + expect(await controller.getIdentifiers()).toStrictEqual({ + identifiers: IDENTIFIERS, + epoch: REGISTERED_EPOCH, + authIdentifiers: authProvider.identifiers, + }); + expect(authorize).toHaveBeenCalledExactlyOnceWith({ + requestHash: await hash({ operation: 'getIdentifiers' }), + audiences: ['stub-audience', 'stub-audience'], + }); + }); + }); + + it('returns the highest version when one escrow is behind', async () => { + await withController(async ({ controller, escrowA, options }) => { + await controller.register(SECRET, IDENTIFIERS); + options.authProvider.identifiers = [...IDENTIFIERS, OTHER_PASSKEY]; + escrowA.failNextApplyCount = 1; + await expect( + controller.updateIdentifiers( + PASSKEY, + [SOCIAL, OTHER_PASSKEY], + REGISTERED_EPOCH, + ), + ).rejects.toBeInstanceOf(IncompleteMutationError); + expect(await controller.getIdentifiers()).toMatchObject({ + identifiers: [SOCIAL, OTHER_PASSKEY], + epoch: REGISTERED_EPOCH + 1, + }); + }); + }); + + it('throws when replicas at the same version disagree', async () => { + await withController(async ({ controller, escrowA }) => { + await controller.register(SECRET, IDENTIFIERS); + vi.spyOn(escrowA, 'getIdentifiers').mockResolvedValue({ + identifiers: [SOCIAL, OTHER_PASSKEY], + version: REGISTERED_EPOCH, + }); + await expect(controller.getIdentifiers()).rejects.toMatchObject({ + code: 'replica_corruption', + }); + }); + }); + + it('throws when no escrow returns identifiers', async () => { + await withController(async ({ controller }) => { + await expect(controller.getIdentifiers()).rejects.toMatchObject({ + code: 'read_failed', + }); + }); + }); + + it('throws when no escrow is available', async () => { + await withController(async ({ controller, escrowA, escrowB }) => { + escrowA.available = false; + escrowB.available = false; + await expect(controller.getIdentifiers()).rejects.toMatchObject({ + code: 'no_available_escrow', + }); + }); + }); + }); + describe('updateIdentifiers', () => { it('replaces the identifier set', async () => { await withController(async ({ controller }) => { diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 7701c5e2903..fd1af208cce 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -44,6 +44,7 @@ import { } from './pending-operation-validation.js'; import { assertAbortAllowed, getRecoveryPhase } from './state-machine.js'; import type { + AuthControllerToken, Identifier, IdentifierSession, Mutation, @@ -57,6 +58,7 @@ import type { RecoveryAuthProvider, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, + RecoveryIdentifiers, RecoveryPhase, RecoveredSecret, WrappedSecret, @@ -71,6 +73,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'updateIdentifiers', 'authenticateIdentifier', 'getRecoverySecret', + 'getIdentifiers', 'resume', 'abort', 'getPhase', @@ -391,6 +394,7 @@ export class MfaRecoveryController extends BaseController< const authorizedEscrows = await authorizeEscrowsWithToken({ escrows: available, credential: session, + authControllerToken: approval, }); const results = await Promise.allSettled( authorizedEscrows.map(async ({ escrow, authorization }) => { @@ -426,6 +430,54 @@ export class MfaRecoveryController extends BaseController< }); } + /** + * Reads the enrolled identifiers from available escrows at the highest + * consistent version, with Auth's current identifiers. Needs no recent MFA, + * so a new device can build an {@link updateIdentifiers} call from it. + * + * @returns Enrolled identifiers, `epoch`, and Auth's identifiers. + */ + async getIdentifiers(): Promise { + return await this.#withLock(async () => { + const available = await this.#getAvailableEscrows(); + if (available.length === 0) { + throw new MfaRecoveryError( + 'No escrow is available', + 'no_available_escrow', + ); + } + const { approval, identifiers: authIdentifiers } = + await this.#authProvider.authorizeRecoveryRequest({ + requestHash: await hash({ operation: 'getIdentifiers' }), + audiences: available.map((escrow) => escrow.authAudience), + }); + const responses = ( + await Promise.allSettled( + available.map( + async (escrow) => await escrow.getIdentifiers(approval), + ), + ) + ) + .filter(isFulfilledResult) + .map((result) => result.value); + if (responses.length === 0) { + throw new MfaRecoveryError( + 'No escrow returned identifiers', + 'read_failed', + ); + } + const epoch = Math.max(...responses.map(({ version }) => version)); + const highest = responses.filter(({ version }) => version === epoch); + const hashes = await Promise.all( + highest.map(async ({ identifiers }) => await hash(identifiers)), + ); + if (new Set(hashes).size !== 1) { + throw new MfaRecoveryError('Replica corruption', 'replica_corruption'); + } + return { identifiers: highest[0].identifiers, epoch, authIdentifiers }; + }); + } + /** * Completes a persisted pending mutation, if any. */ @@ -563,6 +615,7 @@ export class MfaRecoveryController extends BaseController< escrows: remainingEscrows, identifier: identifier as Identifier, requestHash: mutation.requestHash, + authControllerToken: writing.authControllerToken, }); if ( authorizedEscrows !== null && @@ -692,10 +745,12 @@ export class MfaRecoveryController extends BaseController< escrows, identifier, requestHash, + authControllerToken, }: { escrows: RecoveryEscrowProvider[]; identifier: Identifier; requestHash: string; + authControllerToken: AuthControllerToken; }): Promise { getIdentifierAuthMode(identifier.type); const credential = await requestKeyBoundIdentifierToken({ @@ -703,7 +758,11 @@ export class MfaRecoveryController extends BaseController< requestHash, identifierAuthProvider: this.#identifierAuthProvider, }); - return await authorizeEscrowsWithToken({ escrows, credential }); + return await authorizeEscrowsWithToken({ + escrows, + credential, + authControllerToken, + }); } async #payloadForEscrow( diff --git a/packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.test.ts b/packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.test.ts index 6f7fc9352d2..0a9c9afae98 100644 --- a/packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.test.ts +++ b/packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.test.ts @@ -140,17 +140,17 @@ describe('CubistEscrowProvider', () => { const { provider, invoke } = createProvider({ stdout: CHALLENGE, }); - expect(await provider.generateChallenge()).toStrictEqual(CHALLENGE); + expect(await provider.generateChallenge(TOKEN)).toStrictEqual(CHALLENGE); expect(invoke).toHaveBeenCalledWith('cubist_secret_escrow', 'latest', { - request: { cmd: 'generateChallenge' }, + request: { cmd: 'generateChallenge', authControllerToken: TOKEN }, }); }); it('invokes a caller-specified function version', async () => { const { provider, invoke } = createProvider({ version: 'v0' }); - await provider.generateChallenge(); + await provider.generateChallenge(TOKEN); expect(invoke).toHaveBeenCalledWith('cubist_secret_escrow', 'v0', { - request: { cmd: 'generateChallenge' }, + request: { cmd: 'generateChallenge', authControllerToken: TOKEN }, }); }); @@ -163,10 +163,10 @@ describe('CubistEscrowProvider', () => { ['non-integer expiry', { ...CHALLENGE, expiresAt: 1.5 }], ])('rejects a challenge that is %s', async (_label, body) => { const { provider } = createProvider({ stdout: body }); - await expect(provider.generateChallenge()).rejects.toBeInstanceOf( + await expect(provider.generateChallenge(TOKEN)).rejects.toBeInstanceOf( MfaRecoveryError, ); - await expect(provider.generateChallenge()).rejects.toMatchObject({ + await expect(provider.generateChallenge(TOKEN)).rejects.toMatchObject({ code: 'escrow_response_invalid', }); }); @@ -317,12 +317,35 @@ describe('CubistEscrowProvider', () => { }); }); + describe('getIdentifiers', () => { + const response = { identifiers: APPROVED, version: 2 }; + + it('invokes getIdentifiers and returns the enrolled identifiers', async () => { + const { provider, invoke } = createProvider({ stdout: response }); + expect(await provider.getIdentifiers(TOKEN)).toStrictEqual(response); + expect(invoke).toHaveBeenCalledWith('cubist_secret_escrow', 'latest', { + request: { cmd: 'getIdentifiers', authControllerToken: TOKEN }, + }); + }); + + it.each([ + ['missing identifiers', { version: 2 }], + ['a malformed identifier', { identifiers: [{}], version: 2 }], + ['a negative version', { ...response, version: -1 }], + ])('rejects a response with %s', async (_label, body) => { + const { provider } = createProvider({ stdout: body }); + await expect(provider.getIdentifiers(TOKEN)).rejects.toMatchObject({ + code: 'escrow_response_invalid', + }); + }); + }); + describe('request failures', () => { it('throws when the policy denies the invocation', async () => { const { provider } = createProvider({ policy: { response: 'Deny', reason: 'not allowed' }, }); - await expect(provider.generateChallenge()).rejects.toMatchObject({ + await expect(provider.generateChallenge(TOKEN)).rejects.toMatchObject({ code: 'escrow_request_failed', message: 'not allowed', }); @@ -332,7 +355,7 @@ describe('CubistEscrowProvider', () => { const { provider } = createProvider({ policy: { response: 'Error', error: 'wasm panic' }, }); - await expect(provider.generateChallenge()).rejects.toMatchObject({ + await expect(provider.generateChallenge(TOKEN)).rejects.toMatchObject({ code: 'escrow_request_failed', message: 'wasm panic', }); @@ -345,7 +368,7 @@ describe('CubistEscrowProvider', () => { stdout: 'not hex', }), }); - await expect(provider.generateChallenge()).rejects.toMatchObject({ + await expect(provider.generateChallenge(TOKEN)).rejects.toMatchObject({ code: 'escrow_response_invalid', }); }); @@ -357,7 +380,7 @@ describe('CubistEscrowProvider', () => { stdout: '', }), }); - await expect(provider.generateChallenge()).rejects.toMatchObject({ + await expect(provider.generateChallenge(TOKEN)).rejects.toMatchObject({ code: 'escrow_request_failed', message: 'not allowed', }); @@ -367,7 +390,7 @@ describe('CubistEscrowProvider', () => { const { provider } = createProvider({ stdoutBytes: new TextEncoder().encode('nope'), }); - await expect(provider.generateChallenge()).rejects.toMatchObject({ + await expect(provider.generateChallenge(TOKEN)).rejects.toMatchObject({ code: 'escrow_response_invalid', }); }); @@ -378,7 +401,7 @@ describe('CubistEscrowProvider', () => { throw new Error('offline'); }, }); - await expect(provider.generateChallenge()).rejects.toMatchObject({ + await expect(provider.generateChallenge(TOKEN)).rejects.toMatchObject({ code: 'escrow_request_failed', message: 'offline', }); @@ -392,7 +415,7 @@ describe('CubistEscrowProvider', () => { throw nonError; }, }); - await expect(provider.generateChallenge()).rejects.toMatchObject({ + await expect(provider.generateChallenge(TOKEN)).rejects.toMatchObject({ code: 'escrow_request_failed', message: 'Escrow request failed', }); diff --git a/packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.ts b/packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.ts index 97b34ef9319..8429085e0dd 100644 --- a/packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.ts +++ b/packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.ts @@ -3,7 +3,15 @@ import type { InvokePolicyResponse, Version, } from '@cubist-labs/cubesigner-sdk'; -import { integer, is, literal, min, string, type } from '@metamask/superstruct'; +import { + array, + integer, + is, + literal, + min, + string, + type, +} from '@metamask/superstruct'; import { bytesToHex, bytesToString, @@ -20,6 +28,7 @@ import { MfaRecoveryError, MutationRejectedError } from '../errors.js'; import { isMutationReceipt } from '../escrow-utils.js'; import type { AuthControllerToken, + GetIdentifiersResponse, GetRecoverySecretResponse, Identifier, IdentifierAuthorization, @@ -109,10 +118,31 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { } /** + * @param authControllerToken - Approval for the profile the challenge is for. * @returns A proof-of-possession challenge for this escrow. */ - async generateChallenge(): Promise { - return await this.#invoke('generateChallenge', {}, parseChallenge); + async generateChallenge( + authControllerToken: AuthControllerToken, + ): Promise { + return await this.#invoke( + 'generateChallenge', + { authControllerToken }, + parseChallenge, + ); + } + + /** + * @param authControllerToken - Approval for the profile to read. + * @returns The enrolled identifiers and record version. + */ + async getIdentifiers( + authControllerToken: AuthControllerToken, + ): Promise { + return await this.#invoke( + 'getIdentifiers', + { authControllerToken }, + parseIdentifiers, + ); } /** @@ -258,6 +288,13 @@ const ChallengeStruct = type({ expiresAt: integer(), }); +const IdentifiersStruct = type({ + identifiers: array( + type({ type: string(), namespace: string(), value: string() }), + ), + version: min(integer(), 0), +}); + const GetSecretResponseStruct = type({ recoverySecret: type({ ciphertext: string() }), version: min(integer(), 0), @@ -288,6 +325,16 @@ function parseChallenge(value: unknown): PoPChallenge { }; } +function parseIdentifiers(value: unknown): GetIdentifiersResponse { + if (!is(value, IdentifiersStruct)) { + throw new MfaRecoveryError( + 'Invalid identifiers response', + 'escrow_response_invalid', + ); + } + return { identifiers: value.identifiers, version: value.version }; +} + /** * Encodes a request for the escrow wire: every byte field becomes a 0x-prefixed * hex string. Typed arrays have no `toJSON`, so the replacer sees them directly. diff --git a/packages/mfa-recovery-controller/src/identifier-auth.ts b/packages/mfa-recovery-controller/src/identifier-auth.ts index 7d367cbdc70..cbc6c57e13d 100644 --- a/packages/mfa-recovery-controller/src/identifier-auth.ts +++ b/packages/mfa-recovery-controller/src/identifier-auth.ts @@ -2,6 +2,7 @@ import { generateSigningKey, hash, sign } from './crypto.js'; import { MfaRecoveryError } from './errors.js'; import { isFulfilledResult } from './escrow-utils.js'; import type { + AuthControllerToken, Identifier, IdentifierAuthMode, IdentifierAuthorization, @@ -81,18 +82,22 @@ export async function requestKeyBoundIdentifierToken({ * @param params - Escrow authorization parameters. * @param params.escrows - Escrows to authorize. * @param params.credential - Key-bound identifier credential. + * @param params.authControllerToken - Approval each escrow keys its challenge + * to. * @returns Successful escrow authorizations. */ export async function authorizeEscrowsWithToken({ escrows, credential, + authControllerToken, }: { escrows: RecoveryEscrowProvider[]; credential: KeyBoundCredential; + authControllerToken: AuthControllerToken; }): Promise { const { token, proofPrivateKey } = credential; const challengeResults = await Promise.allSettled( - escrows.map((escrow) => escrow.generateChallenge()), + escrows.map((escrow) => escrow.generateChallenge(authControllerToken)), ); const challenges = challengeResults.flatMap((result, index) => isFulfilledResult(result) diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts index daea6d7bac1..a3d433c6ed2 100644 --- a/packages/mfa-recovery-controller/src/index.ts +++ b/packages/mfa-recovery-controller/src/index.ts @@ -14,6 +14,7 @@ export type { export type { MfaRecoveryControllerAbortAction, MfaRecoveryControllerAuthenticateIdentifierAction, + MfaRecoveryControllerGetIdentifiersAction, MfaRecoveryControllerGetPhaseAction, MfaRecoveryControllerGetRecoverySecretAction, MfaRecoveryControllerRegisterAction, @@ -41,6 +42,7 @@ export { export type { AuthControllerToken, EncryptedPendingOperation, + GetIdentifiersResponse, Identifier, IdentifierAuthorization, IdentifierSession, @@ -58,6 +60,7 @@ export type { RecoveryAuthProvider, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, + RecoveryIdentifiers, RecoveryPhase, RecoveredSecret, WrappedSecret, diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index f1acc6e027b..0ce5a7460d4 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -251,6 +251,24 @@ export type RecoveredSecret = { epoch: number; }; +/** + * One escrow's enrolled identifiers and record version. + */ +export type GetIdentifiersResponse = { + identifiers: Identifier[]; + version: number; +}; + +/** + * Enrolled identifiers and `epoch`, plus Auth's current identifiers to build + * an `updateIdentifiers` set from. + */ +export type RecoveryIdentifiers = { + identifiers: Identifier[]; + epoch: number; + authIdentifiers: Identifier[]; +}; + /** * Authenticates the MetaMask profile and issues request-bound attestations. */ @@ -298,7 +316,12 @@ export type RecoveryEscrowProvider = { */ readonly wrapPublicKey: Uint8Array; isAvailable: () => Promise; - generateChallenge: () => Promise; + generateChallenge: ( + authControllerToken: AuthControllerToken, + ) => Promise; + getIdentifiers: ( + authControllerToken: AuthControllerToken, + ) => Promise; getSecret: ( authControllerToken: AuthControllerToken, authorization: IdentifierAuthorization, diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index b398a40e6e3..54f98ddcc29 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -26,6 +26,7 @@ import { } from '../src/identifier-auth.js'; import type { AuthControllerToken, + GetIdentifiersResponse, Identifier, IdentifierAuthorization, KeyBoundIdentifierToken, @@ -213,7 +214,10 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { return this.available; } - async generateChallenge(): Promise { + async generateChallenge( + authControllerToken: AuthControllerToken, + ): Promise { + this.#verifyProfileToken(authControllerToken); const challenge: StoredPoPChallenge = { id: `${this.id}-pop-${this.#popChallenges.size + 1}`, escrowId: this.id, @@ -228,6 +232,29 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { }; } + async getIdentifiers( + authControllerToken: AuthControllerToken, + ): Promise { + const record = this.#records.get( + this.#verifyProfileToken(authControllerToken), + ); + if (record === undefined) { + throw new MfaRecoveryError('No recovery record', 'not_registered'); + } + return { identifiers: record.identifiers, version: record.version }; + } + + #verifyProfileToken(authControllerToken: AuthControllerToken): string { + const claims = decodeAuthControllerToken(authControllerToken); + if (!claims || claims.ext.aal < 1 || claims.exp <= this.now()) { + throw new MfaRecoveryError( + 'Invalid profile approval', + 'invalid_profile_auth', + ); + } + return claims.sub; + } + /** * Test helper: inspect a replica's version without going through getSecret. *