Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 9 additions & 3 deletions packages/mfa-recovery-controller/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`,
Expand Down
4 changes: 3 additions & 1 deletion packages/mfa-recovery-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/mfa-recovery-controller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -100,6 +112,7 @@ export type MfaRecoveryControllerMethodActions =
| MfaRecoveryControllerUpdateIdentifiersAction
| MfaRecoveryControllerAuthenticateIdentifierAction
| MfaRecoveryControllerGetRecoverySecretAction
| MfaRecoveryControllerGetIdentifiersAction
| MfaRecoveryControllerResumeAction
| MfaRecoveryControllerAbortAction
| MfaRecoveryControllerGetPhaseAction;
Original file line number Diff line number Diff line change
Expand Up @@ -393,16 +393,19 @@ 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({
requestHash: session.token.requestHash,
audiences: ['stub-audience', 'stub-audience'],
requireTwoFactor: true,
});
const { approval } = await authorize.mock.results[0].value;
expect(generateChallenge).toHaveBeenCalledExactlyOnceWith(approval);
});
});

Expand Down Expand Up @@ -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));
});
Expand Down Expand Up @@ -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 }) => {
Expand Down
61 changes: 60 additions & 1 deletion packages/mfa-recovery-controller/src/MfaRecoveryController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from './pending-operation-validation.js';
import { assertAbortAllowed, getRecoveryPhase } from './state-machine.js';
import type {
AuthControllerToken,
Identifier,
IdentifierSession,
Mutation,
Expand All @@ -57,6 +58,7 @@ import type {
RecoveryAuthProvider,
RecoveryEscrowProvider,
RecoveryIdentifierAuthProvider,
RecoveryIdentifiers,
RecoveryPhase,
RecoveredSecret,
WrappedSecret,
Expand All @@ -71,6 +73,7 @@ const MESSENGER_EXPOSED_METHODS = [
'updateIdentifiers',
'authenticateIdentifier',
'getRecoverySecret',
'getIdentifiers',
'resume',
'abort',
'getPhase',
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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<RecoveryIdentifiers> {
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.
*/
Expand Down Expand Up @@ -563,6 +615,7 @@ export class MfaRecoveryController extends BaseController<
escrows: remainingEscrows,
identifier: identifier as Identifier,
requestHash: mutation.requestHash,
authControllerToken: writing.authControllerToken,
});
if (
authorizedEscrows !== null &&
Expand Down Expand Up @@ -692,18 +745,24 @@ export class MfaRecoveryController extends BaseController<
escrows,
identifier,
requestHash,
authControllerToken,
}: {
escrows: RecoveryEscrowProvider[];
identifier: Identifier;
requestHash: string;
authControllerToken: AuthControllerToken;
}): Promise<AuthorizedEscrow[]> {
getIdentifierAuthMode(identifier.type);
const credential = await requestKeyBoundIdentifierToken({
identifier,
requestHash,
identifierAuthProvider: this.#identifierAuthProvider,
});
return await authorizeEscrowsWithToken({ escrows, credential });
return await authorizeEscrowsWithToken({
escrows,
credential,
authControllerToken,
});
}

async #payloadForEscrow(
Expand Down
Loading
Loading