From 7b3f28c081879026cdaab96b7ea93fc5373ddece Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:31:38 +0800 Subject: [PATCH 01/55] Add `mfa-recovery-controller` package with initial implementation - Introduced the `@metamask/mfa-recovery-controller` package to manage MFA recovery flows across MetaMask clients. - Added core functionalities including methods for registering recovery secrets, updating identifiers, and handling recovery states. - Updated relevant documentation and code ownership files to include the new package. - Ensured integration with existing MetaMask infrastructure by updating configuration files and dependencies. This commit lays the groundwork for enhanced MFA recovery capabilities in MetaMask, improving user experience and security. --- .github/CODEOWNERS | 4 + README.md | 2 + codeowners.ts | 5 + packages/mfa-recovery-controller/CHANGELOG.md | 14 + packages/mfa-recovery-controller/LICENSE | 6 + .../mfa-recovery-controller/LICENSE.APACHE2 | 201 +++++ packages/mfa-recovery-controller/LICENSE.MIT | 21 + packages/mfa-recovery-controller/README.md | 21 + .../mfa-recovery-controller/jest.config.js | 26 + packages/mfa-recovery-controller/package.json | 77 ++ ...aRecoveryController-method-action-types.ts | 88 ++ .../src/MfaRecoveryController.test.ts | 744 +++++++++++++++++ .../src/MfaRecoveryController.ts | 783 ++++++++++++++++++ .../src/crypto.test.ts | 46 + .../mfa-recovery-controller/src/crypto.ts | 201 +++++ .../mfa-recovery-controller/src/errors.ts | 29 + .../src/identifier-auth.ts | 32 + packages/mfa-recovery-controller/src/index.ts | 42 + .../src/state-machine.ts | 51 ++ packages/mfa-recovery-controller/src/types.ts | 208 +++++ .../mfa-recovery-controller/tests/stubs.ts | 603 ++++++++++++++ .../tsconfig.build.json | 17 + .../mfa-recovery-controller/tsconfig.json | 15 + packages/mfa-recovery-controller/typedoc.json | 7 + teams.json | 1 + tsconfig.build.json | 7 +- tsconfig.json | 31 +- yarn.lock | 20 + 28 files changed, 3286 insertions(+), 16 deletions(-) create mode 100644 packages/mfa-recovery-controller/CHANGELOG.md create mode 100644 packages/mfa-recovery-controller/LICENSE create mode 100644 packages/mfa-recovery-controller/LICENSE.APACHE2 create mode 100644 packages/mfa-recovery-controller/LICENSE.MIT create mode 100644 packages/mfa-recovery-controller/README.md create mode 100644 packages/mfa-recovery-controller/jest.config.js create mode 100644 packages/mfa-recovery-controller/package.json create mode 100644 packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts create mode 100644 packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts create mode 100644 packages/mfa-recovery-controller/src/MfaRecoveryController.ts create mode 100644 packages/mfa-recovery-controller/src/crypto.test.ts create mode 100644 packages/mfa-recovery-controller/src/crypto.ts create mode 100644 packages/mfa-recovery-controller/src/errors.ts create mode 100644 packages/mfa-recovery-controller/src/identifier-auth.ts create mode 100644 packages/mfa-recovery-controller/src/index.ts create mode 100644 packages/mfa-recovery-controller/src/state-machine.ts create mode 100644 packages/mfa-recovery-controller/src/types.ts create mode 100644 packages/mfa-recovery-controller/tests/stubs.ts create mode 100644 packages/mfa-recovery-controller/tsconfig.build.json create mode 100644 packages/mfa-recovery-controller/tsconfig.json create mode 100644 packages/mfa-recovery-controller/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a2047d1fa8a..b3e93d7cfb8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -123,6 +123,7 @@ ## Web3Auth Team /packages/seedless-onboarding-controller @MetaMask/web3auth /packages/passkey-controller @MetaMask/web3auth +/packages/mfa-recovery-controller @MetaMask/web3auth /packages/shield-controller @MetaMask/web3auth /packages/subscription-controller @MetaMask/web3auth /packages/claims-controller @MetaMask/web3auth @@ -231,6 +232,9 @@ /packages/passkey-controller/package.json @MetaMask/web3auth @MetaMask/core-platform /packages/passkey-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform /packages/passkey-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform +/packages/mfa-recovery-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/mfa-recovery-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/mfa-recovery-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform /packages/logging-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/logging-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/logging-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform diff --git a/README.md b/README.md index 7ea1324a138..29db701941d 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/message-manager`](packages/message-manager) - [`@metamask/messenger`](packages/messenger) - [`@metamask/messenger-cli`](packages/messenger-cli) +- [`@metamask/mfa-recovery-controller`](packages/mfa-recovery-controller) - [`@metamask/money-account-api-data-service`](packages/money-account-api-data-service) - [`@metamask/money-account-balance-service`](packages/money-account-balance-service) - [`@metamask/money-account-controller`](packages/money-account-controller) @@ -197,6 +198,7 @@ linkStyle default opacity:0.5 message_manager(["@metamask/message-manager"]); messenger(["@metamask/messenger"]); messenger_cli(["@metamask/messenger-cli"]); + mfa_recovery_controller(["@metamask/mfa-recovery-controller"]); money_account_api_data_service(["@metamask/money-account-api-data-service"]); money_account_balance_service(["@metamask/money-account-balance-service"]); money_account_controller(["@metamask/money-account-controller"]); diff --git a/codeowners.ts b/codeowners.ts index b497380a2d9..62a3ae477c1 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -203,6 +203,9 @@ const PACKAGES: Record = { 'message-manager': { teams: ['@MetaMask/confirmations'], }, + 'mfa-recovery-controller': { + teams: ['@MetaMask/web3auth'], + }, messenger: { teams: ['@MetaMask/core-platform'], }, @@ -570,6 +573,7 @@ function buildTeamSections(): CodeownersSection[] { rules: [ buildRuleForPackage('seedless-onboarding-controller'), buildRuleForPackage('passkey-controller'), + buildRuleForPackage('mfa-recovery-controller'), buildRuleForPackage('shield-controller'), buildRuleForPackage('subscription-controller'), buildRuleForPackage('claims-controller'), @@ -672,6 +676,7 @@ function buildPackageReleaseSection(): CodeownersSection { 'geolocation-controller', 'keyring-controller', 'passkey-controller', + 'mfa-recovery-controller', 'logging-controller', 'message-manager', 'multichain-account-service', diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md new file mode 100644 index 00000000000..b1bd921bd9c --- /dev/null +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Add `MfaRecoveryController` with injectable `RecoveryAuthProvider` and `RecoveryEscrowProvider` interfaces, a persisted `idle` / `authorizing` / `writing` mutation state machine, and `register`, `updateRecoverySecret`, `updateIdentifiers`, `getRecoverySecret`, `resume`, and `abort` methods + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/mfa-recovery-controller/LICENSE b/packages/mfa-recovery-controller/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/mfa-recovery-controller/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/mfa-recovery-controller/LICENSE.APACHE2 b/packages/mfa-recovery-controller/LICENSE.APACHE2 new file mode 100644 index 00000000000..e6e77b08909 --- /dev/null +++ b/packages/mfa-recovery-controller/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/mfa-recovery-controller/LICENSE.MIT b/packages/mfa-recovery-controller/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/mfa-recovery-controller/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md new file mode 100644 index 00000000000..ba45a0667fc --- /dev/null +++ b/packages/mfa-recovery-controller/README.md @@ -0,0 +1,21 @@ +# `@metamask/mfa-recovery-controller` + +Manages MFA recovery flows across MetaMask clients. Auth and escrow storage are +injected so the same controller can run against different identity providers and +escrow backends. + +Pending mutations are persisted as encrypted `authorizing` / `writing` state so +a crash can `resume()` the same mutation. `abort()` is allowed only before the +first escrow write. + +## Installation + +`yarn add @metamask/mfa-recovery-controller` + +or + +`npm install @metamask/mfa-recovery-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/mfa-recovery-controller/jest.config.js b/packages/mfa-recovery-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/mfa-recovery-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/mfa-recovery-controller/package.json b/packages/mfa-recovery-controller/package.json new file mode 100644 index 00000000000..6afdc4e99cf --- /dev/null +++ b/packages/mfa-recovery-controller/package.json @@ -0,0 +1,77 @@ +{ + "name": "@metamask/mfa-recovery-controller", + "version": "0.0.0", + "description": "Manages MFA recovery flows across MetaMask clients", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/mfa-recovery-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/mfa-recovery-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/mfa-recovery-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^9.1.0", + "@metamask/messenger": "^2.0.0", + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts new file mode 100644 index 00000000000..cd20e9023cb --- /dev/null +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts @@ -0,0 +1,88 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MfaRecoveryController } from './MfaRecoveryController.js'; + +/** + * Creates version 1 of a recovery record. + * + * @param recoverySecret - Secret replicated in full to every escrow. + * @param identifiers - Ownership-approved identifier set. Must be non-empty. + */ +export type MfaRecoveryControllerRegisterAction = { + type: `MfaRecoveryController:register`; + handler: MfaRecoveryController['register']; +}; + +/** + * Replaces the recovery secret. + * + * @param identifier - Currently registered identifier used to authorize. + * @param recoverySecret - New secret. + */ +export type MfaRecoveryControllerUpdateRecoverySecretAction = { + type: `MfaRecoveryController:updateRecoverySecret`; + handler: MfaRecoveryController['updateRecoverySecret']; +}; + +/** + * Replaces the complete identifier set. + * + * @param identifier - Currently registered identifier used to authorize. + * @param identifiers - New non-empty identifier set. + */ +export type MfaRecoveryControllerUpdateIdentifiersAction = { + type: `MfaRecoveryController:updateIdentifiers`; + handler: MfaRecoveryController['updateIdentifiers']; +}; + +/** + * Reads the recovery secret from available escrows and returns the highest + * consistent version. + * + * @param identifier - Identifier used to authorize the read. + * @returns Recovered secret bytes. + */ +export type MfaRecoveryControllerGetRecoverySecretAction = { + type: `MfaRecoveryController:getRecoverySecret`; + handler: MfaRecoveryController['getRecoverySecret']; +}; + +/** + * Completes a persisted pending mutation, if any. + */ +export type MfaRecoveryControllerResumeAction = { + type: `MfaRecoveryController:resume`; + handler: MfaRecoveryController['resume']; +}; + +/** + * Drops a mutation that has not yet begun writing. Writing mutations must be + * resumed instead. + */ +export type MfaRecoveryControllerAbortAction = { + type: `MfaRecoveryController:abort`; + handler: MfaRecoveryController['abort']; +}; + +/** + * @returns Current recovery phase. + */ +export type MfaRecoveryControllerGetPhaseAction = { + type: `MfaRecoveryController:getPhase`; + handler: MfaRecoveryController['getPhase']; +}; + +/** + * Union of all MfaRecoveryController action types. + */ +export type MfaRecoveryControllerMethodActions = + | MfaRecoveryControllerRegisterAction + | MfaRecoveryControllerUpdateRecoverySecretAction + | MfaRecoveryControllerUpdateIdentifiersAction + | MfaRecoveryControllerGetRecoverySecretAction + | MfaRecoveryControllerResumeAction + | MfaRecoveryControllerAbortAction + | MfaRecoveryControllerGetPhaseAction; diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts new file mode 100644 index 00000000000..c6e17f2c2a8 --- /dev/null +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -0,0 +1,744 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { bytesToHex } from '@metamask/utils'; + +import { + StubAuthProvider, + StubEscrowProvider, + StubIdentifierAuthProvider, + passthroughEncryptor, +} from '../tests/stubs.js'; +import { hash } from './crypto.js'; +import { MutationRepairPendingError } from './errors.js'; +import type { + MfaRecoveryControllerMessenger, + MfaRecoveryControllerOptions, +} from './MfaRecoveryController.js'; +import { MfaRecoveryController } from './MfaRecoveryController.js'; +import type { Identifier } from './types.js'; + +const PASSKEY: Identifier = { + type: 'passkey', + namespace: 'example.com', + value: 'cred-1', + verifier: { publicKey: 'pk' }, +}; + +const EMAIL: Identifier = { + type: 'emailOtp', + namespace: 'email', + value: 'user@example.com', + verifier: null, +}; + +const SECRET = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); +const SECRET_2 = new Uint8Array([8, 7, 6, 5, 4, 3, 2, 1]); + +describe('MfaRecoveryController', () => { + describe('constructor', () => { + it('fills in missing initial state with defaults', async () => { + await withController(({ controller }) => { + expect(controller.state).toMatchInlineSnapshot(` + { + "pendingOperation": null, + } + `); + }); + }); + + it('throws if no escrows are configured', () => { + const rootMessenger = getRootMessenger(); + expect( + () => + new MfaRecoveryController({ + messenger: getMessenger(rootMessenger), + authProvider: new StubAuthProvider(), + identifierAuthProvider: new StubIdentifierAuthProvider(), + escrows: [], + pendingOperationEncryptor: passthroughEncryptor, + collectChallengeResponse: async (): Promise => 'otp', + }), + ).toThrow('At least one escrow is required'); + }); + + it('throws if escrow ids are duplicated', () => { + const rootMessenger = getRootMessenger(); + expect( + () => + new MfaRecoveryController({ + messenger: getMessenger(rootMessenger), + authProvider: new StubAuthProvider(), + identifierAuthProvider: new StubIdentifierAuthProvider(), + escrows: [ + new StubEscrowProvider('escrow-a'), + new StubEscrowProvider('escrow-a'), + ], + pendingOperationEncryptor: passthroughEncryptor, + collectChallengeResponse: async (): Promise => 'otp', + }), + ).toThrow('Duplicate escrow id'); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + }); + + it('includes expected state in state logs', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`{}`); + }); + }); + + it('persists expected state', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "pendingOperation": null, + } + `); + }); + }); + + it('exposes expected state to UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "pendingOperation": null, + } + `); + }); + }); + }); + + describe('register', () => { + it('replicates the recovery secret to every escrow', async () => { + await withController(async ({ controller }) => { + await controller.register(SECRET, [PASSKEY]); + expect(controller.state.pendingOperation).toBeNull(); + expect(await controller.getPhase()).toBe('idle'); + + const recovered = await controller.getRecoverySecret(PASSKEY); + expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + }); + }); + + it('rejects an empty identifier list', async () => { + await withController(async ({ controller }) => { + await expect(controller.register(SECRET, [])).rejects.toThrow( + 'Registration requires at least one identifier', + ); + }); + }); + + it('rejects a second registration for the same profile', async () => { + await withController(async ({ controller }) => { + await controller.register(SECRET, [PASSKEY]); + await expect(controller.register(SECRET, [PASSKEY])).rejects.toThrow( + 'Profile is already registered', + ); + }); + }); + }); + + describe('getRecoverySecret', () => { + it('returns the highest consistent version when one escrow is down', async () => { + await withController(async ({ controller, escrowB }) => { + await controller.register(SECRET, [PASSKEY]); + escrowB.available = false; + const recovered = await controller.getRecoverySecret(PASSKEY); + expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + }); + }); + + it('throws when matching versions disagree', async () => { + await withController(async ({ controller, escrowB }) => { + await controller.register(SECRET, [PASSKEY]); + escrowB.patchRecord('profile-1', { + lastMutationId: '0xdifferent', + }); + await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( + 'Replica corruption', + ); + }); + }); + + it('throws when no escrow is available', async () => { + await withController(async ({ controller, escrowA, escrowB }) => { + escrowA.available = false; + escrowB.available = false; + await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( + 'No escrow is available', + ); + }); + }); + + it('throws when every available escrow fails the read', async () => { + await withController(async ({ controller, escrowA, escrowB }) => { + await controller.register(SECRET, [PASSKEY]); + escrowA.failGetSecret = true; + escrowB.failGetSecret = true; + await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( + 'No escrow returned a recovery secret', + ); + }); + }); + }); + + describe('updateRecoverySecret', () => { + it('replaces the secret at every escrow', async () => { + await withController(async ({ controller }) => { + await controller.register(SECRET, [PASSKEY]); + await controller.updateRecoverySecret(PASSKEY, SECRET_2); + const recovered = await controller.getRecoverySecret(PASSKEY); + expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET_2)); + }); + }); + + it('throws when a configured escrow is unavailable', async () => { + await withController(async ({ controller, escrowB }) => { + await controller.register(SECRET, [PASSKEY]); + escrowB.available = false; + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2), + ).rejects.toThrow('All configured escrows are required for mutation'); + }); + }); + + it('throws when replicas disagree on version', async () => { + await withController(async ({ controller, escrowB }) => { + await controller.register(SECRET, [PASSKEY]); + escrowB.patchRecord('profile-1', { version: 2 }); + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2), + ).rejects.toThrow('Escrows disagree on recovery version'); + }); + }); + + it('throws when a replica is missing the recovery record', async () => { + await withController(async ({ controller, escrowB }) => { + await controller.register(SECRET, [PASSKEY]); + escrowB.clearRecord('profile-1'); + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2), + ).rejects.toThrow('Recovery record missing at a configured escrow'); + }); + }); + }); + + describe('updateIdentifiers', () => { + it('replaces the identifier set using escrow-challenge auth', async () => { + await withController(async ({ controller }) => { + await controller.register(SECRET, [PASSKEY, EMAIL]); + await controller.updateIdentifiers(EMAIL, [EMAIL]); + const recovered = await controller.getRecoverySecret(EMAIL); + expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + }); + }); + + it('rejects an empty identifier list', async () => { + await withController(async ({ controller }) => { + await controller.register(SECRET, [PASSKEY]); + await expect(controller.updateIdentifiers(PASSKEY, [])).rejects.toThrow( + 'Identifier list must be non-empty', + ); + }); + }); + }); + + describe('resume and abort', () => { + it('resumes a persisted writing mutation after a replica failure', async () => { + await withController(async ({ controller, escrowA }) => { + await controller.register(SECRET, [PASSKEY]); + escrowA.failNextApplyCount = 1; + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2), + ).rejects.toBeInstanceOf(MutationRepairPendingError); + expect(await controller.getPhase()).toBe('writing'); + + await controller.resume(); + expect(await controller.getPhase()).toBe('idle'); + const recovered = await controller.getRecoverySecret(PASSKEY); + expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET_2)); + }); + }); + + it('aborts an authorizing mutation and refuses to abort writing', async () => { + await withController(async ({ options }) => { + const authorizing = await passthroughEncryptor.encrypt({ + phase: 'authorizing', + mutation: { + id: '0xmut', + profileId: 'profile-1', + operation: 'register', + expectedVersion: 0, + newVersion: 1, + payloadHash: '0x', + audiences: ['escrow-a', 'escrow-b'], + requestHash: '0x', + }, + payload: { + recoverySecret: bytesToHex(SECRET), + identifiers: [PASSKEY], + }, + identifier: null, + }); + const authorizingMessenger = getMessenger(getRootMessenger()); + const idleController = new MfaRecoveryController({ + ...options, + messenger: authorizingMessenger, + state: { pendingOperation: authorizing }, + }); + expect(await idleController.getPhase()).toBe('authorizing'); + await idleController.abort(); + expect(await idleController.getPhase()).toBe('idle'); + + const writing = await passthroughEncryptor.encrypt({ + phase: 'writing', + mutation: { + id: '0xmut', + profileId: 'profile-1', + operation: 'register', + expectedVersion: 0, + newVersion: 1, + payloadHash: '0x', + audiences: ['escrow-a', 'escrow-b'], + requestHash: '0x', + }, + authControllerToken: { + profileId: 'profile-1', + requestHash: '0x', + issuer: 'stub-auth', + expiresAt: Date.now() + 60_000, + signature: 'stub-auth-signature', + }, + payload: { + recoverySecret: bytesToHex(SECRET), + identifiers: [PASSKEY], + }, + identifier: null, + receipts: [], + }); + const writingMessenger = getMessenger(getRootMessenger()); + const writingController = new MfaRecoveryController({ + ...options, + messenger: writingMessenger, + state: { pendingOperation: writing }, + }); + await expect(writingController.abort()).rejects.toThrow( + 'Cannot abort a mutation once writing has begun', + ); + expect(await writingController.getPhase()).toBe('writing'); + }); + }); + + it('resumes an authorizing mutation', async () => { + await withController(async ({ options }) => { + const payload = { + recoverySecret: bytesToHex(SECRET), + identifiers: [PASSKEY], + }; + const payloadHash = await hash(payload); + const fields = { + id: '0xmutauth', + profileId: 'profile-1', + operation: 'register' as const, + expectedVersion: 0, + newVersion: 1, + payloadHash, + audiences: ['escrow-a', 'escrow-b'], + }; + const mutation = { + ...fields, + requestHash: await hash(fields), + }; + const authorizing = await passthroughEncryptor.encrypt({ + phase: 'authorizing', + mutation, + payload, + identifier: null, + }); + const controller = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: authorizing }, + }); + await controller.resume(); + expect(await controller.getPhase()).toBe('idle'); + expect(bytesToHex(await controller.getRecoverySecret(PASSKEY))).toBe( + bytesToHex(SECRET), + ); + }); + }); + + it('is a no-op to resume or abort when idle', async () => { + await withController(async ({ controller }) => { + await controller.resume(); + await controller.abort(); + expect(await controller.getPhase()).toBe('idle'); + }); + }); + + it('clears a writing mutation that already has every receipt', async () => { + await withController(async ({ controller, options }) => { + await controller.register(SECRET, [PASSKEY]); + const mutation = { + id: '0xmut', + profileId: 'profile-1', + operation: 'updateRecoverySecret' as const, + expectedVersion: 1, + newVersion: 2, + payloadHash: await hash({ + recoverySecret: bytesToHex(SECRET_2), + }), + audiences: ['escrow-a', 'escrow-b'], + requestHash: '0x', + }; + mutation.requestHash = await hash({ + id: mutation.id, + profileId: mutation.profileId, + operation: mutation.operation, + expectedVersion: mutation.expectedVersion, + newVersion: mutation.newVersion, + payloadHash: mutation.payloadHash, + audiences: mutation.audiences, + }); + const receipts = [ + { + mutationId: mutation.id, + requestHash: mutation.requestHash, + escrowId: 'escrow-a', + version: 2, + signature: '0x', + }, + { + mutationId: mutation.id, + requestHash: mutation.requestHash, + escrowId: 'escrow-b', + version: 2, + signature: '0x', + }, + ]; + const writing = await passthroughEncryptor.encrypt({ + phase: 'writing', + mutation, + authControllerToken: { + profileId: 'profile-1', + requestHash: mutation.requestHash, + twoFactor: true, + issuer: 'stub-auth', + expiresAt: Date.now() + 60_000, + signature: 'stub-auth-signature', + }, + payload: { recoverySecret: bytesToHex(SECRET_2) }, + identifier: PASSKEY, + receipts, + }); + const resumed = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: writing }, + }); + await resumed.resume(); + expect(await resumed.getPhase()).toBe('idle'); + }); + }); + + it('rejects a persisted mutation with the wrong escrow audience', async () => { + await withController(async ({ options }) => { + const writing = await passthroughEncryptor.encrypt({ + phase: 'authorizing', + mutation: { + id: '0xmut', + profileId: 'profile-1', + operation: 'register', + expectedVersion: 0, + newVersion: 1, + payloadHash: '0x', + audiences: ['escrow-other'], + requestHash: '0x', + }, + payload: { + recoverySecret: bytesToHex(SECRET), + identifiers: [PASSKEY], + }, + identifier: null, + }); + const controller = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: writing }, + }); + await expect(controller.resume()).rejects.toThrow( + 'Persisted mutation audiences do not match configured escrows', + ); + }); + }); + + it('rejects a pending payload that does not match the mutation hash', async () => { + await withController(async ({ options }) => { + const writing = await passthroughEncryptor.encrypt({ + phase: 'writing', + mutation: { + id: '0xmut', + profileId: 'profile-1', + operation: 'register', + expectedVersion: 0, + newVersion: 1, + payloadHash: '0xdead', + audiences: ['escrow-a', 'escrow-b'], + requestHash: '0x', + }, + authControllerToken: { + profileId: 'profile-1', + requestHash: '0x', + issuer: 'stub-auth', + expiresAt: Date.now() + 60_000, + signature: 'stub-auth-signature', + }, + payload: { + recoverySecret: bytesToHex(SECRET), + identifiers: [PASSKEY], + }, + identifier: null, + receipts: [], + }); + const controller = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: writing }, + }); + await expect(controller.resume()).rejects.toThrow( + 'Pending payload does not match mutation', + ); + }); + }); + + it('rejects a receipt from an unknown escrow', async () => { + await withController(async ({ options }) => { + const payload = { + recoverySecret: bytesToHex(SECRET), + identifiers: [PASSKEY], + }; + const payloadHash = await hash(payload); + const writing = await passthroughEncryptor.encrypt({ + phase: 'writing', + mutation: { + id: '0xmut', + profileId: 'profile-1', + operation: 'register', + expectedVersion: 0, + newVersion: 1, + payloadHash, + audiences: ['escrow-a', 'escrow-b'], + requestHash: '0x', + }, + authControllerToken: { + profileId: 'profile-1', + requestHash: '0x', + issuer: 'stub-auth', + expiresAt: Date.now() + 60_000, + signature: 'stub-auth-signature', + }, + payload, + identifier: null, + receipts: [ + { + mutationId: '0xmut', + requestHash: '0x', + escrowId: 'escrow-z', + version: 1, + signature: '0x', + }, + ], + }); + const controller = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: writing }, + }); + await expect(controller.resume()).rejects.toThrow( + 'Receipt escrow is not configured', + ); + }); + }); + + it('rejects an invalid persisted receipt', async () => { + await withController(async ({ options, escrowA }) => { + escrowA.invalidReceipts = true; + const payload = { + recoverySecret: bytesToHex(SECRET), + identifiers: [PASSKEY], + }; + const payloadHash = await hash(payload); + const writing = await passthroughEncryptor.encrypt({ + phase: 'writing', + mutation: { + id: '0xmut', + profileId: 'profile-1', + operation: 'register', + expectedVersion: 0, + newVersion: 1, + payloadHash, + audiences: ['escrow-a', 'escrow-b'], + requestHash: '0x', + }, + authControllerToken: { + profileId: 'profile-1', + requestHash: '0x', + issuer: 'stub-auth', + expiresAt: Date.now() + 60_000, + signature: 'stub-auth-signature', + }, + payload, + identifier: null, + receipts: [ + { + mutationId: '0xmut', + requestHash: '0x', + escrowId: 'escrow-a', + version: 1, + signature: '0x', + }, + ], + }); + const controller = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: writing }, + }); + await expect(controller.resume()).rejects.toThrow( + 'Invalid mutation receipt', + ); + }); + }); + + it('rejects a newly issued invalid receipt', async () => { + await withController(async ({ controller, escrowA }) => { + escrowA.invalidReceipts = true; + await expect(controller.register(SECRET, [PASSKEY])).rejects.toThrow( + 'Invalid mutation receipt', + ); + }); + }); + + it('refreshes an expired AuthController token while resuming', async () => { + await withController(async ({ controller, escrowA, options }) => { + await controller.register(SECRET, [PASSKEY]); + escrowA.failNextApplyCount = 1; + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2), + ).rejects.toBeInstanceOf(MutationRepairPendingError); + const pending = JSON.parse( + controller.state.pendingOperation as string, + ) as { authControllerToken: { expiresAt: number } }; + pending.authControllerToken.expiresAt = 0; + const resumed = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: JSON.stringify(pending) }, + }); + await resumed.resume(); + expect(await resumed.getPhase()).toBe('idle'); + }); + }); + }); + + describe('identifier registry', () => { + it('rejects unknown identifier types', async () => { + await withController(async ({ controller }) => { + await expect( + controller.register(SECRET, [ + { type: 'unknown', namespace: 'x', value: 'y', verifier: null }, + ]), + ).rejects.toThrow('Unknown identifier type'); + }); + }); + }); +}); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type WithControllerCallback = (payload: { + controller: MfaRecoveryController; + rootMessenger: RootMessenger; + controllerMessenger: MfaRecoveryControllerMessenger; + escrowA: StubEscrowProvider; + escrowB: StubEscrowProvider; + options: MfaRecoveryControllerOptions; +}) => Promise | ReturnValue; + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function getMessenger( + rootMessenger: RootMessenger, +): MfaRecoveryControllerMessenger { + return new Messenger({ + namespace: 'MfaRecoveryController', + parent: rootMessenger, + }); +} + +async function withController( + testFunction: WithControllerCallback, +): Promise { + const rootMessenger = getRootMessenger(); + const controllerMessenger = getMessenger(rootMessenger); + const escrowA = new StubEscrowProvider('escrow-a'); + const escrowB = new StubEscrowProvider('escrow-b'); + const options: MfaRecoveryControllerOptions = { + messenger: controllerMessenger, + authProvider: new StubAuthProvider(), + identifierAuthProvider: new StubIdentifierAuthProvider(), + escrows: [escrowA, escrowB], + pendingOperationEncryptor: passthroughEncryptor, + collectChallengeResponse: async (): Promise => 'otp', + }; + const controller = new MfaRecoveryController(options); + return await testFunction({ + controller, + rootMessenger, + controllerMessenger, + escrowA, + escrowB, + options, + }); +} diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts new file mode 100644 index 00000000000..caecc457634 --- /dev/null +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -0,0 +1,783 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import { areUint8ArraysEqual } from '@metamask/utils'; + +import { + bytesToSecretHex, + generateSigningKey, + hash, + randomId, + sign, +} from './crypto.js'; +import { MfaRecoveryError, MutationRepairPendingError } from './errors.js'; +import { getIdentifierAuthMode } from './identifier-auth.js'; +import type { MfaRecoveryControllerMethodActions } from './MfaRecoveryController-method-action-types.js'; +import { + assertAbortAllowed, + assertSameAudienceIds, + getRecoveryPhase, +} from './state-machine.js'; +import type { + AuthControllerToken, + EscrowAuthChallenge, + Identifier, + IdentifierAuthorization, + Mutation, + MutationPayload, + MutationReceipt, + PendingOperation, + PendingOperationEncryptor, + RecoveryAuthProvider, + RecoveryEscrowProvider, + RecoveryIdentifierAuthProvider, + RecoveryPhase, + RegisterPayload, + UpdateIdentifiersPayload, + WritingPendingOperation, +} from './types.js'; + +export type { + AuthControllerToken, + EncryptedPendingOperation, + Identifier, + IdentifierAuthorization, + Mutation, + MutationPayload, + MutationReceipt, + PendingOperation, + PendingOperationEncryptor, + RecoveryAuthProvider, + RecoveryEscrowProvider, + RecoveryIdentifierAuthProvider, + RecoveryPhase, +} from './types.js'; + +const CONTROLLER_NAME = 'MfaRecoveryController'; + +const MESSENGER_EXPOSED_METHODS = [ + 'register', + 'updateRecoverySecret', + 'updateIdentifiers', + 'getRecoverySecret', + 'resume', + 'abort', + 'getPhase', +] as const; + +export type MfaRecoveryControllerState = { + /** + * Encrypted pending mutation, or `null` when idle. + */ + pendingOperation: string | null; +}; + +const mfaRecoveryControllerMetadata = { + pendingOperation: { + persist: true, + includeInDebugSnapshot: false, + includeInStateLogs: false, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * @returns The default {@link MfaRecoveryController} state. + */ +export function getDefaultMfaRecoveryControllerState(): MfaRecoveryControllerState { + return { pendingOperation: null }; +} + +export type MfaRecoveryControllerGetStateAction = ControllerGetStateAction< + typeof CONTROLLER_NAME, + MfaRecoveryControllerState +>; + +export type MfaRecoveryControllerActions = + | MfaRecoveryControllerGetStateAction + | MfaRecoveryControllerMethodActions; + +type AllowedActions = never; + +export type MfaRecoveryControllerStateChangeEvent = ControllerStateChangeEvent< + typeof CONTROLLER_NAME, + MfaRecoveryControllerState +>; + +export type MfaRecoveryControllerEvents = MfaRecoveryControllerStateChangeEvent; + +type AllowedEvents = never; + +export type MfaRecoveryControllerMessenger = Messenger< + typeof CONTROLLER_NAME, + MfaRecoveryControllerActions | AllowedActions, + MfaRecoveryControllerEvents | AllowedEvents +>; + +export type MfaRecoveryControllerOptions = { + /** + * The messenger suited for this controller. + */ + messenger: MfaRecoveryControllerMessenger; + + /** + * The desired state with which to initialize this controller. Missing + * properties will be filled in with defaults. + */ + state?: Partial; + + /** + * Authenticates the MetaMask profile and issues request-bound attestations. + */ + authProvider: RecoveryAuthProvider; + + /** + * Obtains key-bound identifier tokens from an IdP. + */ + identifierAuthProvider: RecoveryIdentifierAuthProvider; + + /** + * Ordered, non-empty set of recovery escrow replicas. This is build-time + * configuration; public methods cannot override it. + */ + escrows: RecoveryEscrowProvider[]; + + /** + * Encrypts pending mutation state using wallet secure storage. + */ + pendingOperationEncryptor: PendingOperationEncryptor; + + /** + * Collects a provider-specific response for an escrow-challenge identifier + * (for example an Email/SMS OTP). + */ + collectChallengeResponse: ( + challenge: EscrowAuthChallenge, + ) => Promise; + + /** + * Clock used for AuthController token expiry checks. Defaults to `Date.now`. + */ + now?: () => number; +}; + +/** + * Coordinates MFA recovery secret replication across independent escrows. + */ +export class MfaRecoveryController extends BaseController< + typeof CONTROLLER_NAME, + MfaRecoveryControllerState, + MfaRecoveryControllerMessenger +> { + readonly #authProvider: RecoveryAuthProvider; + + readonly #identifierAuthProvider: RecoveryIdentifierAuthProvider; + + readonly #escrows: RecoveryEscrowProvider[]; + + readonly #encryptor: PendingOperationEncryptor; + + readonly #collectChallengeResponse: ( + challenge: EscrowAuthChallenge, + ) => Promise; + + readonly #now: () => number; + + #lock: Promise = Promise.resolve(); + + constructor({ + messenger, + state, + authProvider, + identifierAuthProvider, + escrows, + pendingOperationEncryptor, + collectChallengeResponse, + now = (): number => Date.now(), + }: MfaRecoveryControllerOptions) { + if (escrows.length === 0) { + throw new MfaRecoveryError( + 'At least one escrow is required', + 'empty_escrow_set', + ); + } + const ids = escrows.map((escrow) => escrow.id); + if (new Set(ids).size !== ids.length) { + throw new MfaRecoveryError('Duplicate escrow id', 'duplicate_escrow_id'); + } + + super({ + messenger, + metadata: mfaRecoveryControllerMetadata, + name: CONTROLLER_NAME, + state: { + ...getDefaultMfaRecoveryControllerState(), + ...state, + }, + }); + + this.#authProvider = authProvider; + this.#identifierAuthProvider = identifierAuthProvider; + this.#escrows = escrows; + this.#encryptor = pendingOperationEncryptor; + this.#collectChallengeResponse = collectChallengeResponse; + this.#now = now; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Creates version 1 of a recovery record. + * + * @param recoverySecret - Secret replicated in full to every escrow. + * @param identifiers - Ownership-approved identifier set. Must be non-empty. + */ + async register( + recoverySecret: Uint8Array, + identifiers: Identifier[], + ): Promise { + this.#assertKnownIdentifierTypes(identifiers); + if (identifiers.length === 0) { + throw new MfaRecoveryError( + 'Registration requires at least one identifier', + 'empty_identifiers', + ); + } + await this.#runRecoveryMutation({ + operation: 'register', + payload: { + recoverySecret: bytesToSecretHex(recoverySecret), + identifiers, + }, + identifier: null, + }); + } + + /** + * Replaces the recovery secret. + * + * @param identifier - Currently registered identifier used to authorize. + * @param recoverySecret - New secret. + */ + async updateRecoverySecret( + identifier: Identifier, + recoverySecret: Uint8Array, + ): Promise { + this.#assertKnownIdentifierTypes([identifier]); + await this.#runRecoveryMutation({ + operation: 'updateRecoverySecret', + payload: { recoverySecret: bytesToSecretHex(recoverySecret) }, + identifier, + }); + } + + /** + * Replaces the complete identifier set. + * + * @param identifier - Currently registered identifier used to authorize. + * @param identifiers - New non-empty identifier set. + */ + async updateIdentifiers( + identifier: Identifier, + identifiers: Identifier[], + ): Promise { + this.#assertKnownIdentifierTypes([identifier, ...identifiers]); + if (identifiers.length === 0) { + throw new MfaRecoveryError( + 'Identifier list must be non-empty', + 'empty_identifiers', + ); + } + await this.#runRecoveryMutation({ + operation: 'updateIdentifiers', + payload: { identifiers }, + identifier, + }); + } + + /** + * Reads the recovery secret from available escrows and returns the highest + * consistent version. + * + * @param identifier - Identifier used to authorize the read. + * @returns Recovered secret bytes. + */ + async getRecoverySecret(identifier: Identifier): Promise { + return await this.#withLock(async () => { + this.#assertKnownIdentifierTypes([identifier]); + const requestId = randomId(); + const requestHash = await hash({ + operation: 'getRecoverySecret', + requestId, + }); + const available = await this.#getAvailableEscrows(); + if (available.length === 0) { + throw new MfaRecoveryError( + 'No escrow is available', + 'no_available_escrow', + ); + } + const authorizations = await this.#authorizeIdentifier({ + escrows: available, + identifier, + requestHash, + }); + const results = await Promise.allSettled( + available.map(async (escrow, index) => ({ + escrowId: escrow.id, + ...(await escrow.getSecret(authorizations[index], requestId)), + })), + ); + return this.#selectHighestConsistentVersion(results).recoverySecret; + }); + } + + /** + * Completes a persisted pending mutation, if any. + */ + async resume(): Promise { + await this.#withLock(async () => { + await this.#repairPendingMutation(this.#escrows); + }); + } + + /** + * Drops a mutation that has not yet begun writing. Writing mutations must be + * resumed instead. + */ + async abort(): Promise { + await this.#withLock(async () => { + const pending = await this.#loadPending(); + assertAbortAllowed(getRecoveryPhase(pending)); + if (pending) { + await this.#clearPending(); + } + }); + } + + /** + * @returns Current recovery phase. + */ + async getPhase(): Promise { + return getRecoveryPhase(await this.#loadPending()); + } + + async #runRecoveryMutation({ + operation, + payload, + identifier, + }: { + operation: Mutation['operation']; + payload: MutationPayload; + identifier: Identifier | null; + }): Promise { + await this.#withLock(async () => { + const configured = this.#escrows; + await this.#repairPendingMutation(configured); + const profileId = await this.#authProvider.getAuthenticatedProfileId(); + const targets = await this.#requireAllEscrows(configured); + const audiences = configured.map((escrow) => escrow.id); + const currentVersion = await this.#resolveCurrentRecoveryVersion({ + operation, + profileId, + targets, + }); + const payloadHash = await hash(payload); + const mutation = await this.#createMutation({ + id: randomId(), + profileId, + operation, + expectedVersion: currentVersion, + newVersion: currentVersion + 1, + payloadHash, + audiences, + }); + + await this.#persistPending({ + phase: 'authorizing', + mutation, + payload, + identifier, + }); + + const authControllerToken = await this.#authorizeMutation( + mutation, + payload, + ); + const pending: WritingPendingOperation = { + phase: 'writing', + mutation, + authControllerToken, + payload, + identifier, + receipts: [], + }; + await this.#persistPending(pending); + await this.#replicateMutation({ escrows: targets, pending }); + }); + } + + async #createMutation( + fields: Omit, + ): Promise { + return { + ...fields, + requestHash: await hash({ + id: fields.id, + profileId: fields.profileId, + operation: fields.operation, + expectedVersion: fields.expectedVersion, + newVersion: fields.newVersion, + payloadHash: fields.payloadHash, + audiences: fields.audiences, + }), + }; + } + + async #authorizeMutation( + mutation: Mutation, + payload: MutationPayload, + ): Promise { + const identifiers = + mutation.operation === 'register' || + mutation.operation === 'updateIdentifiers' + ? (payload as RegisterPayload | UpdateIdentifiersPayload).identifiers + : undefined; + return await this.#authProvider.authorizeRecoveryRequest({ + requestHash: mutation.requestHash, + ...(mutation.operation === 'register' ? {} : { requireTwoFactor: true }), + ...(identifiers === undefined ? {} : { identifiers }), + }); + } + + async #repairPendingMutation( + escrows: RecoveryEscrowProvider[], + ): Promise { + const saved = await this.#loadPending(); + if (!saved) { + return; + } + assertSameAudienceIds( + saved.mutation.audiences, + escrows.map((escrow) => escrow.id), + ); + const authControllerToken = + saved.phase === 'authorizing' || + this.#tokenNeedsRefresh(saved.authControllerToken) + ? await this.#authorizeMutation(saved.mutation, saved.payload) + : saved.authControllerToken; + const pending: WritingPendingOperation = { + phase: 'writing', + mutation: saved.mutation, + authControllerToken, + payload: saved.payload, + identifier: saved.identifier, + receipts: saved.phase === 'writing' ? saved.receipts : [], + }; + await this.#persistPending(pending); + await this.#replicateMutation({ + escrows: await this.#requireAllEscrows(escrows), + pending, + }); + } + + async #replicateMutation({ + escrows, + pending, + }: { + escrows: RecoveryEscrowProvider[]; + pending: WritingPendingOperation; + }): Promise { + const { mutation, payload, identifier } = pending; + const expectedPayloadHash = await hash(payload); + if (expectedPayloadHash !== mutation.payloadHash) { + throw new MfaRecoveryError( + 'Pending payload does not match mutation', + 'payload_mismatch', + ); + } + const configuredIds = new Set(escrows.map((escrow) => escrow.id)); + pending.receipts.forEach((receipt) => { + const escrow = escrows.find((item) => item.id === receipt.escrowId); + if (!configuredIds.has(receipt.escrowId) || escrow === undefined) { + throw new MfaRecoveryError( + 'Receipt escrow is not configured', + 'unknown_receipt_escrow', + ); + } + if (!escrow.verifyReceipt(receipt, mutation)) { + throw new MfaRecoveryError( + 'Invalid mutation receipt', + 'invalid_receipt', + ); + } + }); + const acknowledged = new Set( + pending.receipts.map((receipt) => receipt.escrowId), + ); + const targets = escrows.filter((escrow) => !acknowledged.has(escrow.id)); + if (targets.length === 0) { + await this.#clearPending(); + return; + } + const authorizations = + mutation.operation === 'register' + ? targets.map(() => null) + : await this.#authorizeIdentifier({ + escrows: targets, + identifier: identifier as Identifier, + requestHash: mutation.requestHash, + }); + const results = await Promise.allSettled( + targets.map((escrow, index) => + escrow.applyMutation( + mutation, + pending.authControllerToken, + authorizations[index], + payload, + ), + ), + ); + + const receipts: MutationReceipt[] = [...pending.receipts]; + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + const escrow = targets[index]; + if (!escrow.verifyReceipt(result.value, mutation)) { + throw new MfaRecoveryError( + 'Invalid mutation receipt', + 'invalid_receipt', + ); + } + receipts.push(result.value); + } + }); + await this.#persistPending({ ...pending, receipts }); + + if ( + new Set(receipts.map((receipt) => receipt.escrowId)).size !== + escrows.length + ) { + throw new MutationRepairPendingError(mutation.id); + } + await this.#clearPending(); + } + + async #authorizeIdentifier({ + escrows, + identifier, + requestHash, + }: { + escrows: RecoveryEscrowProvider[]; + identifier: Identifier; + requestHash: string; + }): Promise { + const mode = getIdentifierAuthMode(identifier.type); + if (mode === 'key-bound') { + const proofKey = await generateSigningKey(); + const token = + await this.#identifierAuthProvider.getKeyBoundIdentifierToken({ + identifier, + proofPublicKey: proofKey.publicKey, + requestHash, + }); + const challenges = await Promise.all( + escrows.map((escrow) => escrow.generateChallenge()), + ); + return await Promise.all( + challenges.map(async (challenge) => { + const message = await hash([token, challenge.id, requestHash]); + return { + kind: 'key-bound' as const, + token, + proof: { + challengeId: challenge.id, + requestHash, + signature: await sign(proofKey.privateKey, message), + }, + }; + }), + ); + } + + const challenges = await Promise.all( + escrows.map((escrow) => + escrow.beginIdentifierAuthentication({ identifier, requestHash }), + ), + ); + const responses = await Promise.all( + challenges.map((challenge) => this.#collectChallengeResponse(challenge)), + ); + const grants = await Promise.all( + escrows.map((escrow, index) => + escrow.completeIdentifierAuthentication( + challenges[index].id, + responses[index], + ), + ), + ); + return grants.map((grant) => ({ + kind: 'escrow-challenge' as const, + grant, + })); + } + + async #resolveCurrentRecoveryVersion({ + operation, + profileId, + targets, + }: { + operation: Mutation['operation']; + profileId: string; + targets: RecoveryEscrowProvider[]; + }): Promise { + const metadatas = await Promise.all( + targets.map((escrow) => escrow.getRecoveryMetadata(profileId)), + ); + if (operation === 'register') { + if (metadatas.some((metadata) => metadata !== null)) { + throw new MfaRecoveryError( + 'Profile is already registered', + 'already_registered', + ); + } + return 0; + } + const present = metadatas.filter( + (metadata): metadata is NonNullable => metadata !== null, + ); + if (present.length !== metadatas.length) { + throw new MfaRecoveryError( + 'Recovery record missing at a configured escrow', + 'missing_record', + ); + } + const selected = present[0]; + if ( + !present.every( + (metadata) => + metadata.version === selected.version && + metadata.lastMutationId === selected.lastMutationId, + ) + ) { + throw new MfaRecoveryError( + 'Escrows disagree on recovery version', + 'version_divergence', + ); + } + return selected.version; + } + + #selectHighestConsistentVersion( + results: PromiseSettledResult<{ + escrowId: string; + recoverySecret: Uint8Array; + version: number; + lastMutationId: string; + }>[], + ): { recoverySecret: Uint8Array; version: number; lastMutationId: string } { + const responses = results + .filter((result) => result.status === 'fulfilled') + .map((result) => result.value); + if (responses.length === 0) { + throw new MfaRecoveryError( + 'No escrow returned a recovery secret', + 'read_failed', + ); + } + const highestVersion = Math.max( + ...responses.map((response) => response.version), + ); + const highest = responses.filter( + (response) => response.version === highestVersion, + ); + const selected = highest[0]; + if ( + !highest.every( + (response) => + response.lastMutationId === selected.lastMutationId && + areUint8ArraysEqual(response.recoverySecret, selected.recoverySecret), + ) + ) { + throw new MfaRecoveryError('Replica corruption', 'replica_corruption'); + } + return selected; + } + + async #getAvailableEscrows(): Promise { + const flags = await Promise.all( + this.#escrows.map(async (escrow) => ({ + escrow, + available: await escrow.isAvailable(), + })), + ); + return flags.filter((item) => item.available).map((item) => item.escrow); + } + + async #requireAllEscrows( + escrows: RecoveryEscrowProvider[], + ): Promise { + const available = await Promise.all( + escrows.map(async (escrow) => ({ + escrow, + available: await escrow.isAvailable(), + })), + ); + if (available.some((item) => !item.available)) { + throw new MfaRecoveryError( + 'All configured escrows are required for mutation', + 'escrow_unavailable', + ); + } + return escrows; + } + + #tokenNeedsRefresh(token: AuthControllerToken): boolean { + return token.expiresAt <= this.#now(); + } + + #assertKnownIdentifierTypes(identifiers: Identifier[]): void { + for (const identifier of identifiers) { + getIdentifierAuthMode(identifier.type); + } + } + + async #loadPending(): Promise { + const { pendingOperation } = this.state; + if (pendingOperation === null) { + return null; + } + return await this.#encryptor.decrypt(pendingOperation); + } + + async #persistPending(operation: PendingOperation): Promise { + const encrypted = await this.#encryptor.encrypt(operation); + this.update((state) => { + state.pendingOperation = encrypted; + }); + } + + async #clearPending(): Promise { + this.update((state) => { + state.pendingOperation = null; + }); + } + + async #withLock( + fn: () => Promise, + ): Promise { + const run = this.#lock.then(fn, fn); + this.#lock = run.then( + () => undefined, + () => undefined, + ); + return await run; + } +} diff --git a/packages/mfa-recovery-controller/src/crypto.test.ts b/packages/mfa-recovery-controller/src/crypto.test.ts new file mode 100644 index 00000000000..8143c857f9e --- /dev/null +++ b/packages/mfa-recovery-controller/src/crypto.test.ts @@ -0,0 +1,46 @@ +import { bytesToHex } from '@metamask/utils'; + +import { + bytesToSecretHex, + canonicalize, + canonicalizeIdentifiers, + hash, + secretHexToBytes, +} from './crypto.js'; +import type { Identifier } from './types.js'; + +describe('crypto', () => { + it('canonicalizes objects with sorted keys and Uint8Array values', () => { + expect(canonicalize({ b: 1, a: new Uint8Array([1, 2]) })).toBe( + `{"a":"${bytesToHex(new Uint8Array([1, 2]))}","b":1}`, + ); + }); + + it('hashes independently of key order', async () => { + expect(await hash({ b: 1, a: 2 })).toBe(await hash({ a: 2, b: 1 })); + }); + + it('round-trips secret hex without a 0x prefix', () => { + const bytes = new Uint8Array([255, 0, 16]); + const encoded = bytesToSecretHex(bytes); + expect(secretHexToBytes(encoded.slice(2))).toStrictEqual(bytes); + }); + + it('sorts identifiers for ownership hashes', () => { + const first: Identifier = { + type: 'passkey', + namespace: 'b.com', + value: '2', + verifier: null, + }; + const second: Identifier = { + type: 'passkey', + namespace: 'a.com', + value: '1', + verifier: null, + }; + expect(canonicalize(canonicalizeIdentifiers([first, second]))).toBe( + canonicalize(canonicalizeIdentifiers([second, first])), + ); + }); +}); diff --git a/packages/mfa-recovery-controller/src/crypto.ts b/packages/mfa-recovery-controller/src/crypto.ts new file mode 100644 index 00000000000..83aa9272a81 --- /dev/null +++ b/packages/mfa-recovery-controller/src/crypto.ts @@ -0,0 +1,201 @@ +import { bytesToHex, hexToBytes, stringToBytes } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { Identifier } from './types.js'; + +const ECDSA = { name: 'ECDSA', namedCurve: 'P-256' } as const; +const ECDSA_SIGN = { name: 'ECDSA', hash: 'SHA-256' } as const; + +/** + * Recursively sorts object keys so hashes are independent of property order. + * + * @param value - JSON-compatible value. + * @returns A canonical JSON string. + */ +export function canonicalize(value: unknown): string { + return JSON.stringify(sortKeys(value)); +} + +/** + * SHA-256 of the canonical JSON form of `value`, as a 0x-prefixed hex string. + * + * @param value - Value to hash. + * @returns Hex digest. + */ +export async function hash(value: unknown): Promise { + const digest = await globalThis.crypto.subtle.digest( + 'SHA-256', + stringToBytes(canonicalize(value)), + ); + return bytesToHex(new Uint8Array(digest)); +} + +/** + * Canonical identifier list bound into AuthController `identifiersHash`. + * + * @param identifiers - Identifier set. + * @returns Sorted identifier records including verifier material. + */ +export function canonicalizeIdentifiers(identifiers: Identifier[]): unknown { + return [...identifiers] + .map((identifier) => ({ + namespace: identifier.namespace, + type: identifier.type, + value: identifier.value, + verifier: identifier.verifier, + })) + .sort((left, right) => + canonicalize(left).localeCompare(canonicalize(right)), + ); +} + +/** + * @returns A random 0x-prefixed id. + */ +export function randomId(): Hex { + return bytesToHex(globalThis.crypto.getRandomValues(new Uint8Array(16))); +} + +/** + * Generates an ephemeral ECDSA P-256 proof key pair for key-bound identifier PoP. + * + * @returns JWK-encoded public and private keys. + */ +export async function generateSigningKey(): Promise<{ + publicKey: string; + privateKey: string; +}> { + const pair = await globalThis.crypto.subtle.generateKey(ECDSA, true, [ + 'sign', + 'verify', + ]); + const [publicJwk, privateJwk] = await Promise.all([ + globalThis.crypto.subtle.exportKey('jwk', pair.publicKey), + globalThis.crypto.subtle.exportKey('jwk', pair.privateKey), + ]); + return { + publicKey: JSON.stringify(publicJwk), + privateKey: JSON.stringify(privateJwk), + }; +} + +/** + * Signs a message with the ephemeral proof private key. + * + * @param privateKey - JWK JSON private key. + * @param message - Prehashed message hex or string. + * @returns Hex signature. + */ +export async function sign(privateKey: string, message: string): Promise { + const key = await globalThis.crypto.subtle.importKey( + 'jwk', + JSON.parse(privateKey) as JsonWebKey, + ECDSA, + false, + ['sign'], + ); + const signature = await globalThis.crypto.subtle.sign( + ECDSA_SIGN, + key, + stringToBytes(message), + ); + return bytesToHex(new Uint8Array(signature)); +} + +/** + * Verifies a proof signature against a public key. + * + * @param publicKey - JWK JSON public key. + * @param signature - Hex signature. + * @param message - Prehashed message. + * @returns Whether the signature is valid. + */ +export async function verifySignature( + publicKey: string, + signature: string, + message: string, +): Promise { + const key = await globalThis.crypto.subtle.importKey( + 'jwk', + JSON.parse(publicKey) as JsonWebKey, + ECDSA, + false, + ['verify'], + ); + return await globalThis.crypto.subtle.verify( + ECDSA_SIGN, + key, + hexToBytes(signature as Hex), + stringToBytes(message), + ); +} + +/** + * Receipt domain-separated digest from the recovery ADR. + * + * @param receiptFields - Receipt fields covered by the signature. + * @param receiptFields.escrowId - Escrow that issued the receipt. + * @param receiptFields.mutationId - Mutation id. + * @param receiptFields.requestHash - Mutation request hash. + * @param receiptFields.version - Applied version. + * @returns Hex digest. + */ +export async function hashMutationReceipt(receiptFields: { + escrowId: string; + mutationId: string; + requestHash: string; + version: number; +}): Promise { + return await hash([ + 'mfa-recovery-mutation-receipt-v1', + receiptFields.escrowId, + receiptFields.mutationId, + receiptFields.requestHash, + receiptFields.version, + ]); +} + +/** + * Converts bytes to 0x-prefixed hex. + * + * @param bytes - Secret bytes. + * @returns Hex string. + */ +export function bytesToSecretHex(bytes: Uint8Array): Hex { + return bytesToHex(bytes); +} + +/** + * Converts a 0x-prefixed hex secret to bytes. + * + * @param secretHex - Hex string. + * @returns Secret bytes. + */ +export function secretHexToBytes(secretHex: string): Uint8Array { + return hexToBytes( + (secretHex.startsWith('0x') ? secretHex : `0x${secretHex}`) as Hex, + ); +} + +/** + * Recursively sorts keys and encodes Uint8Array as hex. + * + * @param value - Value to normalize. + * @returns JSON-safe canonical form. + */ +function sortKeys(value: unknown): unknown { + if (value instanceof Uint8Array) { + return bytesToHex(value); + } + if (Array.isArray(value)) { + return value.map(sortKeys); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value as Record) + .sort() + .map((key) => [key, sortKeys((value as Record)[key])]), + ); + } + return value; +} diff --git a/packages/mfa-recovery-controller/src/errors.ts b/packages/mfa-recovery-controller/src/errors.ts new file mode 100644 index 00000000000..b6887291fca --- /dev/null +++ b/packages/mfa-recovery-controller/src/errors.ts @@ -0,0 +1,29 @@ +/** + * Errors thrown by {@link MfaRecoveryController}. + */ +export class MfaRecoveryError extends Error { + readonly code: string; + + constructor(message: string, code: string) { + super(message); + this.name = 'MfaRecoveryError'; + this.code = code; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Thrown when a mutation was applied at some but not all configured escrows. + * Call {@link MfaRecoveryController.resume} to retry the same mutation. + */ +export class MutationRepairPendingError extends MfaRecoveryError { + readonly mutationId: string; + + constructor(mutationId: string) { + super( + `Mutation ${mutationId} is not acknowledged by every escrow`, + 'mutation_repair_pending', + ); + this.mutationId = mutationId; + } +} diff --git a/packages/mfa-recovery-controller/src/identifier-auth.ts b/packages/mfa-recovery-controller/src/identifier-auth.ts new file mode 100644 index 00000000000..e6b781634cc --- /dev/null +++ b/packages/mfa-recovery-controller/src/identifier-auth.ts @@ -0,0 +1,32 @@ +import { MfaRecoveryError } from './errors.js'; +import type { IdentifierAuthMode } from './types.js'; + +/** + * Trusted identifier-type registry. Escrows and the controller derive the + * authentication mode from this table, never from a client-selected flag. + */ +export const IDENTIFIER_AUTH_MODES: Record = { + oidc: 'key-bound', + passkey: 'key-bound', + siwe: 'key-bound', + emailOtp: 'escrow-challenge', + smsOtp: 'escrow-challenge', +}; + +/** + * Returns the auth mode for an identifier type. + * + * @param type - Identifier type. + * @returns Auth mode. + * @throws If the type is not in the trusted registry. + */ +export function getIdentifierAuthMode(type: string): IdentifierAuthMode { + const mode = IDENTIFIER_AUTH_MODES[type]; + if (mode === undefined) { + throw new MfaRecoveryError( + `Unknown identifier type: ${type}`, + 'unknown_identifier_type', + ); + } + return mode; +} diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts new file mode 100644 index 00000000000..257996841ce --- /dev/null +++ b/packages/mfa-recovery-controller/src/index.ts @@ -0,0 +1,42 @@ +export { + MfaRecoveryController, + getDefaultMfaRecoveryControllerState, +} from './MfaRecoveryController.js'; +export type { + MfaRecoveryControllerActions, + MfaRecoveryControllerEvents, + MfaRecoveryControllerGetStateAction, + MfaRecoveryControllerMessenger, + MfaRecoveryControllerOptions, + MfaRecoveryControllerState, + MfaRecoveryControllerStateChangeEvent, +} from './MfaRecoveryController.js'; +export type { + MfaRecoveryControllerAbortAction, + MfaRecoveryControllerGetPhaseAction, + MfaRecoveryControllerGetRecoverySecretAction, + MfaRecoveryControllerRegisterAction, + MfaRecoveryControllerResumeAction, + MfaRecoveryControllerUpdateIdentifiersAction, + MfaRecoveryControllerUpdateRecoverySecretAction, +} from './MfaRecoveryController-method-action-types.js'; +export { MfaRecoveryError, MutationRepairPendingError } from './errors.js'; +export { + IDENTIFIER_AUTH_MODES, + getIdentifierAuthMode, +} from './identifier-auth.js'; +export type { + AuthControllerToken, + EncryptedPendingOperation, + Identifier, + IdentifierAuthorization, + Mutation, + MutationPayload, + MutationReceipt, + PendingOperation, + PendingOperationEncryptor, + RecoveryAuthProvider, + RecoveryEscrowProvider, + RecoveryIdentifierAuthProvider, + RecoveryPhase, +} from './types.js'; diff --git a/packages/mfa-recovery-controller/src/state-machine.ts b/packages/mfa-recovery-controller/src/state-machine.ts new file mode 100644 index 00000000000..63fa5da7367 --- /dev/null +++ b/packages/mfa-recovery-controller/src/state-machine.ts @@ -0,0 +1,51 @@ +import { MfaRecoveryError } from './errors.js'; +import type { PendingOperation, RecoveryPhase } from './types.js'; + +/** + * Returns the persisted recovery phase. + * + * @param pending - Loaded pending operation, if any. + * @returns Current phase. + */ +export function getRecoveryPhase( + pending: PendingOperation | null, +): RecoveryPhase { + return pending?.phase ?? 'idle'; +} + +/** + * Abort is allowed only before the first escrow write. Once `writing` begins + * the same mutation must be retried to completion. + * + * @param phase - Current phase. + * @throws If the mutation is already being written. + */ +export function assertAbortAllowed(phase: RecoveryPhase): void { + if (phase === 'writing') { + throw new MfaRecoveryError( + 'Cannot abort a mutation once writing has begun', + 'abort_not_allowed', + ); + } +} + +/** + * Ensures a persisted mutation targets the build-configured escrow set. + * + * @param audiences - Mutation audience ids. + * @param escrowIds - Configured escrow ids. + */ +export function assertSameAudienceIds( + audiences: string[], + escrowIds: string[], +): void { + if ( + audiences.length !== escrowIds.length || + audiences.some((id, index) => id !== escrowIds[index]) + ) { + throw new MfaRecoveryError( + 'Persisted mutation audiences do not match configured escrows', + 'audience_mismatch', + ); + } +} diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts new file mode 100644 index 00000000000..63d0c1d853a --- /dev/null +++ b/packages/mfa-recovery-controller/src/types.ts @@ -0,0 +1,208 @@ +/** + * MFA recovery protocol types from the recovery ADR. + */ + +export type Identifier = { + type: string; + namespace: string; + value: string; + verifier: unknown; +}; + +export type PoPChallenge = { + id: string; + escrowId: string; + expiresAt: number; +}; + +export type IdentifierAuthMode = 'key-bound' | 'escrow-challenge'; + +export type KeyBoundIdentifierToken = { + identifier: Identifier; + proofPublicKey: string; + requestHash: string; + providerAssertion: unknown; +}; + +export type ProofOfPossession = { + challengeId: string; + requestHash: string; + signature: string; +}; + +export type EscrowAuthChallenge = { + id: string; + escrowId: string; + identifier: Identifier; + requestHash: string; + expiresAt: number; +}; + +export type EscrowIdentifierGrant = { + id: string; +}; + +export type IdentifierAuthorization = + | { + kind: 'key-bound'; + token: KeyBoundIdentifierToken; + proof: ProofOfPossession; + } + | { + kind: 'escrow-challenge'; + grant: EscrowIdentifierGrant; + }; + +export type MutationOperation = + | 'register' + | 'updateRecoverySecret' + | 'updateIdentifiers'; + +export type Mutation = { + id: string; + profileId: string; + operation: MutationOperation; + expectedVersion: number; + newVersion: number; + payloadHash: string; + audiences: string[]; + requestHash: string; +}; + +export type AuthControllerToken = { + profileId: string; + requestHash: string; + twoFactor?: true; + identifiersHash?: string; + identifierOwnershipApproved?: true; + issuer: string; + expiresAt: number; + signature: string; +}; + +export type MutationReceipt = { + mutationId: string; + requestHash: string; + escrowId: string; + version: number; + signature: string; +}; + +export type RegisterPayload = { + recoverySecret: string; + identifiers: Identifier[]; +}; + +export type UpdateRecoverySecretPayload = { + recoverySecret: string; +}; + +export type UpdateIdentifiersPayload = { + identifiers: Identifier[]; +}; + +export type MutationPayload = + | RegisterPayload + | UpdateRecoverySecretPayload + | UpdateIdentifiersPayload; + +export type AuthorizingPendingOperation = { + phase: 'authorizing'; + mutation: Mutation; + payload: MutationPayload; + identifier: Identifier | null; +}; + +export type WritingPendingOperation = { + phase: 'writing'; + mutation: Mutation; + authControllerToken: AuthControllerToken; + payload: MutationPayload; + identifier: Identifier | null; + receipts: MutationReceipt[]; +}; + +export type PendingOperation = + | AuthorizingPendingOperation + | WritingPendingOperation; + +export type RecoveryPhase = 'idle' | PendingOperation['phase']; + +/** + * Ciphertext of {@link PendingOperation}. Wallet secure storage encrypts this + * value; a raw recovery secret is never persisted in the clear. + */ +export type EncryptedPendingOperation = string; + +export type RecoveryRecordMetadata = { + version: number; + lastMutationId: string; +}; + +export type RecoverySecretResponse = { + recoverySecret: Uint8Array; + version: number; + lastMutationId: string; +}; + +/** + * Authenticates the MetaMask profile and issues request-bound attestations. + */ +export type RecoveryAuthProvider = { + getAuthenticatedProfileId: () => Promise; + authorizeRecoveryRequest: (params: { + requestHash: string; + requireTwoFactor?: boolean; + identifiers?: Identifier[]; + }) => Promise; +}; + +/** + * Obtains a key-bound identifier assertion from an IdP (OIDC, Passkey, SIWE). + */ +export type RecoveryIdentifierAuthProvider = { + getKeyBoundIdentifierToken: (params: { + identifier: Identifier; + proofPublicKey: string; + requestHash: string; + }) => Promise; +}; + +/** + * One recovery escrow replica. Implementations may talk to different backends. + */ +export type RecoveryEscrowProvider = { + readonly id: string; + isAvailable: () => Promise; + generateChallenge: () => Promise; + beginIdentifierAuthentication: (params: { + identifier: Identifier; + requestHash: string; + }) => Promise; + completeIdentifierAuthentication: ( + challengeId: string, + response: unknown, + ) => Promise; + getRecoveryMetadata: ( + profileId: string, + ) => Promise; + getSecret: ( + authorization: IdentifierAuthorization, + requestId: string, + ) => Promise; + applyMutation: ( + mutation: Mutation, + authControllerToken: AuthControllerToken, + identifierAuthorization: IdentifierAuthorization | null, + payload: MutationPayload, + ) => Promise; + verifyReceipt: (receipt: MutationReceipt, mutation: Mutation) => boolean; +}; + +/** + * Encrypts pending mutation state using wallet secure storage. + */ +export type PendingOperationEncryptor = { + encrypt: (operation: PendingOperation) => Promise; + decrypt: (ciphertext: EncryptedPendingOperation) => Promise; +}; diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts new file mode 100644 index 00000000000..579dcc79558 --- /dev/null +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -0,0 +1,603 @@ +import { + canonicalizeIdentifiers, + hash, + hashMutationReceipt, + secretHexToBytes, + verifySignature, +} from '../src/crypto.js'; +import { MfaRecoveryError } from '../src/errors.js'; +import { getIdentifierAuthMode } from '../src/identifier-auth.js'; +import type { + AuthControllerToken, + EscrowAuthChallenge, + EscrowIdentifierGrant, + Identifier, + IdentifierAuthorization, + KeyBoundIdentifierToken, + Mutation, + MutationPayload, + MutationReceipt, + PendingOperation, + PendingOperationEncryptor, + PoPChallenge, + RecoveryAuthProvider, + RecoveryEscrowProvider, + RecoveryIdentifierAuthProvider, + RecoveryRecordMetadata, + RecoverySecretResponse, + RegisterPayload, + UpdateIdentifiersPayload, + UpdateRecoverySecretPayload, +} from '../src/types.js'; + +type RecoveryRecord = { + profileId: string; + identifiers: Identifier[]; + recoverySecret: string; + version: number; + lastMutationId: string; + appliedMutations: Record; +}; + +type StoredPoPChallenge = PoPChallenge & { consumed: boolean }; + +type StoredEscrowAuth = EscrowAuthChallenge & { + completed: boolean; + consumed: boolean; + grant?: EscrowIdentifierGrant; +}; + +const CHALLENGE_TTL_MS = 5 * 60 * 1000; + +/** + * In-memory AuthController used in tests. + */ +export class StubAuthProvider implements RecoveryAuthProvider { + profileId = 'profile-1'; + + now: () => number = () => Date.now(); + + async getAuthenticatedProfileId(): Promise { + return this.profileId; + } + + async authorizeRecoveryRequest(params: { + requestHash: string; + requireTwoFactor?: boolean; + identifiers?: Identifier[]; + }): Promise { + const identifiersHash = + params.identifiers === undefined + ? undefined + : await hash(canonicalizeIdentifiers(params.identifiers)); + return { + profileId: this.profileId, + requestHash: params.requestHash, + ...(params.requireTwoFactor ? { twoFactor: true as const } : {}), + ...(identifiersHash === undefined + ? {} + : { + identifiersHash, + identifierOwnershipApproved: true as const, + }), + issuer: 'stub-auth', + expiresAt: this.now() + 60 * 60 * 1000, + signature: 'stub-auth-signature', + }; + } +} + +/** + * In-memory identifier IdP used in tests. + */ +export class StubIdentifierAuthProvider implements RecoveryIdentifierAuthProvider { + async getKeyBoundIdentifierToken(params: { + identifier: Identifier; + proofPublicKey: string; + requestHash: string; + }): Promise { + return { + identifier: params.identifier, + proofPublicKey: params.proofPublicKey, + requestHash: params.requestHash, + providerAssertion: { + bound: await hash({ + proofPublicKey: params.proofPublicKey, + requestHash: params.requestHash, + }), + }, + }; + } +} + +/** + * In-memory escrow replica used in tests. + */ +export class StubEscrowProvider implements RecoveryEscrowProvider { + available = true; + + failNextApplyCount = 0; + + failGetSecret = false; + + invalidReceipts = false; + + now: () => number = () => Date.now(); + + readonly #records = new Map(); + + readonly #identifierIndex = new Map(); + + readonly #popChallenges = new Map(); + + readonly #escrowAuth = new Map(); + + readonly #grants = new Map< + string, + { + identifier: Identifier; + requestHash: string; + expiresAt: number; + consumed: boolean; + } + >(); + + readonly id: string; + + constructor(id: string) { + this.id = id; + } + + async isAvailable(): Promise { + return this.available; + } + + async generateChallenge(): Promise { + const challenge: StoredPoPChallenge = { + id: `${this.id}-pop-${this.#popChallenges.size + 1}`, + escrowId: this.id, + expiresAt: this.now() + CHALLENGE_TTL_MS, + consumed: false, + }; + this.#popChallenges.set(challenge.id, challenge); + return { + id: challenge.id, + escrowId: challenge.escrowId, + expiresAt: challenge.expiresAt, + }; + } + + async beginIdentifierAuthentication(params: { + identifier: Identifier; + requestHash: string; + }): Promise { + if (getIdentifierAuthMode(params.identifier.type) !== 'escrow-challenge') { + throw new MfaRecoveryError( + 'Identifier type is not escrow-challenge', + 'invalid_auth_mode', + ); + } + const challenge: StoredEscrowAuth = { + id: `${this.id}-otp-${this.#escrowAuth.size + 1}`, + escrowId: this.id, + identifier: params.identifier, + requestHash: params.requestHash, + expiresAt: this.now() + CHALLENGE_TTL_MS, + completed: false, + consumed: false, + }; + this.#escrowAuth.set(challenge.id, challenge); + return { + id: challenge.id, + escrowId: challenge.escrowId, + identifier: challenge.identifier, + requestHash: challenge.requestHash, + expiresAt: challenge.expiresAt, + }; + } + + async completeIdentifierAuthentication( + challengeId: string, + _response: unknown, + ): Promise { + const challenge = this.#escrowAuth.get(challengeId); + if ( + challenge === undefined || + challenge.expiresAt <= this.now() || + challenge.completed + ) { + throw new MfaRecoveryError( + 'Invalid escrow auth challenge', + 'invalid_challenge', + ); + } + challenge.completed = true; + const grant = { id: `${challenge.id}-grant` }; + challenge.grant = grant; + this.#grants.set(grant.id, { + identifier: challenge.identifier, + requestHash: challenge.requestHash, + expiresAt: challenge.expiresAt, + consumed: false, + }); + return grant; + } + + async getRecoveryMetadata( + profileId: string, + ): Promise { + const record = this.#records.get(profileId); + if (record === undefined) { + return null; + } + return { version: record.version, lastMutationId: record.lastMutationId }; + } + + async getSecret( + authorization: IdentifierAuthorization, + requestId: string, + ): Promise { + if (this.failGetSecret) { + throw new MfaRecoveryError( + 'Injected getSecret failure', + 'get_secret_failed', + ); + } + const requestHash = await hash({ + operation: 'getRecoverySecret', + requestId, + }); + const { profileId } = await this.#verifyIdentifierAuthorization( + authorization, + requestHash, + ); + const record = this.#records.get(profileId); + if (record === undefined) { + throw new MfaRecoveryError('No recovery record', 'not_registered'); + } + return { + recoverySecret: secretHexToBytes(record.recoverySecret), + version: record.version, + lastMutationId: record.lastMutationId, + }; + } + + async applyMutation( + mutation: Mutation, + authControllerToken: AuthControllerToken, + identifierAuthorization: IdentifierAuthorization | null, + payload: MutationPayload, + ): Promise { + if (this.failNextApplyCount > 0) { + this.failNextApplyCount -= 1; + throw new MfaRecoveryError('Injected apply failure', 'apply_failed'); + } + + await this.#verifyMutationAuthorization(mutation, authControllerToken); + + const existing = this.#records.get(mutation.profileId)?.appliedMutations[ + mutation.id + ]; + if (existing) { + if ( + existing.requestHash !== mutation.requestHash || + existing.version !== mutation.newVersion + ) { + throw new MfaRecoveryError( + 'Conflicting mutation replay', + 'mutation_conflict', + ); + } + return await this.#signReceipt(mutation); + } + + const record = this.#records.get(mutation.profileId); + const currentVersion = record?.version ?? 0; + if ( + mutation.expectedVersion !== currentVersion || + mutation.newVersion !== currentVersion + 1 + ) { + throw new MfaRecoveryError('Version mismatch', 'version_mismatch'); + } + + if (mutation.operation === 'register') { + if (identifierAuthorization !== null || record !== undefined) { + throw new MfaRecoveryError('Invalid registration', 'invalid_register'); + } + const registerPayload = payload as RegisterPayload; + await this.#assertIdentifierOwnership( + authControllerToken, + registerPayload.identifiers, + ); + this.#assertIdentifiersAvailable( + mutation.profileId, + registerPayload.identifiers, + ); + const next: RecoveryRecord = { + profileId: mutation.profileId, + identifiers: registerPayload.identifiers, + recoverySecret: registerPayload.recoverySecret, + version: mutation.newVersion, + lastMutationId: mutation.id, + appliedMutations: { + [mutation.id]: { + requestHash: mutation.requestHash, + version: mutation.newVersion, + }, + }, + }; + this.#records.set(mutation.profileId, next); + this.#replaceIdentifierIndex( + mutation.profileId, + [], + registerPayload.identifiers, + ); + return await this.#signReceipt(mutation); + } + + if (record === undefined || authControllerToken.twoFactor !== true) { + throw new MfaRecoveryError( + 'Update not authorized', + 'update_unauthorized', + ); + } + if (identifierAuthorization === null) { + throw new MfaRecoveryError( + 'Identifier authorization required', + 'missing_identifier_auth', + ); + } + const authorization = await this.#verifyIdentifierAuthorization( + identifierAuthorization, + mutation.requestHash, + ); + if (authorization.profileId !== mutation.profileId) { + throw new MfaRecoveryError('Profile mismatch', 'profile_mismatch'); + } + + if (mutation.operation === 'updateRecoverySecret') { + record.recoverySecret = ( + payload as UpdateRecoverySecretPayload + ).recoverySecret; + } else { + const { identifiers } = payload as UpdateIdentifiersPayload; + await this.#assertIdentifierOwnership(authControllerToken, identifiers); + this.#assertIdentifiersAvailable(mutation.profileId, identifiers); + this.#replaceIdentifierIndex( + mutation.profileId, + record.identifiers, + identifiers, + ); + record.identifiers = identifiers; + } + record.version = mutation.newVersion; + record.lastMutationId = mutation.id; + record.appliedMutations[mutation.id] = { + requestHash: mutation.requestHash, + version: mutation.newVersion, + }; + return await this.#signReceipt(mutation); + } + + verifyReceipt(receipt: MutationReceipt, mutation: Mutation): boolean { + if (this.invalidReceipts) { + return false; + } + return ( + receipt.mutationId === mutation.id && + receipt.requestHash === mutation.requestHash && + receipt.escrowId === this.id && + receipt.version === mutation.newVersion + ); + } + + /** + * Test helper: remove a replica record. + * + * @param profileId - Profile id. + */ + clearRecord(profileId: string): void { + this.#records.delete(profileId); + } + + /** + * Test helper: force a replica to a specific record version. + * + * @param profileId - Profile id. + * @param patch - Fields to overwrite. + */ + patchRecord( + profileId: string, + patch: Partial< + Pick + >, + ): void { + const record = this.#records.get(profileId); + if (record === undefined) { + throw new Error(`No record for ${profileId}`); + } + Object.assign(record, patch); + } + + async #signReceipt(mutation: Mutation): Promise { + const signature = await hashMutationReceipt({ + escrowId: this.id, + mutationId: mutation.id, + requestHash: mutation.requestHash, + version: mutation.newVersion, + }); + return { + mutationId: mutation.id, + requestHash: mutation.requestHash, + escrowId: this.id, + version: mutation.newVersion, + signature, + }; + } + + async #verifyMutationAuthorization( + mutation: Mutation, + token: AuthControllerToken, + ): Promise { + const requestHash = await hash({ + id: mutation.id, + profileId: mutation.profileId, + operation: mutation.operation, + expectedVersion: mutation.expectedVersion, + newVersion: mutation.newVersion, + payloadHash: mutation.payloadHash, + audiences: mutation.audiences, + }); + if ( + requestHash !== mutation.requestHash || + token.requestHash !== mutation.requestHash || + token.profileId !== mutation.profileId || + token.signature !== 'stub-auth-signature' || + token.expiresAt <= this.now() || + !mutation.audiences.includes(this.id) + ) { + throw new MfaRecoveryError( + 'Invalid mutation authorization', + 'invalid_mutation_auth', + ); + } + } + + async #assertIdentifierOwnership( + token: AuthControllerToken, + identifiers: Identifier[], + ): Promise { + if ( + token.identifierOwnershipApproved !== true || + token.identifiersHash !== + (await hash(canonicalizeIdentifiers(identifiers))) + ) { + throw new MfaRecoveryError( + 'Identifier ownership not approved', + 'ownership_not_approved', + ); + } + if (identifiers.length === 0) { + throw new MfaRecoveryError('Empty identifier list', 'empty_identifiers'); + } + } + + async #verifyIdentifierAuthorization( + authorization: IdentifierAuthorization, + requestHash: string, + ): Promise<{ profileId: string }> { + let identifier: Identifier; + if (authorization.kind === 'key-bound') { + const { token, proof } = authorization; + identifier = token.identifier; + if (getIdentifierAuthMode(identifier.type) !== 'key-bound') { + throw new MfaRecoveryError( + 'Not a key-bound identifier', + 'invalid_auth_mode', + ); + } + if ( + token.requestHash !== requestHash || + proof.requestHash !== requestHash + ) { + throw new MfaRecoveryError( + 'Request hash mismatch', + 'request_hash_mismatch', + ); + } + const challenge = this.#popChallenges.get(proof.challengeId); + if ( + challenge === undefined || + challenge.consumed || + challenge.escrowId !== this.id || + challenge.expiresAt <= this.now() + ) { + throw new MfaRecoveryError( + 'Invalid PoP challenge', + 'invalid_challenge', + ); + } + const message = await hash([token, proof.challengeId, requestHash]); + if ( + !(await verifySignature(token.proofPublicKey, proof.signature, message)) + ) { + throw new MfaRecoveryError('Invalid PoP signature', 'invalid_pop'); + } + challenge.consumed = true; + } else { + const grant = this.#grants.get(authorization.grant.id); + if ( + grant === undefined || + grant.consumed || + grant.requestHash !== requestHash || + grant.expiresAt <= this.now() + ) { + throw new MfaRecoveryError('Invalid escrow grant', 'invalid_grant'); + } + grant.consumed = true; + identifier = grant.identifier; + if (getIdentifierAuthMode(identifier.type) !== 'escrow-challenge') { + throw new MfaRecoveryError( + 'Not an escrow-challenge identifier', + 'invalid_auth_mode', + ); + } + } + + const profileId = this.#identifierIndex.get( + canonicalIdentifier(identifier), + ); + if (profileId === undefined) { + throw new MfaRecoveryError('Unknown identifier', 'unknown_identifier'); + } + return { profileId }; + } + + #assertIdentifiersAvailable( + profileId: string, + identifiers: Identifier[], + ): void { + for (const identifier of identifiers) { + const owner = this.#identifierIndex.get(canonicalIdentifier(identifier)); + if (owner !== undefined && owner !== profileId) { + throw new MfaRecoveryError( + 'Identifier owned by another profile', + 'identifier_taken', + ); + } + } + } + + #replaceIdentifierIndex( + profileId: string, + previous: Identifier[], + next: Identifier[], + ): void { + for (const identifier of previous) { + this.#identifierIndex.delete(canonicalIdentifier(identifier)); + } + for (const identifier of next) { + this.#identifierIndex.set(canonicalIdentifier(identifier), profileId); + } + } +} + +/** + * JSON round-trip encryptor for tests. Production uses wallet secure storage. + */ +export const passthroughEncryptor: PendingOperationEncryptor = { + async encrypt(operation: PendingOperation): Promise { + return JSON.stringify(operation); + }, + async decrypt(ciphertext: string): Promise { + return JSON.parse(ciphertext) as PendingOperation; + }, +}; + +/** + * @param identifier - Identifier to canonicalize. + * @returns Index key. + */ +function canonicalIdentifier(identifier: Identifier): string { + return `${identifier.type}:${identifier.namespace}:${identifier.value}`; +} diff --git a/packages/mfa-recovery-controller/tsconfig.build.json b/packages/mfa-recovery-controller/tsconfig.build.json new file mode 100644 index 00000000000..249f327913d --- /dev/null +++ b/packages/mfa-recovery-controller/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/mfa-recovery-controller/tsconfig.json b/packages/mfa-recovery-controller/tsconfig.json new file mode 100644 index 00000000000..890b592b55a --- /dev/null +++ b/packages/mfa-recovery-controller/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../base-controller" + }, + { + "path": "../messenger" + } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/mfa-recovery-controller/typedoc.json b/packages/mfa-recovery-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/mfa-recovery-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index 3078fda3c48..0e0641c09e2 100644 --- a/teams.json +++ b/teams.json @@ -62,6 +62,7 @@ "metamask/wallet": "team-core-platform", "metamask/wallet-cli": "team-core-platform", "metamask/passkey-controller": "team-onboarding", + "metamask/mfa-recovery-controller": "team-onboarding", "metamask/seedless-onboarding-controller": "team-onboarding", "metamask/shield-controller": "team-shield", "metamask/subscription-controller": "team-shield", diff --git a/tsconfig.build.json b/tsconfig.build.json index 01a1a01b606..949b70ca04d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -161,14 +161,17 @@ "path": "./packages/messenger/tsconfig.build.json" }, { - "path": "./packages/money-account-api-data-service/tsconfig.build.json" + "path": "./packages/mfa-recovery-controller/tsconfig.build.json" }, { - "path": "./packages/money-account-balance-service/tsconfig.build.json" + "path": "./packages/money-account-api-data-service/tsconfig.build.json" }, { "path": "./packages/money-account-api-data-service/tsconfig.build.json" }, + { + "path": "./packages/money-account-balance-service/tsconfig.build.json" + }, { "path": "./packages/money-account-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index accc0479c8d..473759e3c0a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -101,6 +101,12 @@ { "path": "./packages/earn-controller" }, + { + "path": "./packages/eip-5792-middleware" + }, + { + "path": "./packages/eip-7702-internal-rpc-middleware" + }, { "path": "./packages/eip1193-permission-middleware" }, @@ -143,6 +149,9 @@ { "path": "./packages/local-node-utils" }, + { + "path": "./packages/logging-controller" + }, { "path": "./packages/message-manager" }, @@ -153,14 +162,17 @@ "path": "./packages/messenger-cli" }, { - "path": "./packages/money-account-api-data-service" + "path": "./packages/mfa-recovery-controller" }, { - "path": "./packages/money-account-balance-service" + "path": "./packages/money-account-api-data-service" }, { "path": "./packages/money-account-api-data-service" }, + { + "path": "./packages/money-account-balance-service" + }, { "path": "./packages/money-account-controller" }, @@ -272,6 +284,9 @@ { "path": "./packages/stellar-quickstart-up" }, + { + "path": "./packages/storage-service" + }, { "path": "./packages/subscription-controller" }, @@ -284,18 +299,6 @@ { "path": "./packages/user-operation-controller" }, - { - "path": "./packages/eip-5792-middleware" - }, - { - "path": "./packages/eip-7702-internal-rpc-middleware" - }, - { - "path": "./packages/logging-controller" - }, - { - "path": "./packages/storage-service" - }, { "path": "./packages/wallet" }, diff --git a/yarn.lock b/yarn.lock index 9acaac50d8b..eedaea184f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7846,6 +7846,26 @@ __metadata: languageName: node linkType: hard +"@metamask/mfa-recovery-controller@workspace:packages/mfa-recovery-controller": + version: 0.0.0-use.local + resolution: "@metamask/mfa-recovery-controller@workspace:packages/mfa-recovery-controller" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/utils": "npm:^11.11.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^30.0.0" + deepmerge: "npm:^4.2.2" + jest: "npm:^30.4.2" + ts-jest: "npm:^29.4.11" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/money-account-api-data-service@npm:^0.4.1, @metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service": version: 0.0.0-use.local resolution: "@metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service" From 0b4047c7aaf5b8285f1398c4a7cdc54453f7fdf9 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:10:23 +0800 Subject: [PATCH 02/55] Update `mfa-recovery-controller` with breaking changes and new features - Introduced a new `escrow-utils.ts` module for handling escrow-related operations, including receipt verification and selecting the highest consistent recovery secret version. - Updated `MfaRecoveryController` to require `RecoveryEscrowProvider.verifyReceipt` to accept an expected escrow ID, enhancing receipt verification. - Exposed state updates through the `MfaRecoveryController:stateChanged` event, allowing better tracking of state changes. - Improved documentation in `CHANGELOG.md` and `README.md` to reflect changes in state management and mutation handling. - Fixed issues related to clearing acknowledged mutations and validating persisted mutation states. This commit enhances the robustness of the MFA recovery process and improves the overall user experience in MetaMask. --- packages/mfa-recovery-controller/CHANGELOG.md | 12 + packages/mfa-recovery-controller/README.md | 7 +- .../src/MfaRecoveryController.test.ts | 572 +++++++++++++++--- .../src/MfaRecoveryController.ts | 211 +++---- .../src/escrow-utils.ts | 116 ++++ packages/mfa-recovery-controller/src/index.ts | 2 +- .../src/pending-operation-validation.ts | 214 +++++++ packages/mfa-recovery-controller/src/types.ts | 24 +- .../mfa-recovery-controller/tests/stubs.ts | 9 +- 9 files changed, 979 insertions(+), 188 deletions(-) create mode 100644 packages/mfa-recovery-controller/src/escrow-utils.ts create mode 100644 packages/mfa-recovery-controller/src/pending-operation-validation.ts diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index b1bd921bd9c..a451639d3ef 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -11,4 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `MfaRecoveryController` with injectable `RecoveryAuthProvider` and `RecoveryEscrowProvider` interfaces, a persisted `idle` / `authorizing` / `writing` mutation state machine, and `register`, `updateRecoverySecret`, `updateIdentifiers`, `getRecoverySecret`, `resume`, and `abort` methods +### Changed + +- **BREAKING:** Require `RecoveryEscrowProvider.verifyReceipt` to receive the expected escrow id so receipt verification is explicitly bound to the configured escrow target +- Expose controller state updates through the non-deprecated `MfaRecoveryController:stateChanged` messenger event +- Retry only escrows without persisted mutation receipts and validate persisted mutation state before resuming it + +### Fixed + +- Clear fully acknowledged pending mutations without checking unavailable acknowledged escrows +- Persist valid receipts from concurrent escrow writes before reporting an invalid receipt response +- Reject malformed persisted mutation state before authorization or escrow writes + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md index ba45a0667fc..59b1d2d5fe6 100644 --- a/packages/mfa-recovery-controller/README.md +++ b/packages/mfa-recovery-controller/README.md @@ -4,9 +4,10 @@ Manages MFA recovery flows across MetaMask clients. Auth and escrow storage are injected so the same controller can run against different identity providers and escrow backends. -Pending mutations are persisted as encrypted `authorizing` / `writing` state so -a crash can `resume()` the same mutation. `abort()` is allowed only before the -first escrow write. +Pending mutations are persisted as encoded encrypted `authorizing` / `writing` +state so a crash can `resume()` the same mutation. `abort()` is allowed only +before the first escrow write. The encoded ciphertext is stored as a string so +BaseController state serialization preserves the encrypted value. ## Installation diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index c6e17f2c2a8..b2153b8b77c 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -13,14 +13,18 @@ import { StubIdentifierAuthProvider, passthroughEncryptor, } from '../tests/stubs.js'; -import { hash } from './crypto.js'; +import { canonicalizeIdentifiers, hash } from './crypto.js'; import { MutationRepairPendingError } from './errors.js'; import type { MfaRecoveryControllerMessenger, MfaRecoveryControllerOptions, } from './MfaRecoveryController.js'; import { MfaRecoveryController } from './MfaRecoveryController.js'; -import type { Identifier } from './types.js'; +import type { + Identifier, + MutationReceipt, + WritingPendingOperation, +} from './types.js'; const PASSKEY: Identifier = { type: 'passkey', @@ -145,16 +149,33 @@ describe('MfaRecoveryController', () => { describe('register', () => { it('replicates the recovery secret to every escrow', async () => { - await withController(async ({ controller }) => { + await withController(async ({ controller, escrowA }) => { + const verifyReceipt = jest.spyOn(escrowA, 'verifyReceipt'); await controller.register(SECRET, [PASSKEY]); expect(controller.state.pendingOperation).toBeNull(); expect(await controller.getPhase()).toBe('idle'); + expect(verifyReceipt.mock.calls[0][2]).toBe('escrow-a'); const recovered = await controller.getRecoverySecret(PASSKEY); expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); }); }); + it('publishes state changes through the stateChanged event', async () => { + await withController(async ({ controller, rootMessenger }) => { + const listener = jest.fn(); + rootMessenger.subscribe('MfaRecoveryController:stateChanged', listener); + + await controller.register(SECRET, [PASSKEY]); + + expect(listener).toHaveBeenCalled(); + expect(listener).toHaveBeenLastCalledWith( + { pendingOperation: null }, + expect.any(Array), + ); + }); + }); + it('rejects an empty identifier list', async () => { await withController(async ({ controller }) => { await expect(controller.register(SECRET, [])).rejects.toThrow( @@ -217,6 +238,27 @@ describe('MfaRecoveryController', () => { }); }); + describe('configured escrows', () => { + it('uses a snapshot of the configured escrow array', async () => { + await withController( + async ({ controller, escrowA, escrowB, options }) => { + options.escrows.splice(0, options.escrows.length); + + await controller.register(SECRET, [PASSKEY]); + + expect(await escrowA.getRecoveryMetadata('profile-1')).toStrictEqual({ + version: 1, + lastMutationId: expect.any(String), + }); + expect(await escrowB.getRecoveryMetadata('profile-1')).toStrictEqual({ + version: 1, + lastMutationId: expect.any(String), + }); + }, + ); + }); + }); + describe('updateRecoverySecret', () => { it('replaces the secret at every escrow', async () => { await withController(async ({ controller }) => { @@ -295,25 +337,68 @@ describe('MfaRecoveryController', () => { }); }); + it('repairs only the missing replica when an acknowledged replica is offline', async () => { + await withController(async ({ controller, escrowA, escrowB }) => { + await controller.register(SECRET, [PASSKEY]); + escrowA.failNextApplyCount = 1; + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2), + ).rejects.toBeInstanceOf(MutationRepairPendingError); + + escrowB.available = false; + + await controller.resume(); + + expect(await controller.getPhase()).toBe('idle'); + expect(bytesToHex(await controller.getRecoverySecret(PASSKEY))).toBe( + bytesToHex(SECRET_2), + ); + }); + }); + + it('clears a fully acknowledged mutation without checking escrow availability', async () => { + await withController(async ({ controller, escrowA, escrowB }) => { + await controller.register(SECRET, [PASSKEY]); + const pending = await getValidWritingPending(); + pending.receipts = ['escrow-a', 'escrow-b'].map( + (escrowId): MutationReceipt => ({ + mutationId: pending.mutation.id, + requestHash: pending.mutation.requestHash, + escrowId, + version: pending.mutation.newVersion, + signature: '0xsignature', + }), + ); + const encrypted = await passthroughEncryptor.encrypt(pending); + const availabilityA = jest.spyOn(escrowA, 'isAvailable'); + const availabilityB = jest.spyOn(escrowB, 'isAvailable'); + availabilityA.mockClear(); + availabilityB.mockClear(); + escrowA.available = false; + escrowB.available = false; + + const resumed = new MfaRecoveryController({ + ...getOptions(escrowA, escrowB), + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: encrypted }, + }); + + await resumed.resume(); + + expect(await resumed.getPhase()).toBe('idle'); + expect(availabilityA).not.toHaveBeenCalled(); + expect(availabilityB).not.toHaveBeenCalled(); + }); + }); + it('aborts an authorizing mutation and refuses to abort writing', async () => { await withController(async ({ options }) => { + const validPending = await getValidWritingPending(); const authorizing = await passthroughEncryptor.encrypt({ phase: 'authorizing', - mutation: { - id: '0xmut', - profileId: 'profile-1', - operation: 'register', - expectedVersion: 0, - newVersion: 1, - payloadHash: '0x', - audiences: ['escrow-a', 'escrow-b'], - requestHash: '0x', - }, - payload: { - recoverySecret: bytesToHex(SECRET), - identifiers: [PASSKEY], - }, - identifier: null, + mutation: validPending.mutation, + payload: validPending.payload, + identifier: validPending.identifier, }); const authorizingMessenger = getMessenger(getRootMessenger()); const idleController = new MfaRecoveryController({ @@ -325,32 +410,7 @@ describe('MfaRecoveryController', () => { await idleController.abort(); expect(await idleController.getPhase()).toBe('idle'); - const writing = await passthroughEncryptor.encrypt({ - phase: 'writing', - mutation: { - id: '0xmut', - profileId: 'profile-1', - operation: 'register', - expectedVersion: 0, - newVersion: 1, - payloadHash: '0x', - audiences: ['escrow-a', 'escrow-b'], - requestHash: '0x', - }, - authControllerToken: { - profileId: 'profile-1', - requestHash: '0x', - issuer: 'stub-auth', - expiresAt: Date.now() + 60_000, - signature: 'stub-auth-signature', - }, - payload: { - recoverySecret: bytesToHex(SECRET), - identifiers: [PASSKEY], - }, - identifier: null, - receipts: [], - }); + const writing = await passthroughEncryptor.encrypt(validPending); const writingMessenger = getMessenger(getRootMessenger()); const writingController = new MfaRecoveryController({ ...options, @@ -403,6 +463,47 @@ describe('MfaRecoveryController', () => { }); }); + it('resumes a persisted authorizing updateIdentifiers mutation', async () => { + await withController(async ({ controller, options }) => { + await controller.register(SECRET, [PASSKEY]); + const payload = { identifiers: [EMAIL] }; + const payloadHash = await hash(payload); + const mutationFields = { + id: '0xmutidentifiers', + profileId: 'profile-1', + operation: 'updateIdentifiers' as const, + expectedVersion: 1, + newVersion: 2, + payloadHash, + audiences: ['escrow-a', 'escrow-b'], + }; + const mutation = { + ...mutationFields, + requestHash: await hash(mutationFields), + }; + const authorizing = { + phase: 'authorizing' as const, + mutation, + payload, + identifier: PASSKEY, + }; + const resumed = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { + pendingOperation: await passthroughEncryptor.encrypt(authorizing), + }, + }); + + await resumed.resume(); + + expect(await resumed.getPhase()).toBe('idle'); + expect(bytesToHex(await resumed.getRecoverySecret(EMAIL))).toBe( + bytesToHex(SECRET), + ); + }); + }); + it('is a no-op to resume or abort when idle', async () => { await withController(async ({ controller }) => { await controller.resume(); @@ -553,21 +654,35 @@ describe('MfaRecoveryController', () => { identifiers: [PASSKEY], }; const payloadHash = await hash(payload); + const mutation = { + id: '0xmut', + profileId: 'profile-1', + operation: 'register' as const, + expectedVersion: 0, + newVersion: 1, + payloadHash, + audiences: ['escrow-a', 'escrow-b'], + requestHash: '', + }; + mutation.requestHash = await hash({ + id: mutation.id, + profileId: mutation.profileId, + operation: mutation.operation, + expectedVersion: mutation.expectedVersion, + newVersion: mutation.newVersion, + payloadHash: mutation.payloadHash, + audiences: mutation.audiences, + }); const writing = await passthroughEncryptor.encrypt({ phase: 'writing', - mutation: { - id: '0xmut', - profileId: 'profile-1', - operation: 'register', - expectedVersion: 0, - newVersion: 1, - payloadHash, - audiences: ['escrow-a', 'escrow-b'], - requestHash: '0x', - }, + mutation, authControllerToken: { profileId: 'profile-1', - requestHash: '0x', + requestHash: mutation.requestHash, + identifiersHash: await hash( + canonicalizeIdentifiers(payload.identifiers), + ), + identifierOwnershipApproved: true, issuer: 'stub-auth', expiresAt: Date.now() + 60_000, signature: 'stub-auth-signature', @@ -577,7 +692,7 @@ describe('MfaRecoveryController', () => { receipts: [ { mutationId: '0xmut', - requestHash: '0x', + requestHash: mutation.requestHash, escrowId: 'escrow-z', version: 1, signature: '0x', @@ -603,21 +718,35 @@ describe('MfaRecoveryController', () => { identifiers: [PASSKEY], }; const payloadHash = await hash(payload); + const mutation = { + id: '0xmut', + profileId: 'profile-1', + operation: 'register' as const, + expectedVersion: 0, + newVersion: 1, + payloadHash, + audiences: ['escrow-a', 'escrow-b'], + requestHash: '', + }; + mutation.requestHash = await hash({ + id: mutation.id, + profileId: mutation.profileId, + operation: mutation.operation, + expectedVersion: mutation.expectedVersion, + newVersion: mutation.newVersion, + payloadHash: mutation.payloadHash, + audiences: mutation.audiences, + }); const writing = await passthroughEncryptor.encrypt({ phase: 'writing', - mutation: { - id: '0xmut', - profileId: 'profile-1', - operation: 'register', - expectedVersion: 0, - newVersion: 1, - payloadHash, - audiences: ['escrow-a', 'escrow-b'], - requestHash: '0x', - }, + mutation, authControllerToken: { profileId: 'profile-1', - requestHash: '0x', + requestHash: mutation.requestHash, + identifiersHash: await hash( + canonicalizeIdentifiers(payload.identifiers), + ), + identifierOwnershipApproved: true, issuer: 'stub-auth', expiresAt: Date.now() + 60_000, signature: 'stub-auth-signature', @@ -627,7 +756,7 @@ describe('MfaRecoveryController', () => { receipts: [ { mutationId: '0xmut', - requestHash: '0x', + requestHash: mutation.requestHash, escrowId: 'escrow-a', version: 1, signature: '0x', @@ -654,6 +783,58 @@ describe('MfaRecoveryController', () => { }); }); + it('persists valid receipts before reporting an invalid receipt', async () => { + await withController(async ({ controller, escrowA, escrowB }) => { + escrowA.invalidReceipts = true; + + await expect(controller.register(SECRET, [PASSKEY])).rejects.toThrow( + 'Invalid mutation receipt', + ); + + const pending = JSON.parse( + controller.state.pendingOperation as string, + ) as { + receipts: { escrowId: string }[]; + }; + expect(pending.receipts).toStrictEqual([ + expect.objectContaining({ escrowId: 'escrow-b' }), + ]); + expect(await escrowB.getRecoveryMetadata('profile-1')).toStrictEqual({ + version: 1, + lastMutationId: expect.any(String), + }); + }); + }); + + it('rejects a receipt whose escrow id does not match its target', async () => { + await withController(async ({ controller, escrowA }) => { + jest.spyOn(escrowA, 'applyMutation').mockResolvedValue({ + mutationId: '0xmutation', + requestHash: '0xrequest', + escrowId: 'escrow-z', + version: 1, + signature: '0xsignature', + }); + jest.spyOn(escrowA, 'verifyReceipt').mockReturnValue(true); + + await expect(controller.register(SECRET, [PASSKEY])).rejects.toThrow( + 'Invalid mutation receipt', + ); + }); + }); + + it('treats receipt verification errors as invalid receipts', async () => { + await withController(async ({ controller, escrowA }) => { + jest.spyOn(escrowA, 'verifyReceipt').mockImplementation(() => { + throw new Error('verification failed'); + }); + + await expect(controller.register(SECRET, [PASSKEY])).rejects.toThrow( + 'Invalid mutation receipt', + ); + }); + }); + it('refreshes an expired AuthController token while resuming', async () => { await withController(async ({ controller, escrowA, options }) => { await controller.register(SECRET, [PASSKEY]); @@ -674,6 +855,207 @@ describe('MfaRecoveryController', () => { expect(await resumed.getPhase()).toBe('idle'); }); }); + + it('rejects a null decrypted pending operation before external calls', async () => { + await withController(async ({ escrowA, escrowB, options }) => { + const auth = jest.spyOn( + options.authProvider, + 'authorizeRecoveryRequest', + ); + const availabilityA = jest.spyOn(escrowA, 'isAvailable'); + const availabilityB = jest.spyOn(escrowB, 'isAvailable'); + const controller = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: 'null' }, + }); + + await expect(controller.resume()).rejects.toThrow( + 'Invalid pending operation', + ); + + expect(auth).not.toHaveBeenCalled(); + expect(availabilityA).not.toHaveBeenCalled(); + expect(availabilityB).not.toHaveBeenCalled(); + }); + }); + + it.each([ + { + name: 'an invalid phase', + mutate: (pending: Record): void => { + pending.phase = 'invalid'; + }, + }, + { + name: 'a malformed mutation', + mutate: (pending: Record): void => { + pending.mutation = null; + }, + }, + { + name: 'an operation and payload mismatch', + mutate: (pending: Record): void => { + const mutation = pending.mutation as Record; + mutation.operation = 'updateIdentifiers'; + pending.payload = { recoverySecret: bytesToHex(SECRET) }; + }, + }, + { + name: 'a malformed payload', + mutate: (pending: Record): void => { + pending.payload = null; + }, + }, + { + name: 'a payload without a recovery secret', + mutate: (pending: Record): void => { + pending.payload = {}; + }, + }, + { + name: 'an identifier payload with invalid entries', + mutate: (pending: Record): void => { + const mutation = pending.mutation as Record; + mutation.operation = 'updateIdentifiers'; + pending.payload = { identifiers: [{}] }; + }, + }, + { + name: 'a non-null register identifier', + mutate: (pending: Record): void => { + const mutation = pending.mutation as Record; + mutation.operation = 'register'; + pending.payload = { + recoverySecret: bytesToHex(SECRET), + identifiers: [PASSKEY], + }; + pending.identifier = PASSKEY; + }, + }, + { + name: 'a null update identifier', + mutate: (pending: Record): void => { + pending.identifier = null; + }, + }, + { + name: 'an unknown identifier type', + mutate: (pending: Record): void => { + pending.identifier = { + type: 'unknown', + namespace: 'example.com', + value: 'unknown', + verifier: null, + }; + }, + }, + { + name: 'a non-consecutive version', + mutate: (pending: Record): void => { + const mutation = pending.mutation as Record; + mutation.expectedVersion = 4; + mutation.newVersion = 6; + }, + }, + { + name: 'a mismatched payload hash', + mutate: (pending: Record): void => { + const mutation = pending.mutation as Record; + mutation.payloadHash = '0xwrong-payload-hash'; + }, + }, + { + name: 'a mismatched mutation request hash', + mutate: (pending: Record): void => { + const mutation = pending.mutation as Record; + mutation.requestHash = '0xwrong-request-hash'; + }, + }, + { + name: 'an out-of-order audience list', + mutate: (pending: Record): void => { + const mutation = pending.mutation as Record; + mutation.audiences = ['escrow-b', 'escrow-a']; + }, + }, + { + name: 'an invalid AuthController token', + mutate: (pending: Record): void => { + const token = pending.authControllerToken as Record; + token.requestHash = '0xwrong-token-request-hash'; + }, + }, + { + name: 'a malformed AuthController token', + mutate: (pending: Record): void => { + pending.authControllerToken = null; + }, + }, + { + name: 'an AuthController token with invalid fields', + mutate: (pending: Record): void => { + pending.authControllerToken = {}; + }, + }, + { + name: 'an invalid receipt array', + mutate: (pending: Record): void => { + pending.receipts = null; + }, + }, + { + name: 'a receipt with invalid fields', + mutate: (pending: Record): void => { + pending.receipts = [{}]; + }, + }, + { + name: 'duplicate receipts', + mutate: (pending: Record): void => { + const mutation = pending.mutation as Record; + const receipt = { + mutationId: mutation.id, + requestHash: mutation.requestHash, + escrowId: 'escrow-a', + version: mutation.newVersion, + signature: '0xsignature', + }; + pending.receipts = [receipt, receipt]; + }, + }, + ])( + 'rejects persisted state with $name before external calls', + async ({ mutate }) => { + await withController(async ({ escrowA, escrowB, options }) => { + const pending = JSON.parse( + JSON.stringify(await getValidWritingPending()), + ) as Record; + mutate(pending); + const auth = jest.spyOn( + options.authProvider, + 'authorizeRecoveryRequest', + ); + const availabilityA = jest.spyOn(escrowA, 'isAvailable'); + const availabilityB = jest.spyOn(escrowB, 'isAvailable'); + const applyA = jest.spyOn(escrowA, 'applyMutation'); + const applyB = jest.spyOn(escrowB, 'applyMutation'); + const controller = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: JSON.stringify(pending) }, + }); + + await expect(controller.resume()).rejects.toThrow(/./u); + + expect(auth).not.toHaveBeenCalled(); + expect(availabilityA).not.toHaveBeenCalled(); + expect(availabilityB).not.toHaveBeenCalled(); + expect(applyA).not.toHaveBeenCalled(); + expect(applyB).not.toHaveBeenCalled(); + }); + }, + ); }); describe('identifier registry', () => { @@ -717,6 +1099,52 @@ function getMessenger( }); } +function getOptions( + escrowA: StubEscrowProvider, + escrowB: StubEscrowProvider, +): MfaRecoveryControllerOptions { + return { + messenger: getMessenger(getRootMessenger()), + authProvider: new StubAuthProvider(), + identifierAuthProvider: new StubIdentifierAuthProvider(), + escrows: [escrowA, escrowB], + pendingOperationEncryptor: passthroughEncryptor, + collectChallengeResponse: async (): Promise => 'otp', + }; +} + +async function getValidWritingPending(): Promise { + const payload = { recoverySecret: bytesToHex(SECRET_2) }; + const payloadHash = await hash(payload); + const fields = { + id: '0xvalid-pending', + profileId: 'profile-1', + operation: 'updateRecoverySecret' as const, + expectedVersion: 1, + newVersion: 2, + payloadHash, + audiences: ['escrow-a', 'escrow-b'], + }; + return { + phase: 'writing', + mutation: { + ...fields, + requestHash: await hash(fields), + }, + authControllerToken: { + profileId: 'profile-1', + requestHash: await hash(fields), + twoFactor: true, + issuer: 'stub-auth', + expiresAt: Date.now() + 60_000, + signature: 'stub-auth-signature', + }, + payload, + identifier: PASSKEY, + receipts: [], + }; +} + async function withController( testFunction: WithControllerCallback, ): Promise { @@ -724,13 +1152,9 @@ async function withController( const controllerMessenger = getMessenger(rootMessenger); const escrowA = new StubEscrowProvider('escrow-a'); const escrowB = new StubEscrowProvider('escrow-b'); - const options: MfaRecoveryControllerOptions = { + const options = { + ...getOptions(escrowA, escrowB), messenger: controllerMessenger, - authProvider: new StubAuthProvider(), - identifierAuthProvider: new StubIdentifierAuthProvider(), - escrows: [escrowA, escrowB], - pendingOperationEncryptor: passthroughEncryptor, - collectChallengeResponse: async (): Promise => 'otp', }; const controller = new MfaRecoveryController(options); return await testFunction({ diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index caecc457634..dbfd098535e 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -1,11 +1,10 @@ import type { ControllerGetStateAction, - ControllerStateChangeEvent, + ControllerStateChangedEvent, StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; -import { areUint8ArraysEqual } from '@metamask/utils'; import { bytesToSecretHex, @@ -15,13 +14,16 @@ import { sign, } from './crypto.js'; import { MfaRecoveryError, MutationRepairPendingError } from './errors.js'; +import { + isFulfilledResult, + isMutationReceipt, + selectHighestConsistentVersion, + verifyMutationReceipt, +} from './escrow-utils.js'; import { getIdentifierAuthMode } from './identifier-auth.js'; import type { MfaRecoveryControllerMethodActions } from './MfaRecoveryController-method-action-types.js'; -import { - assertAbortAllowed, - assertSameAudienceIds, - getRecoveryPhase, -} from './state-machine.js'; +import { assertValidPendingOperation } from './pending-operation-validation.js'; +import { assertAbortAllowed, getRecoveryPhase } from './state-machine.js'; import type { AuthControllerToken, EscrowAuthChallenge, @@ -103,12 +105,14 @@ export type MfaRecoveryControllerActions = type AllowedActions = never; -export type MfaRecoveryControllerStateChangeEvent = ControllerStateChangeEvent< - typeof CONTROLLER_NAME, - MfaRecoveryControllerState ->; +export type MfaRecoveryControllerStateChangedEvent = + ControllerStateChangedEvent< + typeof CONTROLLER_NAME, + MfaRecoveryControllerState + >; -export type MfaRecoveryControllerEvents = MfaRecoveryControllerStateChangeEvent; +export type MfaRecoveryControllerEvents = + MfaRecoveryControllerStateChangedEvent; type AllowedEvents = never; @@ -179,6 +183,8 @@ export class MfaRecoveryController extends BaseController< readonly #escrows: RecoveryEscrowProvider[]; + readonly #escrowIds: string[]; + readonly #encryptor: PendingOperationEncryptor; readonly #collectChallengeResponse: ( @@ -205,8 +211,8 @@ export class MfaRecoveryController extends BaseController< 'empty_escrow_set', ); } - const ids = escrows.map((escrow) => escrow.id); - if (new Set(ids).size !== ids.length) { + const escrowIds = escrows.map((escrow) => escrow.id); + if (new Set(escrowIds).size !== escrowIds.length) { throw new MfaRecoveryError('Duplicate escrow id', 'duplicate_escrow_id'); } @@ -222,7 +228,8 @@ export class MfaRecoveryController extends BaseController< this.#authProvider = authProvider; this.#identifierAuthProvider = identifierAuthProvider; - this.#escrows = escrows; + this.#escrows = [...escrows]; + this.#escrowIds = [...escrowIds]; this.#encryptor = pendingOperationEncryptor; this.#collectChallengeResponse = collectChallengeResponse; this.#now = now; @@ -335,7 +342,7 @@ export class MfaRecoveryController extends BaseController< ...(await escrow.getSecret(authorizations[index], requestId)), })), ); - return this.#selectHighestConsistentVersion(results).recoverySecret; + return selectHighestConsistentVersion(results).recoverySecret; }); } @@ -383,7 +390,7 @@ export class MfaRecoveryController extends BaseController< await this.#repairPendingMutation(configured); const profileId = await this.#authProvider.getAuthenticatedProfileId(); const targets = await this.#requireAllEscrows(configured); - const audiences = configured.map((escrow) => escrow.id); + const audiences = [...this.#escrowIds]; const currentVersion = await this.#resolveCurrentRecoveryVersion({ operation, profileId, @@ -464,55 +471,54 @@ export class MfaRecoveryController extends BaseController< if (!saved) { return; } - assertSameAudienceIds( - saved.mutation.audiences, - escrows.map((escrow) => escrow.id), - ); - const authControllerToken = - saved.phase === 'authorizing' || - this.#tokenNeedsRefresh(saved.authControllerToken) - ? await this.#authorizeMutation(saved.mutation, saved.payload) - : saved.authControllerToken; - const pending: WritingPendingOperation = { - phase: 'writing', - mutation: saved.mutation, - authControllerToken, - payload: saved.payload, - identifier: saved.identifier, - receipts: saved.phase === 'writing' ? saved.receipts : [], - }; - await this.#persistPending(pending); - await this.#replicateMutation({ - escrows: await this.#requireAllEscrows(escrows), - pending, - }); + if (saved.phase === 'authorizing') { + const availableEscrows = await this.#requireAllEscrows(escrows); + const pending: WritingPendingOperation = { + phase: 'writing', + mutation: saved.mutation, + authControllerToken: await this.#authorizeMutation( + saved.mutation, + saved.payload, + ), + payload: saved.payload, + identifier: saved.identifier, + receipts: [], + }; + await this.#persistPending(pending); + await this.#replicateMutation({ + escrows: availableEscrows, + pending, + availabilityChecked: true, + }); + return; + } + + await this.#replicateMutation({ escrows, pending: saved }); } async #replicateMutation({ escrows, pending, + availabilityChecked = false, }: { escrows: RecoveryEscrowProvider[]; pending: WritingPendingOperation; + availabilityChecked?: boolean; }): Promise { const { mutation, payload, identifier } = pending; - const expectedPayloadHash = await hash(payload); - if (expectedPayloadHash !== mutation.payloadHash) { - throw new MfaRecoveryError( - 'Pending payload does not match mutation', - 'payload_mismatch', - ); - } - const configuredIds = new Set(escrows.map((escrow) => escrow.id)); + const entries = escrows.map((escrow) => ({ + escrow, + id: this.#getConfiguredEscrowId(escrow), + })); pending.receipts.forEach((receipt) => { - const escrow = escrows.find((item) => item.id === receipt.escrowId); - if (!configuredIds.has(receipt.escrowId) || escrow === undefined) { + const entry = entries.find((item) => item.id === receipt.escrowId); + if (entry === undefined) { throw new MfaRecoveryError( 'Receipt escrow is not configured', 'unknown_receipt_escrow', ); } - if (!escrow.verifyReceipt(receipt, mutation)) { + if (!verifyMutationReceipt(receipt, mutation, entry.escrow, entry.id)) { throw new MfaRecoveryError( 'Invalid mutation receipt', 'invalid_receipt', @@ -522,44 +528,69 @@ export class MfaRecoveryController extends BaseController< const acknowledged = new Set( pending.receipts.map((receipt) => receipt.escrowId), ); - const targets = escrows.filter((escrow) => !acknowledged.has(escrow.id)); + const targets = entries + .filter((entry) => !acknowledged.has(entry.id)) + .map((entry) => entry.escrow); if (targets.length === 0) { await this.#clearPending(); return; } + const availableTargets = availabilityChecked + ? targets + : await this.#requireAllEscrows(targets); + const refreshedToken = this.#tokenNeedsRefresh(pending.authControllerToken) + ? await this.#authorizeMutation(mutation, payload) + : pending.authControllerToken; + const effectivePending = + refreshedToken === pending.authControllerToken + ? pending + : { ...pending, authControllerToken: refreshedToken }; + if (effectivePending !== pending) { + await this.#persistPending(effectivePending); + } const authorizations = mutation.operation === 'register' - ? targets.map(() => null) + ? availableTargets.map(() => null) : await this.#authorizeIdentifier({ - escrows: targets, + escrows: availableTargets, identifier: identifier as Identifier, requestHash: mutation.requestHash, }); const results = await Promise.allSettled( - targets.map((escrow, index) => + availableTargets.map((escrow, index) => escrow.applyMutation( mutation, - pending.authControllerToken, + effectivePending.authControllerToken, authorizations[index], payload, ), ), ); - const receipts: MutationReceipt[] = [...pending.receipts]; + const receipts: MutationReceipt[] = [...effectivePending.receipts]; + let hasInvalidReceipt = false; results.forEach((result, index) => { - if (result.status === 'fulfilled') { - const escrow = targets[index]; - if (!escrow.verifyReceipt(result.value, mutation)) { - throw new MfaRecoveryError( - 'Invalid mutation receipt', - 'invalid_receipt', - ); - } - receipts.push(result.value); + if (!isFulfilledResult(result)) { + return; + } + const entry = { + escrow: availableTargets[index], + id: this.#getConfiguredEscrowId(availableTargets[index]), + }; + if ( + !isMutationReceipt(result.value) || + !verifyMutationReceipt(result.value, mutation, entry.escrow, entry.id) + ) { + hasInvalidReceipt = true; + return; } + receipts.push(result.value); }); - await this.#persistPending({ ...pending, receipts }); + await this.#persistPending({ ...effectivePending, receipts }); + + if (hasInvalidReceipt) { + throw new MfaRecoveryError('Invalid mutation receipt', 'invalid_receipt'); + } if ( new Set(receipts.map((receipt) => receipt.escrowId)).size !== @@ -589,7 +620,7 @@ export class MfaRecoveryController extends BaseController< requestHash, }); const challenges = await Promise.all( - escrows.map((escrow) => escrow.generateChallenge()), + escrows.map((escrow) => escrow.generateChallenge(proofKey.publicKey)), ); return await Promise.all( challenges.map(async (challenge) => { @@ -675,42 +706,6 @@ export class MfaRecoveryController extends BaseController< return selected.version; } - #selectHighestConsistentVersion( - results: PromiseSettledResult<{ - escrowId: string; - recoverySecret: Uint8Array; - version: number; - lastMutationId: string; - }>[], - ): { recoverySecret: Uint8Array; version: number; lastMutationId: string } { - const responses = results - .filter((result) => result.status === 'fulfilled') - .map((result) => result.value); - if (responses.length === 0) { - throw new MfaRecoveryError( - 'No escrow returned a recovery secret', - 'read_failed', - ); - } - const highestVersion = Math.max( - ...responses.map((response) => response.version), - ); - const highest = responses.filter( - (response) => response.version === highestVersion, - ); - const selected = highest[0]; - if ( - !highest.every( - (response) => - response.lastMutationId === selected.lastMutationId && - areUint8ArraysEqual(response.recoverySecret, selected.recoverySecret), - ) - ) { - throw new MfaRecoveryError('Replica corruption', 'replica_corruption'); - } - return selected; - } - async #getAvailableEscrows(): Promise { const flags = await Promise.all( this.#escrows.map(async (escrow) => ({ @@ -754,7 +749,15 @@ export class MfaRecoveryController extends BaseController< if (pendingOperation === null) { return null; } - return await this.#encryptor.decrypt(pendingOperation); + const pending = (await this.#encryptor.decrypt( + pendingOperation, + )) as unknown; + await assertValidPendingOperation(pending, this.#escrowIds); + return pending as PendingOperation; + } + + #getConfiguredEscrowId(escrow: RecoveryEscrowProvider): string { + return this.#escrowIds[this.#escrows.indexOf(escrow)]; } async #persistPending(operation: PendingOperation): Promise { diff --git a/packages/mfa-recovery-controller/src/escrow-utils.ts b/packages/mfa-recovery-controller/src/escrow-utils.ts new file mode 100644 index 00000000000..7566fd68039 --- /dev/null +++ b/packages/mfa-recovery-controller/src/escrow-utils.ts @@ -0,0 +1,116 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; + +import { MfaRecoveryError } from './errors.js'; +import type { + Mutation, + MutationReceipt, + RecoveryEscrowProvider, +} from './types.js'; + +export type RecoverySecretResult = { + escrowId: string; + recoverySecret: Uint8Array; + version: number; + lastMutationId: string; +}; + +/** + * Identifies a fulfilled promise result for TypeScript narrowing. + * + * @param result - Settled promise result. + * @returns Whether the result is fulfilled. + */ +export function isFulfilledResult( + result: PromiseSettledResult, +): result is PromiseFulfilledResult { + return result.status === 'fulfilled'; +} + +/** + * Checks the serialized shape of a mutation receipt. + * + * @param value - Decrypted or provider-returned value. + * @returns Whether the value has a mutation receipt shape. + */ +export function isMutationReceipt(value: unknown): value is MutationReceipt { + return ( + isRecord(value) && + typeof value.mutationId === 'string' && + typeof value.requestHash === 'string' && + typeof value.escrowId === 'string' && + typeof value.version === 'number' && + Number.isInteger(value.version) && + value.version >= 0 && + typeof value.signature === 'string' + ); +} + +/** + * Verifies a receipt against its expected target escrow. + * + * The provider performs cryptographic verification using its build-pinned + * configuration; the target identity check is performed here as well. + * + * @param receipt - Receipt returned by an escrow. + * @param mutation - Mutation the receipt must acknowledge. + * @param escrow - Expected target escrow provider. + * @param expectedEscrowId - Build-configured identity of the target escrow. + * @returns Whether the receipt is valid for the target. + */ +export function verifyMutationReceipt( + receipt: MutationReceipt, + mutation: Mutation, + escrow: RecoveryEscrowProvider, + expectedEscrowId: string, +): boolean { + if (receipt.escrowId !== expectedEscrowId) { + return false; + } + try { + return escrow.verifyReceipt(receipt, mutation, expectedEscrowId); + } catch { + return false; + } +} + +/** + * Selects the highest consistent successful recovery-secret response. + * + * @param results - Settled escrow read results. + * @returns The selected recovery-secret response. + * @throws If no response succeeds or matching highest versions disagree. + */ +export function selectHighestConsistentVersion( + results: PromiseSettledResult[], +): Omit { + const responses = results + .filter(isFulfilledResult) + .map((result) => result.value); + if (responses.length === 0) { + throw new MfaRecoveryError( + 'No escrow returned a recovery secret', + 'read_failed', + ); + } + const highestVersion = Math.max( + ...responses.map((response) => response.version), + ); + const highest = responses.filter( + (response) => response.version === highestVersion, + ); + const selected = highest[0]; + if ( + !highest.every( + (response) => + response.lastMutationId === selected.lastMutationId && + areUint8ArraysEqual(response.recoverySecret, selected.recoverySecret), + ) + ) { + throw new MfaRecoveryError('Replica corruption', 'replica_corruption'); + } + return selected; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts index 257996841ce..cf8f807feab 100644 --- a/packages/mfa-recovery-controller/src/index.ts +++ b/packages/mfa-recovery-controller/src/index.ts @@ -9,7 +9,7 @@ export type { MfaRecoveryControllerMessenger, MfaRecoveryControllerOptions, MfaRecoveryControllerState, - MfaRecoveryControllerStateChangeEvent, + MfaRecoveryControllerStateChangedEvent, } from './MfaRecoveryController.js'; export type { MfaRecoveryControllerAbortAction, diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts new file mode 100644 index 00000000000..6b4f5789f28 --- /dev/null +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -0,0 +1,214 @@ +import { hash } from './crypto.js'; +import { MfaRecoveryError } from './errors.js'; +import { isMutationReceipt } from './escrow-utils.js'; +import { getIdentifierAuthMode } from './identifier-auth.js'; +import { assertSameAudienceIds } from './state-machine.js'; +import type { + AuthControllerToken, + Identifier, + Mutation, + MutationPayload, + MutationReceipt, + RegisterPayload, + UpdateIdentifiersPayload, + UpdateRecoverySecretPayload, +} from './types.js'; + +/** + * Validates decrypted pending state before it can trigger authorization or + * escrow operations. + * + * @param pending - Decrypted pending state. + * @param escrowIds - Build-configured escrow ids. + * @throws If the pending state is malformed or inconsistent. + */ +export async function assertValidPendingOperation( + pending: unknown, + escrowIds: string[], +): Promise { + if (!isRecord(pending)) { + throwInvalidPendingOperation(); + } + if (pending.phase !== 'authorizing' && pending.phase !== 'writing') { + throwInvalidPendingOperation(); + } + if (!isRecord(pending.mutation)) { + throwInvalidPendingOperation(); + } + + const mutationValue = pending.mutation; + if ( + typeof mutationValue.id !== 'string' || + typeof mutationValue.profileId !== 'string' || + !isMutationOperation(mutationValue.operation) || + typeof mutationValue.expectedVersion !== 'number' || + !Number.isInteger(mutationValue.expectedVersion) || + mutationValue.expectedVersion < 0 || + typeof mutationValue.newVersion !== 'number' || + !Number.isInteger(mutationValue.newVersion) || + mutationValue.newVersion < 0 || + mutationValue.newVersion !== mutationValue.expectedVersion + 1 || + typeof mutationValue.payloadHash !== 'string' || + typeof mutationValue.requestHash !== 'string' || + !Array.isArray(mutationValue.audiences) || + !mutationValue.audiences.every((audience): audience is string => { + return typeof audience === 'string'; + }) + ) { + throwInvalidPendingOperation(); + } + const mutation = mutationValue as unknown as Mutation; + assertSameAudienceIds(mutation.audiences, escrowIds); + + const { payload } = pending; + if (!isMutationPayload(mutation.operation, payload)) { + throwInvalidPendingOperation(); + } + const payloadIdentifiers = + mutation.operation === 'updateRecoverySecret' + ? [] + : (payload as RegisterPayload | UpdateIdentifiersPayload).identifiers; + for (const identifier of payloadIdentifiers) { + getIdentifierAuthMode(identifier.type); + } + + if ( + mutation.operation === 'register' + ? pending.identifier !== null + : !isIdentifier(pending.identifier) + ) { + throwInvalidPendingOperation(); + } + if (mutation.operation !== 'register' && isIdentifier(pending.identifier)) { + getIdentifierAuthMode(pending.identifier.type); + } + + if ((await hash(payload)) !== mutation.payloadHash) { + throw new MfaRecoveryError( + 'Pending payload does not match mutation', + 'payload_mismatch', + ); + } + const requestHash = await hash({ + id: mutation.id, + profileId: mutation.profileId, + operation: mutation.operation, + expectedVersion: mutation.expectedVersion, + newVersion: mutation.newVersion, + payloadHash: mutation.payloadHash, + audiences: mutation.audiences, + }); + if (requestHash !== mutation.requestHash) { + throwInvalidPendingOperation(); + } + + if (pending.phase !== 'writing') { + return; + } + if ( + !isAuthControllerToken(pending.authControllerToken) || + pending.authControllerToken.profileId !== mutation.profileId || + pending.authControllerToken.requestHash !== mutation.requestHash || + (mutation.operation !== 'register' && + pending.authControllerToken.twoFactor !== true) || + ((mutation.operation === 'register' || + mutation.operation === 'updateIdentifiers') && + (pending.authControllerToken.identifierOwnershipApproved !== true || + typeof pending.authControllerToken.identifiersHash !== 'string')) || + !isMutationReceiptArray(pending.receipts) + ) { + throwInvalidPendingOperation(); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isMutationOperation(value: unknown): value is Mutation['operation'] { + return ( + value === 'register' || + value === 'updateRecoverySecret' || + value === 'updateIdentifiers' + ); +} + +function isMutationPayload( + operation: Mutation['operation'], + payload: unknown, +): payload is MutationPayload { + if (!isRecord(payload)) { + return false; + } + if (operation === 'updateRecoverySecret') { + return ( + Object.keys(payload).every((key) => key === 'recoverySecret') && + typeof (payload as UpdateRecoverySecretPayload).recoverySecret === + 'string' + ); + } + const { identifiers } = payload as RegisterPayload | UpdateIdentifiersPayload; + return ( + Object.keys(payload).every((key) => + operation === 'register' + ? key === 'recoverySecret' || key === 'identifiers' + : key === 'identifiers', + ) && + (operation === 'register' + ? typeof (payload as RegisterPayload).recoverySecret === 'string' + : true) && + Array.isArray(identifiers) && + identifiers.length > 0 && + identifiers.every(isIdentifier) + ); +} + +function isIdentifier(value: unknown): value is Identifier { + return ( + isRecord(value) && + typeof value.type === 'string' && + typeof value.namespace === 'string' && + typeof value.value === 'string' && + Object.prototype.hasOwnProperty.call(value, 'verifier') + ); +} + +function isAuthControllerToken(value: unknown): value is AuthControllerToken { + if (!isRecord(value)) { + return false; + } + return ( + typeof value.profileId === 'string' && + typeof value.requestHash === 'string' && + (value.twoFactor === undefined || value.twoFactor === true) && + (value.identifiersHash === undefined || + typeof value.identifiersHash === 'string') && + (value.identifierOwnershipApproved === undefined || + value.identifierOwnershipApproved === true) && + typeof value.issuer === 'string' && + typeof value.expiresAt === 'number' && + Number.isFinite(value.expiresAt) && + typeof value.signature === 'string' + ); +} + +function isMutationReceiptArray(value: unknown): value is MutationReceipt[] { + if (!Array.isArray(value)) { + return false; + } + const escrowIds = new Set(); + for (const receipt of value) { + if (!isMutationReceipt(receipt) || escrowIds.has(receipt.escrowId)) { + return false; + } + escrowIds.add(receipt.escrowId); + } + return true; +} + +function throwInvalidPendingOperation(): never { + throw new MfaRecoveryError( + 'Invalid pending operation', + 'invalid_pending_operation', + ); +} diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index 63d0c1d853a..ffb74f5ea64 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -129,8 +129,12 @@ export type PendingOperation = export type RecoveryPhase = 'idle' | PendingOperation['phase']; /** - * Ciphertext of {@link PendingOperation}. Wallet secure storage encrypts this - * value; a raw recovery secret is never persisted in the clear. + * Encoded ciphertext of {@link PendingOperation}. Wallet secure storage + * encrypts this value; a raw recovery secret is never persisted in the clear. + * + * The recovery spec's pseudocode represents this as `Uint8Array`, but the + * controller stores the encoded ciphertext as a string so BaseController state + * persistence and serialization preserve it without binary coercion. */ export type EncryptedPendingOperation = string; @@ -174,7 +178,7 @@ export type RecoveryIdentifierAuthProvider = { export type RecoveryEscrowProvider = { readonly id: string; isAvailable: () => Promise; - generateChallenge: () => Promise; + generateChallenge: (publicKey: string) => Promise; beginIdentifierAuthentication: (params: { identifier: Identifier; requestHash: string; @@ -196,7 +200,19 @@ export type RecoveryEscrowProvider = { identifierAuthorization: IdentifierAuthorization | null, payload: MutationPayload, ) => Promise; - verifyReceipt: (receipt: MutationReceipt, mutation: Mutation) => boolean; + /** + * Verifies a receipt cryptographically using the build-pinned key for the + * expected escrow and confirms it targets that escrow. + * + * @param receipt - Receipt returned by an escrow. + * @param mutation - Mutation acknowledged by the receipt. + * @param expectedEscrowId - Escrow identity expected by the caller. + */ + verifyReceipt: ( + receipt: MutationReceipt, + mutation: Mutation, + expectedEscrowId: string, + ) => boolean; }; /** diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index 579dcc79558..a8d0443410e 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -379,14 +379,19 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { return await this.#signReceipt(mutation); } - verifyReceipt(receipt: MutationReceipt, mutation: Mutation): boolean { + verifyReceipt( + receipt: MutationReceipt, + mutation: Mutation, + expectedEscrowId: string, + ): boolean { if (this.invalidReceipts) { return false; } return ( receipt.mutationId === mutation.id && receipt.requestHash === mutation.requestHash && - receipt.escrowId === this.id && + receipt.escrowId === expectedEscrowId && + expectedEscrowId === this.id && receipt.version === mutation.newVersion ); } From 91964b3c124aa121a6d3959cb81ddc4ebaa31930 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:23:51 +0800 Subject: [PATCH 03/55] Enhance `MfaRecoveryController` with additional test cases and authorization handling - Added tests to ensure recovery secret retrieval continues despite failures in escrow availability checks and authorization processes. - Updated the controller to handle cases where authorization for one escrow fails, preventing unauthorized writes and ensuring consistent recovery secret retrieval. - Refactored authorization handling to return structured results for better error management and state validation. These changes improve the robustness of the MFA recovery process, ensuring a smoother user experience in MetaMask. --- .../src/MfaRecoveryController.test.ts | 71 ++++++++++++ .../src/MfaRecoveryController.ts | 102 +++++++++++++----- 2 files changed, 144 insertions(+), 29 deletions(-) diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index b2153b8b77c..66dc43a0635 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -204,6 +204,58 @@ describe('MfaRecoveryController', () => { }); }); + it('continues reading when an escrow availability check fails', async () => { + await withController(async ({ controller, escrowA }) => { + await controller.register(SECRET, [PASSKEY]); + jest + .spyOn(escrowA, 'isAvailable') + .mockRejectedValue(new Error('health check failed')); + + const recovered = await controller.getRecoverySecret(PASSKEY); + + expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + }); + }); + + it('continues reading when key-bound authorization fails for one escrow', async () => { + await withController(async ({ controller, escrowA }) => { + await controller.register(SECRET, [PASSKEY]); + jest + .spyOn(escrowA, 'generateChallenge') + .mockRejectedValue(new Error('challenge failed')); + + const recovered = await controller.getRecoverySecret(PASSKEY); + + expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + }); + }); + + it('continues reading when escrow-challenge authorization fails for one escrow', async () => { + await withController(async ({ controller, escrowA }) => { + await controller.register(SECRET, [EMAIL]); + jest + .spyOn(escrowA, 'beginIdentifierAuthentication') + .mockRejectedValue(new Error('challenge failed')); + + const recovered = await controller.getRecoverySecret(EMAIL); + + expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + }); + }); + + it('continues reading when completing one escrow challenge fails', async () => { + await withController(async ({ controller, escrowA }) => { + await controller.register(SECRET, [EMAIL]); + jest + .spyOn(escrowA, 'completeIdentifierAuthentication') + .mockRejectedValue(new Error('grant failed')); + + const recovered = await controller.getRecoverySecret(EMAIL); + + expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + }); + }); + it('throws when matching versions disagree', async () => { await withController(async ({ controller, escrowB }) => { await controller.register(SECRET, [PASSKEY]); @@ -279,6 +331,25 @@ describe('MfaRecoveryController', () => { }); }); + it('does not write when one escrow cannot be authorized', async () => { + await withController(async ({ controller, escrowA, escrowB }) => { + await controller.register(SECRET, [PASSKEY]); + jest + .spyOn(escrowA, 'generateChallenge') + .mockRejectedValue(new Error('challenge failed')); + const applyA = jest.spyOn(escrowA, 'applyMutation'); + const applyB = jest.spyOn(escrowB, 'applyMutation'); + + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2), + ).rejects.toThrow('Unable to authorize every escrow'); + + expect(applyA).not.toHaveBeenCalled(); + expect(applyB).not.toHaveBeenCalled(); + expect(await controller.getPhase()).toBe('writing'); + }); + }); + it('throws when replicas disagree on version', async () => { await withController(async ({ controller, escrowB }) => { await controller.register(SECRET, [PASSKEY]); diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index dbfd098535e..36f70cf33ec 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -71,6 +71,11 @@ const MESSENGER_EXPOSED_METHODS = [ 'getPhase', ] as const; +type AuthorizedEscrow = { + escrow: RecoveryEscrowProvider; + authorization: IdentifierAuthorization; +}; + export type MfaRecoveryControllerState = { /** * Encrypted pending mutation, or `null` when idle. @@ -331,15 +336,15 @@ export class MfaRecoveryController extends BaseController< 'no_available_escrow', ); } - const authorizations = await this.#authorizeIdentifier({ + const authorizedEscrows = await this.#authorizeIdentifier({ escrows: available, identifier, requestHash, }); const results = await Promise.allSettled( - available.map(async (escrow, index) => ({ + authorizedEscrows.map(async ({ escrow, authorization }) => ({ escrowId: escrow.id, - ...(await escrow.getSecret(authorizations[index], requestId)), + ...(await escrow.getSecret(authorization, requestId)), })), ); return selectHighestConsistentVersion(results).recoverySecret; @@ -550,18 +555,27 @@ export class MfaRecoveryController extends BaseController< } const authorizations = mutation.operation === 'register' - ? availableTargets.map(() => null) + ? undefined : await this.#authorizeIdentifier({ escrows: availableTargets, identifier: identifier as Identifier, requestHash: mutation.requestHash, }); + if ( + authorizations !== undefined && + authorizations.length !== availableTargets.length + ) { + throw new MfaRecoveryError( + 'Unable to authorize every escrow', + 'identifier_auth_failed', + ); + } const results = await Promise.allSettled( availableTargets.map((escrow, index) => escrow.applyMutation( mutation, effectivePending.authControllerToken, - authorizations[index], + authorizations?.[index]?.authorization ?? null, payload, ), ), @@ -609,7 +623,7 @@ export class MfaRecoveryController extends BaseController< escrows: RecoveryEscrowProvider[]; identifier: Identifier; requestHash: string; - }): Promise { + }): Promise { const mode = getIdentifierAuthMode(identifier.type); if (mode === 'key-bound') { const proofKey = await generateSigningKey(); @@ -619,44 +633,70 @@ export class MfaRecoveryController extends BaseController< proofPublicKey: proofKey.publicKey, requestHash, }); - const challenges = await Promise.all( + const challengeResults = await Promise.allSettled( escrows.map((escrow) => escrow.generateChallenge(proofKey.publicKey)), ); - return await Promise.all( - challenges.map(async (challenge) => { + const challenges = challengeResults.flatMap((result, index) => + isFulfilledResult(result) + ? [{ escrow: escrows[index], challenge: result.value }] + : [], + ); + const authorizationResults = await Promise.allSettled( + challenges.map(async ({ escrow, challenge }) => { const message = await hash([token, challenge.id, requestHash]); return { - kind: 'key-bound' as const, - token, - proof: { - challengeId: challenge.id, - requestHash, - signature: await sign(proofKey.privateKey, message), + escrow, + authorization: { + kind: 'key-bound' as const, + token, + proof: { + challengeId: challenge.id, + requestHash, + signature: await sign(proofKey.privateKey, message), + }, }, }; }), ); + return authorizationResults + .filter(isFulfilledResult) + .map((result) => result.value); } - const challenges = await Promise.all( + const challengeResults = await Promise.allSettled( escrows.map((escrow) => escrow.beginIdentifierAuthentication({ identifier, requestHash }), ), ); - const responses = await Promise.all( - challenges.map((challenge) => this.#collectChallengeResponse(challenge)), + const challenges = challengeResults.flatMap((result, index) => + isFulfilledResult(result) + ? [{ escrow: escrows[index], challenge: result.value }] + : [], ); - const grants = await Promise.all( - escrows.map((escrow, index) => - escrow.completeIdentifierAuthentication( - challenges[index].id, - responses[index], + const responseResults = await Promise.allSettled( + challenges.map(async (entry) => ({ + ...entry, + response: await this.#collectChallengeResponse(entry.challenge), + })), + ); + const responses = responseResults + .filter(isFulfilledResult) + .map((result) => result.value); + const grantResults = await Promise.allSettled( + responses.map(async ({ escrow, challenge, response }) => ({ + escrow, + grant: await escrow.completeIdentifierAuthentication( + challenge.id, + response, ), - ), + })), ); - return grants.map((grant) => ({ - kind: 'escrow-challenge' as const, - grant, + return grantResults.filter(isFulfilledResult).map(({ value }) => ({ + escrow: value.escrow, + authorization: { + kind: 'escrow-challenge' as const, + grant: value.grant, + }, })); } @@ -707,13 +747,17 @@ export class MfaRecoveryController extends BaseController< } async #getAvailableEscrows(): Promise { - const flags = await Promise.all( + const flags = await Promise.allSettled( this.#escrows.map(async (escrow) => ({ escrow, available: await escrow.isAvailable(), })), ); - return flags.filter((item) => item.available).map((item) => item.escrow); + return flags.flatMap((result) => + isFulfilledResult(result) && result.value.available + ? [result.value.escrow] + : [], + ); } async #requireAllEscrows( From 57f621b28c939530ae65b68328e5b1d6e9a85383 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:18:25 +0800 Subject: [PATCH 04/55] Implement architecture documentation and update mutation handling in MFA recovery controller - Added detailed architecture documentation for the `@metamask/mfa-recovery-controller`, outlining its role as an orchestration layer for MFA recovery, including state machine and mutation sequences. - Updated the handling of mutations to bind version allocation to the payload `epoch` for `register`, `updateRecoverySecret`, and `updateIdentifiers` actions. - Enhanced error handling to ensure that mutations are persisted correctly and can be resumed or aborted as necessary. - Updated tests to reflect changes in the mutation handling and added checks for the new `epoch` parameter in relevant actions. This commit introduces breaking changes that require consumers to adapt to the new mutation versioning scheme. --- .../mfa-recovery-controller/ARCHITECTURE.md | 182 ++++++++++++++++++ packages/mfa-recovery-controller/CHANGELOG.md | 5 +- ...aRecoveryController-method-action-types.ts | 2 + .../src/MfaRecoveryController.test.ts | 138 +++++++++++-- .../src/MfaRecoveryController.ts | 118 ++++-------- .../src/pending-operation-validation.ts | 22 ++- packages/mfa-recovery-controller/src/types.ts | 11 +- .../mfa-recovery-controller/tests/stubs.ts | 9 +- 8 files changed, 373 insertions(+), 114 deletions(-) create mode 100644 packages/mfa-recovery-controller/ARCHITECTURE.md diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md new file mode 100644 index 00000000000..7286d951d01 --- /dev/null +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -0,0 +1,182 @@ +# `@metamask/mfa-recovery-controller` + +Orchestration layer for MFA recovery. The controller never stores the recovery +secret in controller state. It replicates a full copy of that secret to every +configured escrow, and persists only an **encrypted** pending mutation so a +crash can finish the same write. + +## Architecture + +The controller is a coordinator. Auth, identifier proofs, escrow I/O, +encryption, and OTP collection are all injected. + +```mermaid +flowchart LR + Client["Client / UI"] + Messenger["Messenger"] + Ctrl["MfaRecoveryController"] + Auth["RecoveryAuthProvider"] + IdP["RecoveryIdentifierAuthProvider"] + Enc["PendingOperationEncryptor"] + Collect["collectChallengeResponse"] + EA["Escrow A"] + EB["Escrow B"] + + Client --> Messenger + Messenger --> Ctrl + Ctrl --> Auth + Ctrl --> IdP + Ctrl --> Enc + Ctrl --> Collect + Ctrl --> EA + Ctrl --> EB +``` + +| Piece | Role | +| -------------------------- | ------------------------------------------------------------------------- | +| `pendingOperation` | Only persisted field: encrypted `authorizing` / `writing` blob, or `null` | +| `escrows[]` | Build-time replica set; mutations require **all** of them | +| `authProvider` | Profile id + request-bound AuthController token | +| `identifierAuthProvider` | Key-bound IdP assertion (passkey / OIDC / SIWE) | +| `collectChallengeResponse` | OTP (or similar) for email/SMS identifiers | +| `#withLock` | Serializes public methods so two mutations cannot interleave | + +Identifier types pick the auth mode from a trusted table, not a client flag: +`passkey` / `oidc` / `siwe` → key-bound; `emailOtp` / `smsOtp` → escrow +challenge. + +Public surface: `register`, `updateRecoverySecret`, `updateIdentifiers`, +`getRecoverySecret`, `resume`, `abort`, `getPhase`. + +## State machine + +Persisted phase is derived from decrypted pending state: no pending → `idle`. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> authorizing: register / updateRecoverySecret / updateIdentifiers + authorizing --> idle: abort() + authorizing --> writing: identifier auth succeeds, before first apply + writing --> writing: more receipts, still missing replicas + writing --> idle: every configured escrow has a valid receipt + writing --> writing: resume() retries only missing receipts + authorizing --> writing: resume() authorizes then writes +``` + +Rules: + +- **`abort()`** is allowed only in `authorizing` (no escrow write yet). +- **`writing`** must be finished with `resume()`, not aborted. +- `writing` is persisted before the **first escrow apply** after identifier + authorization succeeds, so an ambiguous apply failure remains resumable while + pre-write authorization failures stay abortable. +- Today, a new mutation also calls `#repairPendingMutation` first, then starts + a **second** mutation if repair succeeds. `resume()` is the dedicated repair + API. + +## Mutation sequence (`register` / updates) + +Mutations are all-or-nothing across the configured escrow set. Reads are not. + +```mermaid +sequenceDiagram + participant C as Client + participant M as MfaRecoveryController + participant A as AuthProvider + participant E as Escrows + + C->>M: register / updateRecoverySecret / updateIdentifiers + M->>M: lock + repair any pending mutation + M->>A: getAuthenticatedProfileId + M->>E: require every escrow available + M->>M: expectedVersion from payload.epoch + M->>M: persist pending = authorizing + M->>A: authorizeRecoveryRequest (AuthController token) + alt not register + M->>E: identifier auth per escrow + end + M->>M: persist writing before apply + par write every replica + M->>E: applyMutation + end + M->>M: persist receipts + alt all receipts valid + M->>M: clear pending → idle + else some replicas missing + M-->>C: MutationRepairPendingError (call resume) + end +``` + +Versioning: + +- `register` uses payload `epoch` `0` → version `0 → 1` +- updates use the caller-supplied payload `epoch` as the current version, then + `n → n+1`. Escrows reject a mismatch. + +`resume()` is the same write path without creating a new mutation: authorizing +pending gets a fresh token then replicates; writing pending retries only +escrows that do not already have a stored receipt. + +## Read sequence (`getRecoverySecret`) + +Reads tolerate down replicas. They do **not** repair pending writes. + +```mermaid +sequenceDiagram + participant C as Client + participant M as MfaRecoveryController + participant E as Available escrows + + C->>M: getRecoverySecret(identifier) + M->>E: isAvailable (failures skipped) + M->>E: authorize identifier (partial OK) + par read + M->>E: getSecret + end + M->>M: selectHighestConsistentVersion + alt versions disagree + M-->>C: Replica corruption + else none succeeded + M-->>C: No escrow returned a recovery secret + else + M-->>C: secret bytes + end +``` + +If a mutation is stuck in `writing`, replicas can already disagree. A read in +that window can look like corruption; finish with `resume()` first. + +## Identifier auth (inside writes and reads) + +```mermaid +flowchart TD + Start[identifier + requestHash] --> Mode{trusted type?} + Mode -->|passkey / oidc / siwe| KB[IdP key-bound token] + KB --> Chal[each escrow generateChallenge] + Chal --> PoP[sign PoP over token + challenge + requestHash] + Mode -->|emailOtp / smsOtp| Beg[escrow beginIdentifierAuthentication] + Beg --> OTP[collectChallengeResponse] + OTP --> Grant[escrow completeIdentifierAuthentication] +``` + +On **mutation**, every target escrow must authorize or the write does not +start. On **read**, failed authorizations are skipped. + +## 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 receipts] + Skip --> Write + Write --> All{receipts for every configured escrow?} + All -->|yes| Clear[clear pending] + All -->|no| PendingErr[MutationRepairPendingError] +``` diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index a451639d3ef..777eb074d77 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** Require `RecoveryEscrowProvider.verifyReceipt` to receive the expected escrow id so receipt verification is explicitly bound to the configured escrow target +- **BREAKING:** Require `RecoveryEscrowProvider.verifyReceipt` to receive the expected escrow id so receipt verification is explicitly bound to the configured escrow target- **BREAKING:** Bind mutation version allocation to payload `epoch` on `register`, `updateRecoverySecret`, and `updateIdentifiers`, and remove unauthenticated `RecoveryEscrowProvider.getRecoveryMetadata` lookups - Expose controller state updates through the non-deprecated `MfaRecoveryController:stateChanged` messenger event - Retry only escrows without persisted mutation receipts and validate persisted mutation state before resuming it @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Clear fully acknowledged pending mutations without checking unavailable acknowledged escrows - Persist valid receipts from concurrent escrow writes before reporting an invalid receipt response -- Reject malformed persisted mutation state before authorization or escrow writes +- Persist `writing` mutation state before the first escrow write so ambiguous + failures remain resumable while identifier-auth failures remain abortable [Unreleased]: https://github.com/MetaMask/core/ 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 cd20e9023cb..8514440ba5e 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts @@ -21,6 +21,7 @@ export type MfaRecoveryControllerRegisterAction = { * * @param identifier - Currently registered identifier used to authorize. * @param recoverySecret - New secret. + * @param epoch - Current recovery version, used as `expectedVersion`. */ export type MfaRecoveryControllerUpdateRecoverySecretAction = { type: `MfaRecoveryController:updateRecoverySecret`; @@ -32,6 +33,7 @@ export type MfaRecoveryControllerUpdateRecoverySecretAction = { * * @param identifier - Currently registered identifier used to authorize. * @param identifiers - New non-empty identifier set. + * @param epoch - Current recovery version, used as `expectedVersion`. */ export type MfaRecoveryControllerUpdateIdentifiersAction = { type: `MfaRecoveryController:updateIdentifiers`; diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 66dc43a0635..7581941157b 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -42,6 +42,7 @@ const EMAIL: Identifier = { const SECRET = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); const SECRET_2 = new Uint8Array([8, 7, 6, 5, 4, 3, 2, 1]); +const REGISTERED_EPOCH = 1; describe('MfaRecoveryController', () => { describe('constructor', () => { @@ -187,8 +188,8 @@ describe('MfaRecoveryController', () => { it('rejects a second registration for the same profile', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, [PASSKEY]); - await expect(controller.register(SECRET, [PASSKEY])).rejects.toThrow( - 'Profile is already registered', + await expect(controller.register(SECRET, [PASSKEY])).rejects.toBeInstanceOf( + MutationRepairPendingError, ); }); }); @@ -315,7 +316,7 @@ describe('MfaRecoveryController', () => { it('replaces the secret at every escrow', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, [PASSKEY]); - await controller.updateRecoverySecret(PASSKEY, SECRET_2); + await controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH); const recovered = await controller.getRecoverySecret(PASSKEY); expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET_2)); }); @@ -326,7 +327,7 @@ describe('MfaRecoveryController', () => { await controller.register(SECRET, [PASSKEY]); escrowB.available = false; await expect( - controller.updateRecoverySecret(PASSKEY, SECRET_2), + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toThrow('All configured escrows are required for mutation'); }); }); @@ -341,12 +342,70 @@ describe('MfaRecoveryController', () => { const applyB = jest.spyOn(escrowB, 'applyMutation'); await expect( - controller.updateRecoverySecret(PASSKEY, SECRET_2), + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toThrow('Unable to authorize every escrow'); expect(applyA).not.toHaveBeenCalled(); expect(applyB).not.toHaveBeenCalled(); + expect(await controller.getPhase()).toBe('authorizing'); + await controller.abort(); + expect(await controller.getPhase()).toBe('idle'); + }); + }); + + it('persists writing state when every escrow apply fails', async () => { + await withController(async ({ controller, escrowA, escrowB }) => { + await controller.register(SECRET, [PASSKEY]); + escrowA.failNextApplyCount = 1; + escrowB.failNextApplyCount = 1; + + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), + ).rejects.toBeInstanceOf(MutationRepairPendingError); + expect(await controller.getPhase()).toBe('writing'); + await expect(controller.abort()).rejects.toThrow( + 'Cannot abort a mutation once writing has begun', + ); expect(await controller.getPhase()).toBe('writing'); + await controller.resume(); + expect(await controller.getPhase()).toBe('idle'); + }); + }); + + it('persists writing state before the first escrow write', async () => { + await withController(async ({ controller, escrowA }) => { + await controller.register(SECRET, [PASSKEY]); + + let releaseApply = (): void => undefined; + let resolveApplyStarted = (): void => undefined; + const applyGate = new Promise((resolve) => { + releaseApply = resolve; + }); + const applyStarted = new Promise((resolve) => { + resolveApplyStarted = resolve; + }); + jest.spyOn(escrowA, 'applyMutation').mockImplementation(async () => { + resolveApplyStarted(); + await applyGate; + throw new Error('apply failed'); + }); + + const mutation = controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH); + await applyStarted; + + expect( + JSON.parse(controller.state.pendingOperation as string), + ).toStrictEqual( + expect.objectContaining({ + phase: 'writing', + receipts: [], + }), + ); + + releaseApply(); + await expect(mutation).rejects.toBeInstanceOf( + MutationRepairPendingError, + ); }); }); @@ -355,8 +414,8 @@ describe('MfaRecoveryController', () => { await controller.register(SECRET, [PASSKEY]); escrowB.patchRecord('profile-1', { version: 2 }); await expect( - controller.updateRecoverySecret(PASSKEY, SECRET_2), - ).rejects.toThrow('Escrows disagree on recovery version'); + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), + ).rejects.toBeInstanceOf(MutationRepairPendingError); }); }); @@ -365,8 +424,17 @@ describe('MfaRecoveryController', () => { await controller.register(SECRET, [PASSKEY]); escrowB.clearRecord('profile-1'); await expect( - controller.updateRecoverySecret(PASSKEY, SECRET_2), - ).rejects.toThrow('Recovery record missing at a configured escrow'); + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), + ).rejects.toBeInstanceOf(MutationRepairPendingError); + }); + }); + + it('throws when the supplied epoch is invalid', async () => { + await withController(async ({ controller }) => { + await controller.register(SECRET, [PASSKEY]); + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2, 1.5), + ).rejects.toThrow('Invalid epoch'); }); }); }); @@ -375,7 +443,7 @@ describe('MfaRecoveryController', () => { it('replaces the identifier set using escrow-challenge auth', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, [PASSKEY, EMAIL]); - await controller.updateIdentifiers(EMAIL, [EMAIL]); + await controller.updateIdentifiers(EMAIL, [EMAIL], REGISTERED_EPOCH); const recovered = await controller.getRecoverySecret(EMAIL); expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); }); @@ -384,7 +452,9 @@ describe('MfaRecoveryController', () => { it('rejects an empty identifier list', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, [PASSKEY]); - await expect(controller.updateIdentifiers(PASSKEY, [])).rejects.toThrow( + await expect( + controller.updateIdentifiers(PASSKEY, [], REGISTERED_EPOCH), + ).rejects.toThrow( 'Identifier list must be non-empty', ); }); @@ -397,7 +467,7 @@ describe('MfaRecoveryController', () => { await controller.register(SECRET, [PASSKEY]); escrowA.failNextApplyCount = 1; await expect( - controller.updateRecoverySecret(PASSKEY, SECRET_2), + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toBeInstanceOf(MutationRepairPendingError); expect(await controller.getPhase()).toBe('writing'); @@ -413,7 +483,7 @@ describe('MfaRecoveryController', () => { await controller.register(SECRET, [PASSKEY]); escrowA.failNextApplyCount = 1; await expect( - controller.updateRecoverySecret(PASSKEY, SECRET_2), + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toBeInstanceOf(MutationRepairPendingError); escrowB.available = false; @@ -481,7 +551,18 @@ describe('MfaRecoveryController', () => { await idleController.abort(); expect(await idleController.getPhase()).toBe('idle'); - const writing = await passthroughEncryptor.encrypt(validPending); + const writing = await passthroughEncryptor.encrypt({ + ...validPending, + receipts: [ + { + mutationId: validPending.mutation.id, + requestHash: validPending.mutation.requestHash, + escrowId: 'escrow-a', + version: validPending.mutation.newVersion, + signature: '0xsignature', + }, + ], + }); const writingMessenger = getMessenger(getRootMessenger()); const writingController = new MfaRecoveryController({ ...options, @@ -498,6 +579,7 @@ describe('MfaRecoveryController', () => { it('resumes an authorizing mutation', async () => { await withController(async ({ options }) => { const payload = { + epoch: 0, recoverySecret: bytesToHex(SECRET), identifiers: [PASSKEY], }; @@ -537,7 +619,7 @@ describe('MfaRecoveryController', () => { it('resumes a persisted authorizing updateIdentifiers mutation', async () => { await withController(async ({ controller, options }) => { await controller.register(SECRET, [PASSKEY]); - const payload = { identifiers: [EMAIL] }; + const payload = { epoch: REGISTERED_EPOCH, identifiers: [EMAIL] }; const payloadHash = await hash(payload); const mutationFields = { id: '0xmutidentifiers', @@ -593,6 +675,7 @@ describe('MfaRecoveryController', () => { expectedVersion: 1, newVersion: 2, payloadHash: await hash({ + epoch: REGISTERED_EPOCH, recoverySecret: bytesToHex(SECRET_2), }), audiences: ['escrow-a', 'escrow-b'], @@ -634,7 +717,10 @@ describe('MfaRecoveryController', () => { expiresAt: Date.now() + 60_000, signature: 'stub-auth-signature', }, - payload: { recoverySecret: bytesToHex(SECRET_2) }, + payload: { + epoch: REGISTERED_EPOCH, + recoverySecret: bytesToHex(SECRET_2), + }, identifier: PASSKEY, receipts, }); @@ -663,6 +749,7 @@ describe('MfaRecoveryController', () => { requestHash: '0x', }, payload: { + epoch: 0, recoverySecret: bytesToHex(SECRET), identifiers: [PASSKEY], }, @@ -701,6 +788,7 @@ describe('MfaRecoveryController', () => { signature: 'stub-auth-signature', }, payload: { + epoch: 0, recoverySecret: bytesToHex(SECRET), identifiers: [PASSKEY], }, @@ -721,6 +809,7 @@ describe('MfaRecoveryController', () => { it('rejects a receipt from an unknown escrow', async () => { await withController(async ({ options }) => { const payload = { + epoch: 0, recoverySecret: bytesToHex(SECRET), identifiers: [PASSKEY], }; @@ -785,6 +874,7 @@ describe('MfaRecoveryController', () => { await withController(async ({ options, escrowA }) => { escrowA.invalidReceipts = true; const payload = { + epoch: 0, recoverySecret: bytesToHex(SECRET), identifiers: [PASSKEY], }; @@ -911,7 +1001,7 @@ describe('MfaRecoveryController', () => { await controller.register(SECRET, [PASSKEY]); escrowA.failNextApplyCount = 1; await expect( - controller.updateRecoverySecret(PASSKEY, SECRET_2), + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toBeInstanceOf(MutationRepairPendingError); const pending = JSON.parse( controller.state.pendingOperation as string, @@ -998,6 +1088,7 @@ describe('MfaRecoveryController', () => { const mutation = pending.mutation as Record; mutation.operation = 'register'; pending.payload = { + epoch: 0, recoverySecret: bytesToHex(SECRET), identifiers: [PASSKEY], }; @@ -1029,6 +1120,14 @@ describe('MfaRecoveryController', () => { mutation.newVersion = 6; }, }, + { + name: 'an epoch that does not match expectedVersion', + mutate: (pending: Record): void => { + const mutation = pending.mutation as Record; + mutation.expectedVersion = 2; + mutation.newVersion = 3; + }, + }, { name: 'a mismatched payload hash', mutate: (pending: Record): void => { @@ -1185,7 +1284,10 @@ function getOptions( } async function getValidWritingPending(): Promise { - const payload = { recoverySecret: bytesToHex(SECRET_2) }; + const payload = { + epoch: REGISTERED_EPOCH, + recoverySecret: bytesToHex(SECRET_2), + }; const payloadHash = await hash(payload); const fields = { id: '0xvalid-pending', diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 36f70cf33ec..05eb99fb1d0 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -265,6 +265,7 @@ export class MfaRecoveryController extends BaseController< await this.#runRecoveryMutation({ operation: 'register', payload: { + epoch: 0, recoverySecret: bytesToSecretHex(recoverySecret), identifiers, }, @@ -277,15 +278,20 @@ export class MfaRecoveryController extends BaseController< * * @param identifier - Currently registered identifier used to authorize. * @param recoverySecret - New secret. + * @param epoch - Current recovery version, used as `expectedVersion`. */ async updateRecoverySecret( identifier: Identifier, recoverySecret: Uint8Array, + epoch: number, ): Promise { this.#assertKnownIdentifierTypes([identifier]); await this.#runRecoveryMutation({ operation: 'updateRecoverySecret', - payload: { recoverySecret: bytesToSecretHex(recoverySecret) }, + payload: { + epoch, + recoverySecret: bytesToSecretHex(recoverySecret), + }, identifier, }); } @@ -295,10 +301,12 @@ export class MfaRecoveryController extends BaseController< * * @param identifier - Currently registered identifier used to authorize. * @param identifiers - New non-empty identifier set. + * @param epoch - Current recovery version, used as `expectedVersion`. */ async updateIdentifiers( identifier: Identifier, identifiers: Identifier[], + epoch: number, ): Promise { this.#assertKnownIdentifierTypes([identifier, ...identifiers]); if (identifiers.length === 0) { @@ -309,7 +317,7 @@ export class MfaRecoveryController extends BaseController< } await this.#runRecoveryMutation({ operation: 'updateIdentifiers', - payload: { identifiers }, + payload: { epoch, identifiers }, identifier, }); } @@ -396,11 +404,7 @@ export class MfaRecoveryController extends BaseController< const profileId = await this.#authProvider.getAuthenticatedProfileId(); const targets = await this.#requireAllEscrows(configured); const audiences = [...this.#escrowIds]; - const currentVersion = await this.#resolveCurrentRecoveryVersion({ - operation, - profileId, - targets, - }); + const currentVersion = this.#resolveCurrentRecoveryVersion(payload); const payloadHash = await hash(payload); const mutation = await this.#createMutation({ id: randomId(), @@ -419,20 +423,17 @@ export class MfaRecoveryController extends BaseController< identifier, }); - const authControllerToken = await this.#authorizeMutation( - mutation, - payload, - ); - const pending: WritingPendingOperation = { - phase: 'writing', - mutation, - authControllerToken, - payload, - identifier, - receipts: [], - }; - await this.#persistPending(pending); - await this.#replicateMutation({ escrows: targets, pending }); + await this.#replicateMutation({ + escrows: targets, + pending: { + phase: 'writing', + mutation, + authControllerToken: await this.#authorizeMutation(mutation, payload), + payload, + identifier, + receipts: [], + }, + }); }); } @@ -478,21 +479,19 @@ export class MfaRecoveryController extends BaseController< } if (saved.phase === 'authorizing') { const availableEscrows = await this.#requireAllEscrows(escrows); - const pending: WritingPendingOperation = { - phase: 'writing', - mutation: saved.mutation, - authControllerToken: await this.#authorizeMutation( - saved.mutation, - saved.payload, - ), - payload: saved.payload, - identifier: saved.identifier, - receipts: [], - }; - await this.#persistPending(pending); await this.#replicateMutation({ escrows: availableEscrows, - pending, + pending: { + phase: 'writing', + mutation: saved.mutation, + authControllerToken: await this.#authorizeMutation( + saved.mutation, + saved.payload, + ), + payload: saved.payload, + identifier: saved.identifier, + receipts: [], + }, availabilityChecked: true, }); return; @@ -550,9 +549,6 @@ export class MfaRecoveryController extends BaseController< refreshedToken === pending.authControllerToken ? pending : { ...pending, authControllerToken: refreshedToken }; - if (effectivePending !== pending) { - await this.#persistPending(effectivePending); - } const authorizations = mutation.operation === 'register' ? undefined @@ -570,6 +566,7 @@ export class MfaRecoveryController extends BaseController< 'identifier_auth_failed', ); } + await this.#persistPending(effectivePending); const results = await Promise.allSettled( availableTargets.map((escrow, index) => escrow.applyMutation( @@ -700,50 +697,11 @@ export class MfaRecoveryController extends BaseController< })); } - async #resolveCurrentRecoveryVersion({ - operation, - profileId, - targets, - }: { - operation: Mutation['operation']; - profileId: string; - targets: RecoveryEscrowProvider[]; - }): Promise { - const metadatas = await Promise.all( - targets.map((escrow) => escrow.getRecoveryMetadata(profileId)), - ); - if (operation === 'register') { - if (metadatas.some((metadata) => metadata !== null)) { - throw new MfaRecoveryError( - 'Profile is already registered', - 'already_registered', - ); - } - return 0; - } - const present = metadatas.filter( - (metadata): metadata is NonNullable => metadata !== null, - ); - if (present.length !== metadatas.length) { - throw new MfaRecoveryError( - 'Recovery record missing at a configured escrow', - 'missing_record', - ); - } - const selected = present[0]; - if ( - !present.every( - (metadata) => - metadata.version === selected.version && - metadata.lastMutationId === selected.lastMutationId, - ) - ) { - throw new MfaRecoveryError( - 'Escrows disagree on recovery version', - 'version_divergence', - ); + #resolveCurrentRecoveryVersion(payload: MutationPayload): number { + if (!Number.isInteger(payload.epoch) || payload.epoch < 0) { + throw new MfaRecoveryError('Invalid epoch', 'invalid_epoch'); } - return selected.version; + return payload.epoch; } async #getAvailableEscrows(): Promise { diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts index 6b4f5789f28..542f1b183e4 100644 --- a/packages/mfa-recovery-controller/src/pending-operation-validation.ts +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -83,6 +83,9 @@ export async function assertValidPendingOperation( getIdentifierAuthMode(pending.identifier.type); } + if (payload.epoch !== mutation.expectedVersion) { + throwInvalidPendingOperation(); + } if ((await hash(payload)) !== mutation.payloadHash) { throw new MfaRecoveryError( 'Pending payload does not match mutation', @@ -140,9 +143,14 @@ function isMutationPayload( if (!isRecord(payload)) { return false; } + if (!isNonNegativeInteger(payload.epoch)) { + return false; + } if (operation === 'updateRecoverySecret') { return ( - Object.keys(payload).every((key) => key === 'recoverySecret') && + Object.keys(payload).every( + (key) => key === 'epoch' || key === 'recoverySecret', + ) && typeof (payload as UpdateRecoverySecretPayload).recoverySecret === 'string' ); @@ -151,11 +159,12 @@ function isMutationPayload( return ( Object.keys(payload).every((key) => operation === 'register' - ? key === 'recoverySecret' || key === 'identifiers' - : key === 'identifiers', + ? key === 'epoch' || key === 'recoverySecret' || key === 'identifiers' + : key === 'epoch' || key === 'identifiers', ) && (operation === 'register' - ? typeof (payload as RegisterPayload).recoverySecret === 'string' + ? payload.epoch === 0 && + typeof (payload as RegisterPayload).recoverySecret === 'string' : true) && Array.isArray(identifiers) && identifiers.length > 0 && @@ -163,6 +172,10 @@ function isMutationPayload( ); } +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +} + function isIdentifier(value: unknown): value is Identifier { return ( isRecord(value) && @@ -192,6 +205,7 @@ function isAuthControllerToken(value: unknown): value is AuthControllerToken { ); } + function isMutationReceiptArray(value: unknown): value is MutationReceipt[] { if (!Array.isArray(value)) { return false; diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index ffb74f5ea64..2718e496192 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -89,15 +89,18 @@ export type MutationReceipt = { }; export type RegisterPayload = { + epoch: number; recoverySecret: string; identifiers: Identifier[]; }; export type UpdateRecoverySecretPayload = { + epoch: number; recoverySecret: string; }; export type UpdateIdentifiersPayload = { + epoch: number; identifiers: Identifier[]; }; @@ -138,11 +141,6 @@ export type RecoveryPhase = 'idle' | PendingOperation['phase']; */ export type EncryptedPendingOperation = string; -export type RecoveryRecordMetadata = { - version: number; - lastMutationId: string; -}; - export type RecoverySecretResponse = { recoverySecret: Uint8Array; version: number; @@ -187,9 +185,6 @@ export type RecoveryEscrowProvider = { challengeId: string, response: unknown, ) => Promise; - getRecoveryMetadata: ( - profileId: string, - ) => Promise; getSecret: ( authorization: IdentifierAuthorization, requestId: string, diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index a8d0443410e..9270d6e5915 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -23,7 +23,6 @@ import type { RecoveryAuthProvider, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, - RecoveryRecordMetadata, RecoverySecretResponse, RegisterPayload, UpdateIdentifiersPayload, @@ -223,9 +222,15 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { return grant; } + /** + * Test helper: inspect a replica's version without going through getSecret. + * + * @param profileId - Profile id. + * @returns Version metadata, or `null` if unregistered. + */ async getRecoveryMetadata( profileId: string, - ): Promise { + ): Promise<{ version: number; lastMutationId: string } | null> { const record = this.#records.get(profileId); if (record === undefined) { return null; From 2fb251cbc4ae44e9cc67711bce94fd72d6f970b8 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:35:39 +0800 Subject: [PATCH 05/55] Enhance cryptographic functionality in MFA recovery controller - Added support for P-256 proof signature generation and verification in the `crypto` module. - Introduced new utility functions for base64url encoding and decoding to facilitate JWK coordinate handling. - Updated tests to validate the new signing and verification processes, ensuring robust cryptographic operations. - Included `@noble/curves` as a dependency to leverage optimized elliptic curve operations. These changes improve the security and reliability of the MFA recovery process. --- packages/mfa-recovery-controller/package.json | 3 +- .../src/crypto.test.ts | 21 +++ .../mfa-recovery-controller/src/crypto.ts | 127 +++++++++++------- yarn.lock | 1 + 4 files changed, 104 insertions(+), 48 deletions(-) diff --git a/packages/mfa-recovery-controller/package.json b/packages/mfa-recovery-controller/package.json index 6afdc4e99cf..64df3734c33 100644 --- a/packages/mfa-recovery-controller/package.json +++ b/packages/mfa-recovery-controller/package.json @@ -57,7 +57,8 @@ "dependencies": { "@metamask/base-controller": "^9.1.0", "@metamask/messenger": "^2.0.0", - "@metamask/utils": "^11.11.0" + "@metamask/utils": "^11.11.0", + "@noble/curves": "^1.9.2" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/mfa-recovery-controller/src/crypto.test.ts b/packages/mfa-recovery-controller/src/crypto.test.ts index 8143c857f9e..9c1f29c53f8 100644 --- a/packages/mfa-recovery-controller/src/crypto.test.ts +++ b/packages/mfa-recovery-controller/src/crypto.test.ts @@ -4,8 +4,11 @@ import { bytesToSecretHex, canonicalize, canonicalizeIdentifiers, + generateSigningKey, hash, secretHexToBytes, + sign, + verifySignature, } from './crypto.js'; import type { Identifier } from './types.js'; @@ -26,6 +29,24 @@ describe('crypto', () => { expect(secretHexToBytes(encoded.slice(2))).toStrictEqual(bytes); }); + it('round-trips a P-256 proof signature', async () => { + const key = await generateSigningKey(); + const signature = await sign(key.privateKey, 'hello'); + + expect(await verifySignature(key.publicKey, signature, 'hello')).toBe(true); + expect(await verifySignature(key.publicKey, signature, 'other')).toBe( + false, + ); + expect(await verifySignature(key.publicKey, '0x00', 'hello')).toBe(false); + expect(await verifySignature(key.publicKey, 'not-hex', 'hello')).toBe( + false, + ); + expect(await verifySignature('{}', signature, 'hello')).toBe(false); + await expect(sign('{}', 'hello')).rejects.toThrow( + 'Invalid P-256 private JWK', + ); + }); + it('sorts identifiers for ownership hashes', () => { const first: Identifier = { type: 'passkey', diff --git a/packages/mfa-recovery-controller/src/crypto.ts b/packages/mfa-recovery-controller/src/crypto.ts index 83aa9272a81..7580f7a2a65 100644 --- a/packages/mfa-recovery-controller/src/crypto.ts +++ b/packages/mfa-recovery-controller/src/crypto.ts @@ -1,11 +1,17 @@ -import { bytesToHex, hexToBytes, stringToBytes } from '@metamask/utils'; +import { + base64ToBytes, + bytesToBase64, + bytesToHex, + concatBytes, + hexToBytes, + sha256, + stringToBytes, +} from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { p256 } from '@noble/curves/p256'; import type { Identifier } from './types.js'; -const ECDSA = { name: 'ECDSA', namedCurve: 'P-256' } as const; -const ECDSA_SIGN = { name: 'ECDSA', hash: 'SHA-256' } as const; - /** * Recursively sorts object keys so hashes are independent of property order. * @@ -23,11 +29,7 @@ export function canonicalize(value: unknown): string { * @returns Hex digest. */ export async function hash(value: unknown): Promise { - const digest = await globalThis.crypto.subtle.digest( - 'SHA-256', - stringToBytes(canonicalize(value)), - ); - return bytesToHex(new Uint8Array(digest)); + return bytesToHex(await sha256(stringToBytes(canonicalize(value)))); } /** @@ -65,17 +67,20 @@ export async function generateSigningKey(): Promise<{ publicKey: string; privateKey: string; }> { - const pair = await globalThis.crypto.subtle.generateKey(ECDSA, true, [ - 'sign', - 'verify', - ]); - const [publicJwk, privateJwk] = await Promise.all([ - globalThis.crypto.subtle.exportKey('jwk', pair.publicKey), - globalThis.crypto.subtle.exportKey('jwk', pair.privateKey), - ]); + const privateKeyBytes = p256.utils.randomPrivateKey(); + const publicKeyBytes = p256.getPublicKey(privateKeyBytes, false); + const publicJwk = { + kty: 'EC', + crv: 'P-256', + x: toBase64Url(publicKeyBytes.slice(1, 33)), + y: toBase64Url(publicKeyBytes.slice(33, 65)), + }; return { publicKey: JSON.stringify(publicJwk), - privateKey: JSON.stringify(privateJwk), + privateKey: JSON.stringify({ + ...publicJwk, + d: toBase64Url(privateKeyBytes), + }), }; } @@ -83,31 +88,26 @@ export async function generateSigningKey(): Promise<{ * Signs a message with the ephemeral proof private key. * * @param privateKey - JWK JSON private key. - * @param message - Prehashed message hex or string. - * @returns Hex signature. + * @param message - Message string; hashed with SHA-256 before ECDSA. + * @returns Compact IEEE P1363 hex signature (`r‖s`). */ export async function sign(privateKey: string, message: string): Promise { - const key = await globalThis.crypto.subtle.importKey( - 'jwk', - JSON.parse(privateKey) as JsonWebKey, - ECDSA, - false, - ['sign'], - ); - const signature = await globalThis.crypto.subtle.sign( - ECDSA_SIGN, - key, - stringToBytes(message), + const jwk = JSON.parse(privateKey) as { d?: string }; + if (typeof jwk.d !== 'string') { + throw new Error('Invalid P-256 private JWK'); + } + const digest = await sha256(stringToBytes(message)); + return bytesToHex( + p256.sign(digest, fromBase64Url(jwk.d)).toCompactRawBytes(), ); - return bytesToHex(new Uint8Array(signature)); } /** * Verifies a proof signature against a public key. * * @param publicKey - JWK JSON public key. - * @param signature - Hex signature. - * @param message - Prehashed message. + * @param signature - Compact IEEE P1363 hex signature. + * @param message - Message string; hashed with SHA-256 before ECDSA. * @returns Whether the signature is valid. */ export async function verifySignature( @@ -115,19 +115,24 @@ export async function verifySignature( signature: string, message: string, ): Promise { - const key = await globalThis.crypto.subtle.importKey( - 'jwk', - JSON.parse(publicKey) as JsonWebKey, - ECDSA, - false, - ['verify'], - ); - return await globalThis.crypto.subtle.verify( - ECDSA_SIGN, - key, - hexToBytes(signature as Hex), - stringToBytes(message), - ); + const jwk = JSON.parse(publicKey) as { x?: string; y?: string }; + if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') { + return false; + } + try { + const digest = await sha256(stringToBytes(message)); + return p256.verify( + hexToBytes(signature as Hex), + digest, + concatBytes([ + new Uint8Array([0x04]), + fromBase64Url(jwk.x), + fromBase64Url(jwk.y), + ]), + ); + } catch { + return false; + } } /** @@ -177,6 +182,34 @@ export function secretHexToBytes(secretHex: string): Uint8Array { ); } +/** + * Encodes bytes as unpadded base64url (JWK coordinate format). + * + * @param bytes - Raw coordinate or scalar. + * @returns Base64url string. + */ +function toBase64Url(bytes: Uint8Array): string { + return bytesToBase64(bytes) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); +} + +/** + * Decodes unpadded base64url JWK coordinates. + * + * @param value - Base64url string. + * @returns Raw bytes. + */ +function fromBase64Url(value: string): Uint8Array { + return base64ToBytes( + value + .replace(/-/gu, '+') + .replace(/_/gu, '/') + .padEnd(value.length + ((4 - (value.length % 4)) % 4), '='), + ); +} + /** * Recursively sorts keys and encodes Uint8Array as hex. * diff --git a/yarn.lock b/yarn.lock index dac2c62e631..2af2a2079ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7708,6 +7708,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" + "@noble/curves": "npm:^1.9.2" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" From 427d70bb97b057ab7d04767edb084eca5beda37b Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:36:11 +0800 Subject: [PATCH 06/55] Update MFA recovery controller to return epoch with recovery secret - Modified `getRecoverySecret` to return an object containing both the recovery secret and the selected `epoch`, enabling clients to use the correct version for subsequent mutations. - Updated related types and documentation to reflect the new return structure. - Adjusted tests to validate the new behavior, ensuring that the epoch is correctly returned and utilized in updates. These changes introduce breaking changes that require consumers to adapt to the new return format of `getRecoverySecret`. --- .../mfa-recovery-controller/ARCHITECTURE.md | 7 +- packages/mfa-recovery-controller/CHANGELOG.md | 4 +- ...aRecoveryController-method-action-types.ts | 5 +- .../src/MfaRecoveryController.test.ts | 83 +++++++++++++------ .../src/MfaRecoveryController.ts | 15 +++- packages/mfa-recovery-controller/src/index.ts | 1 + packages/mfa-recovery-controller/src/types.ts | 8 ++ 7 files changed, 88 insertions(+), 35 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 7286d951d01..9701e722cd8 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -120,7 +120,10 @@ escrows that do not already have a stored receipt. ## Read sequence (`getRecoverySecret`) -Reads tolerate down replicas. They do **not** repair pending writes. +Reads tolerate down replicas. They do **not** repair pending writes. The +selected replica version is returned as `epoch` so a later +`updateRecoverySecret` / `updateIdentifiers` can be formed without a locally +stored version. ```mermaid sequenceDiagram @@ -140,7 +143,7 @@ sequenceDiagram else none succeeded M-->>C: No escrow returned a recovery secret else - M-->>C: secret bytes + M-->>C: secret bytes + epoch end ``` diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 777eb074d77..5e67a69b0a6 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** Require `RecoveryEscrowProvider.verifyReceipt` to receive the expected escrow id so receipt verification is explicitly bound to the configured escrow target- **BREAKING:** Bind mutation version allocation to payload `epoch` on `register`, `updateRecoverySecret`, and `updateIdentifiers`, and remove unauthenticated `RecoveryEscrowProvider.getRecoveryMetadata` lookups +- **BREAKING:** Return `{ recoverySecret, epoch }` from `getRecoverySecret` so clients can pass the selected version into later mutations +- **BREAKING:** Require `RecoveryEscrowProvider.verifyReceipt` to receive the expected escrow id so receipt verification is explicitly bound to the configured escrow target +- **BREAKING:** Bind mutation version allocation to payload `epoch` on `register`, `updateRecoverySecret`, and `updateIdentifiers`, and remove unauthenticated `RecoveryEscrowProvider.getRecoveryMetadata` lookups - Expose controller state updates through the non-deprecated `MfaRecoveryController:stateChanged` messenger event - Retry only escrows without persisted mutation receipts and validate persisted mutation state before resuming it 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 8514440ba5e..e010ea74322 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts @@ -42,10 +42,11 @@ export type MfaRecoveryControllerUpdateIdentifiersAction = { /** * Reads the recovery secret from available escrows and returns the highest - * consistent version. + * consistent version. `epoch` is the current recovery version and is required + * by later mutations. * * @param identifier - Identifier used to authorize the read. - * @returns Recovered secret bytes. + * @returns Recovered secret bytes and the selected epoch. */ export type MfaRecoveryControllerGetRecoverySecretAction = { type: `MfaRecoveryController:getRecoverySecret`; diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 7581941157b..c1ac3bcf608 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -158,7 +158,7 @@ describe('MfaRecoveryController', () => { expect(verifyReceipt.mock.calls[0][2]).toBe('escrow-a'); const recovered = await controller.getRecoverySecret(PASSKEY); - expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -188,20 +188,41 @@ describe('MfaRecoveryController', () => { it('rejects a second registration for the same profile', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, [PASSKEY]); - await expect(controller.register(SECRET, [PASSKEY])).rejects.toBeInstanceOf( - MutationRepairPendingError, - ); + await expect( + controller.register(SECRET, [PASSKEY]), + ).rejects.toBeInstanceOf(MutationRepairPendingError); }); }); }); describe('getRecoverySecret', () => { + it('returns the selected epoch so a later update can be formed', async () => { + await withController(async ({ controller }) => { + await controller.register(SECRET, [PASSKEY]); + const recovered = await controller.getRecoverySecret(PASSKEY); + + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); + expect(recovered.epoch).toBe(REGISTERED_EPOCH); + + await controller.updateRecoverySecret( + PASSKEY, + SECRET_2, + recovered.epoch, + ); + const afterUpdate = await controller.getRecoverySecret(PASSKEY); + expect(bytesToHex(afterUpdate.recoverySecret)).toBe( + bytesToHex(SECRET_2), + ); + expect(afterUpdate.epoch).toBe(REGISTERED_EPOCH + 1); + }); + }); + it('returns the highest consistent version when one escrow is down', async () => { await withController(async ({ controller, escrowB }) => { await controller.register(SECRET, [PASSKEY]); escrowB.available = false; const recovered = await controller.getRecoverySecret(PASSKEY); - expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -214,7 +235,7 @@ describe('MfaRecoveryController', () => { const recovered = await controller.getRecoverySecret(PASSKEY); - expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -227,7 +248,7 @@ describe('MfaRecoveryController', () => { const recovered = await controller.getRecoverySecret(PASSKEY); - expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -240,7 +261,7 @@ describe('MfaRecoveryController', () => { const recovered = await controller.getRecoverySecret(EMAIL); - expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -253,7 +274,7 @@ describe('MfaRecoveryController', () => { const recovered = await controller.getRecoverySecret(EMAIL); - expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -316,9 +337,13 @@ describe('MfaRecoveryController', () => { it('replaces the secret at every escrow', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, [PASSKEY]); - await controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH); + await controller.updateRecoverySecret( + PASSKEY, + SECRET_2, + REGISTERED_EPOCH, + ); const recovered = await controller.getRecoverySecret(PASSKEY); - expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET_2)); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); }); }); @@ -390,7 +415,11 @@ describe('MfaRecoveryController', () => { throw new Error('apply failed'); }); - const mutation = controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH); + const mutation = controller.updateRecoverySecret( + PASSKEY, + SECRET_2, + REGISTERED_EPOCH, + ); await applyStarted; expect( @@ -445,7 +474,7 @@ describe('MfaRecoveryController', () => { await controller.register(SECRET, [PASSKEY, EMAIL]); await controller.updateIdentifiers(EMAIL, [EMAIL], REGISTERED_EPOCH); const recovered = await controller.getRecoverySecret(EMAIL); - expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET)); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -454,9 +483,7 @@ describe('MfaRecoveryController', () => { await controller.register(SECRET, [PASSKEY]); await expect( controller.updateIdentifiers(PASSKEY, [], REGISTERED_EPOCH), - ).rejects.toThrow( - 'Identifier list must be non-empty', - ); + ).rejects.toThrow('Identifier list must be non-empty'); }); }); }); @@ -474,7 +501,7 @@ describe('MfaRecoveryController', () => { await controller.resume(); expect(await controller.getPhase()).toBe('idle'); const recovered = await controller.getRecoverySecret(PASSKEY); - expect(bytesToHex(recovered)).toBe(bytesToHex(SECRET_2)); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); }); }); @@ -491,9 +518,11 @@ describe('MfaRecoveryController', () => { await controller.resume(); expect(await controller.getPhase()).toBe('idle'); - expect(bytesToHex(await controller.getRecoverySecret(PASSKEY))).toBe( - bytesToHex(SECRET_2), - ); + expect( + bytesToHex( + (await controller.getRecoverySecret(PASSKEY)).recoverySecret, + ), + ).toBe(bytesToHex(SECRET_2)); }); }); @@ -610,9 +639,11 @@ describe('MfaRecoveryController', () => { }); await controller.resume(); expect(await controller.getPhase()).toBe('idle'); - expect(bytesToHex(await controller.getRecoverySecret(PASSKEY))).toBe( - bytesToHex(SECRET), - ); + expect( + bytesToHex( + (await controller.getRecoverySecret(PASSKEY)).recoverySecret, + ), + ).toBe(bytesToHex(SECRET)); }); }); @@ -651,9 +682,9 @@ describe('MfaRecoveryController', () => { await resumed.resume(); expect(await resumed.getPhase()).toBe('idle'); - expect(bytesToHex(await resumed.getRecoverySecret(EMAIL))).toBe( - bytesToHex(SECRET), - ); + expect( + bytesToHex((await resumed.getRecoverySecret(EMAIL)).recoverySecret), + ).toBe(bytesToHex(SECRET)); }); }); diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 05eb99fb1d0..c625c3afb16 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -38,6 +38,7 @@ import type { RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, RecoveryPhase, + RecoveredSecret, RegisterPayload, UpdateIdentifiersPayload, WritingPendingOperation, @@ -57,6 +58,7 @@ export type { RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, RecoveryPhase, + RecoveredSecret, } from './types.js'; const CONTROLLER_NAME = 'MfaRecoveryController'; @@ -324,12 +326,13 @@ export class MfaRecoveryController extends BaseController< /** * Reads the recovery secret from available escrows and returns the highest - * consistent version. + * consistent version. `epoch` is the current recovery version and is required + * by later mutations. * * @param identifier - Identifier used to authorize the read. - * @returns Recovered secret bytes. + * @returns Recovered secret bytes and the selected epoch. */ - async getRecoverySecret(identifier: Identifier): Promise { + async getRecoverySecret(identifier: Identifier): Promise { return await this.#withLock(async () => { this.#assertKnownIdentifierTypes([identifier]); const requestId = randomId(); @@ -355,7 +358,11 @@ export class MfaRecoveryController extends BaseController< ...(await escrow.getSecret(authorization, requestId)), })), ); - return selectHighestConsistentVersion(results).recoverySecret; + const selected = selectHighestConsistentVersion(results); + return { + recoverySecret: selected.recoverySecret, + epoch: selected.version, + }; }); } diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts index cf8f807feab..842b5cc94a9 100644 --- a/packages/mfa-recovery-controller/src/index.ts +++ b/packages/mfa-recovery-controller/src/index.ts @@ -39,4 +39,5 @@ export type { RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, RecoveryPhase, + RecoveredSecret, } from './types.js'; diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index 2718e496192..1671ca3a186 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -147,6 +147,14 @@ export type RecoverySecretResponse = { lastMutationId: string; }; +/** + * Recovery secret plus the version to use as `epoch` on later mutations. + */ +export type RecoveredSecret = { + recoverySecret: Uint8Array; + epoch: number; +}; + /** * Authenticates the MetaMask profile and issues request-bound attestations. */ From 1bbaf7aa0a6079bac976a8304bb9efdf2c0f8a40 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:24:26 +0800 Subject: [PATCH 07/55] Update TypeScript configuration for MFA recovery controller - Added Jest types to the TypeScript configuration, enhancing type support for testing. - This change improves the development experience by ensuring proper type checking for Jest-related code. No breaking changes introduced. --- packages/mfa-recovery-controller/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mfa-recovery-controller/tsconfig.json b/packages/mfa-recovery-controller/tsconfig.json index 890b592b55a..cc90605fe01 100644 --- a/packages/mfa-recovery-controller/tsconfig.json +++ b/packages/mfa-recovery-controller/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.packages.json", "compilerOptions": { - "baseUrl": "./" + "types": ["jest"] }, "references": [ { From f0e26e9791b165093b76ba9841993965176cb52a Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:35:50 +0800 Subject: [PATCH 08/55] fix: Cursor comment --- packages/mfa-recovery-controller/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mfa-recovery-controller/tsconfig.json b/packages/mfa-recovery-controller/tsconfig.json index cc90605fe01..890b592b55a 100644 --- a/packages/mfa-recovery-controller/tsconfig.json +++ b/packages/mfa-recovery-controller/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.packages.json", "compilerOptions": { - "types": ["jest"] + "baseUrl": "./" }, "references": [ { From 9492baddba64ffefc94862576a0b32170b235305 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:54:31 +0800 Subject: [PATCH 09/55] feat(mfa-recovery-controller): align wrap, receipts, and auth with cubist escrow Wrap each escrow's secret at apply, drop OTP challenge APIs, include receiptKeyId in unsigned receipt hashes, and treat token expiry as unix seconds. --- .../mfa-recovery-controller/ARCHITECTURE.md | 41 +- packages/mfa-recovery-controller/CHANGELOG.md | 3 + packages/mfa-recovery-controller/package.json | 4 +- ...aRecoveryController-method-action-types.ts | 6 +- .../src/MfaRecoveryController.test.ts | 391 +++++++----- .../src/MfaRecoveryController.ts | 604 ++++++++---------- .../src/crypto.test.ts | 100 ++- .../mfa-recovery-controller/src/crypto.ts | 165 ++++- .../mfa-recovery-controller/src/errors.ts | 11 +- .../src/escrow-utils.ts | 7 +- .../src/identifier-auth.ts | 83 ++- packages/mfa-recovery-controller/src/index.ts | 11 +- .../src/pending-operation-validation.ts | 100 +-- packages/mfa-recovery-controller/src/types.ts | 153 +++-- .../mfa-recovery-controller/tests/stubs.ts | 271 ++++---- .../mfa-recovery-controller/tsconfig.json | 3 +- yarn.lock | 2 + 17 files changed, 1162 insertions(+), 793 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 9701e722cd8..219f22e686d 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -7,8 +7,8 @@ crash can finish the same write. ## Architecture -The controller is a coordinator. Auth, identifier proofs, escrow I/O, -encryption, and OTP collection are all injected. +The controller is a coordinator. Auth, identifier proofs, escrow I/O, and +encryption are injected. OTP collection is not wired yet. ```mermaid flowchart LR @@ -18,7 +18,6 @@ flowchart LR Auth["RecoveryAuthProvider"] IdP["RecoveryIdentifierAuthProvider"] Enc["PendingOperationEncryptor"] - Collect["collectChallengeResponse"] EA["Escrow A"] EB["Escrow B"] @@ -27,23 +26,29 @@ flowchart LR Ctrl --> Auth Ctrl --> IdP Ctrl --> Enc - Ctrl --> Collect Ctrl --> EA Ctrl --> EB ``` -| Piece | Role | -| -------------------------- | ------------------------------------------------------------------------- | -| `pendingOperation` | Only persisted field: encrypted `authorizing` / `writing` blob, or `null` | -| `escrows[]` | Build-time replica set; mutations require **all** of them | -| `authProvider` | Profile id + request-bound AuthController token | -| `identifierAuthProvider` | Key-bound IdP assertion (passkey / OIDC / SIWE) | -| `collectChallengeResponse` | OTP (or similar) for email/SMS identifiers | -| `#withLock` | Serializes public methods so two mutations cannot interleave | +| Piece | Role | +| ------------------------ | ------------------------------------------------------------------------- | +| `pendingOperation` | Only persisted field: encrypted `authorizing` / `writing` blob, or `null` | +| `escrows[]` | Build-time replica set; mutations require **all** of them | +| `authProvider` | Profile id + request-bound AuthController token | +| `identifierAuthProvider` | Key-bound IdP assertion (passkey / OIDC / SIWE) | +| `#withLock` | Serializes public methods so two mutations cannot interleave | Identifier types pick the auth mode from a trusted table, not a client flag: -`passkey` / `oidc` / `siwe` → key-bound; `emailOtp` / `smsOtp` → escrow -challenge. +`passkey` / `oidc` / `siwe` → key-bound. `emailOtp` / `smsOtp` escrow-challenge +is not wired yet (MFA-605 / MFA-606 / MFA-568). + +`register` and `updateIdentifiers` require at least two identifiers. + +Recovery secrets are wrapped in transit with escrow-wrap-v1 (P-256 ECDH, HKDF, +ChaCha20-Poly1305). Encrypted pending state stores `PendingMutationPayload` +(secret as 0x-hex). At apply, each escrow receives a `MutationPayload` with +only the `{ pkE, ciphertext }` pair wrapped to its own wrap key. Reads send +the client's ephemeral `pkE` in the `getSecret` request hash. Public surface: `register`, `updateRecoverySecret`, `updateIdentifiers`, `getRecoverySecret`, `resume`, `abort`, `getPhase`. @@ -104,7 +109,7 @@ sequenceDiagram alt all receipts valid M->>M: clear pending → idle else some replicas missing - M-->>C: MutationRepairPendingError (call resume) + M-->>C: IncompleteMutationError (call resume) end ``` @@ -158,9 +163,7 @@ flowchart TD Mode -->|passkey / oidc / siwe| KB[IdP key-bound token] KB --> Chal[each escrow generateChallenge] Chal --> PoP[sign PoP over token + challenge + requestHash] - Mode -->|emailOtp / smsOtp| Beg[escrow beginIdentifierAuthentication] - Beg --> OTP[collectChallengeResponse] - OTP --> Grant[escrow completeIdentifierAuthentication] + Mode -->|emailOtp / smsOtp| Todo[TODO MFA-605 / MFA-606 / MFA-568] ``` On **mutation**, every target escrow must authorize or the write does not @@ -181,5 +184,5 @@ flowchart TD Skip --> Write Write --> All{receipts for every configured escrow?} All -->|yes| Clear[clear pending] - All -->|no| PendingErr[MutationRepairPendingError] + All -->|no| PendingErr[IncompleteMutationError] ``` diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 5e67a69b0a6..b37923e06f9 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Wrap recovery secrets in mutation payloads and `getSecret` responses with escrow-wrap-v1 (`pkE` + ChaCha20-Poly1305 ciphertext), and bind `getSecret` request hashes to the client's ephemeral `pkE` +- **BREAKING:** Wrap mutation recovery secrets to each escrow's wrap key at apply time, store the logical secret as hex inside encrypted pending state, and send each escrow only its own `{ pkE, ciphertext }` wrap +- **BREAKING:** Require at least two identifiers on `register` and `updateIdentifiers`, reject `emailOtp` / `smsOtp` until escrow-challenge OTP is implemented, and omit `RecoveryEscrowProvider` OTP challenge methods until then - **BREAKING:** Return `{ recoverySecret, epoch }` from `getRecoverySecret` so clients can pass the selected version into later mutations - **BREAKING:** Require `RecoveryEscrowProvider.verifyReceipt` to receive the expected escrow id so receipt verification is explicitly bound to the configured escrow target - **BREAKING:** Bind mutation version allocation to payload `epoch` on `register`, `updateRecoverySecret`, and `updateIdentifiers`, and remove unauthenticated `RecoveryEscrowProvider.getRecoveryMetadata` lookups diff --git a/packages/mfa-recovery-controller/package.json b/packages/mfa-recovery-controller/package.json index 64df3734c33..8086d6811d4 100644 --- a/packages/mfa-recovery-controller/package.json +++ b/packages/mfa-recovery-controller/package.json @@ -58,7 +58,9 @@ "@metamask/base-controller": "^9.1.0", "@metamask/messenger": "^2.0.0", "@metamask/utils": "^11.11.0", - "@noble/curves": "^1.9.2" + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.2", + "@noble/hashes": "^1.8.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", 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 e010ea74322..c698457b934 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts @@ -9,7 +9,8 @@ import type { MfaRecoveryController } from './MfaRecoveryController.js'; * Creates version 1 of a recovery record. * * @param recoverySecret - Secret replicated in full to every escrow. - * @param identifiers - Ownership-approved identifier set. Must be non-empty. + * @param identifiers - Ownership-approved identifier set. Must contain at + * least two identifiers. */ export type MfaRecoveryControllerRegisterAction = { type: `MfaRecoveryController:register`; @@ -32,7 +33,8 @@ export type MfaRecoveryControllerUpdateRecoverySecretAction = { * Replaces the complete identifier set. * * @param identifier - Currently registered identifier used to authorize. - * @param identifiers - New non-empty identifier set. + * @param identifiers - New identifier set. Must contain at least two + * identifiers. * @param epoch - Current recovery version, used as `expectedVersion`. */ export type MfaRecoveryControllerUpdateIdentifiersAction = { diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index c1ac3bcf608..0064a483c5d 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -13,8 +13,8 @@ import { StubIdentifierAuthProvider, passthroughEncryptor, } from '../tests/stubs.js'; -import { canonicalizeIdentifiers, hash } from './crypto.js'; -import { MutationRepairPendingError } from './errors.js'; +import { canonicalizeIdentifiers, hash, unixNow, wrapKeyId } from './crypto.js'; +import { IncompleteMutationError } from './errors.js'; import type { MfaRecoveryControllerMessenger, MfaRecoveryControllerOptions, @@ -23,6 +23,8 @@ import { MfaRecoveryController } from './MfaRecoveryController.js'; import type { Identifier, MutationReceipt, + RecoveryEscrowProvider, + RegisterPayload, WritingPendingOperation, } from './types.js'; @@ -33,13 +35,15 @@ const PASSKEY: Identifier = { verifier: { publicKey: 'pk' }, }; -const EMAIL: Identifier = { - type: 'emailOtp', - namespace: 'email', - value: 'user@example.com', - verifier: null, +const OIDC: Identifier = { + type: 'oidc', + namespace: 'https://accounts.google.com', + value: 'sub-1', + verifier: { auds: ['client'] }, }; +const IDENTIFIERS = [PASSKEY, OIDC]; + const SECRET = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); const SECRET_2 = new Uint8Array([8, 7, 6, 5, 4, 3, 2, 1]); const REGISTERED_EPOCH = 1; @@ -66,7 +70,6 @@ describe('MfaRecoveryController', () => { identifierAuthProvider: new StubIdentifierAuthProvider(), escrows: [], pendingOperationEncryptor: passthroughEncryptor, - collectChallengeResponse: async (): Promise => 'otp', }), ).toThrow('At least one escrow is required'); }); @@ -84,7 +87,6 @@ describe('MfaRecoveryController', () => { new StubEscrowProvider('escrow-a'), ], pendingOperationEncryptor: passthroughEncryptor, - collectChallengeResponse: async (): Promise => 'otp', }), ).toThrow('Duplicate escrow id'); }); @@ -150,13 +152,25 @@ describe('MfaRecoveryController', () => { describe('register', () => { it('replicates the recovery secret to every escrow', async () => { - await withController(async ({ controller, escrowA }) => { + await withController(async ({ controller, escrowA, escrowB }) => { const verifyReceipt = jest.spyOn(escrowA, 'verifyReceipt'); - await controller.register(SECRET, [PASSKEY]); + const applyA = jest.spyOn(escrowA, 'applyMutation'); + const applyB = jest.spyOn(escrowB, 'applyMutation'); + await controller.register(SECRET, IDENTIFIERS); expect(controller.state.pendingOperation).toBeNull(); expect(await controller.getPhase()).toBe('idle'); expect(verifyReceipt.mock.calls[0][2]).toBe('escrow-a'); + const wrappedA = (applyA.mock.calls[0][3] as RegisterPayload) + .recoverySecret; + const wrappedB = (applyB.mock.calls[0][3] as RegisterPayload) + .recoverySecret; + expect(Object.keys(wrappedA).sort()).toStrictEqual([ + 'ciphertext', + 'pkE', + ]); + expect(wrappedA.ciphertext).not.toBe(wrappedB.ciphertext); + const recovered = await controller.getRecoverySecret(PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); @@ -167,7 +181,7 @@ describe('MfaRecoveryController', () => { const listener = jest.fn(); rootMessenger.subscribe('MfaRecoveryController:stateChanged', listener); - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); expect(listener).toHaveBeenCalled(); expect(listener).toHaveBeenLastCalledWith( @@ -180,25 +194,55 @@ describe('MfaRecoveryController', () => { it('rejects an empty identifier list', async () => { await withController(async ({ controller }) => { await expect(controller.register(SECRET, [])).rejects.toThrow( - 'Registration requires at least one identifier', + 'Requires at least 2 identifiers', + ); + }); + }); + + it('rejects a single identifier', async () => { + await withController(async ({ controller }) => { + await expect(controller.register(SECRET, [PASSKEY])).rejects.toThrow( + 'Requires at least 2 identifiers', ); }); }); it('rejects a second registration for the same profile', async () => { await withController(async ({ controller }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); await expect( - controller.register(SECRET, [PASSKEY]), - ).rejects.toBeInstanceOf(MutationRepairPendingError); + controller.register(SECRET, IDENTIFIERS), + ).rejects.toBeInstanceOf(IncompleteMutationError); + }); + }); + + it('does not write when an escrow wrap key is invalid', async () => { + const escrowA = new StubEscrowProvider('escrow-a'); + const escrowB = new StubEscrowProvider('escrow-b', { + publicKey: '{}', + privateKey: '{}', + }); + const applyA = jest.spyOn(escrowA, 'applyMutation'); + const applyB = jest.spyOn(escrowB, 'applyMutation'); + const controller = new MfaRecoveryController({ + ...getOptions(escrowA, escrowB), + messenger: getMessenger(getRootMessenger()), }); + + await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( + 'Invalid P-256 public JWK', + ); + + expect(applyA).not.toHaveBeenCalled(); + expect(applyB).not.toHaveBeenCalled(); + expect(await controller.getPhase()).toBe('authorizing'); }); }); describe('getRecoverySecret', () => { it('returns the selected epoch so a later update can be formed', async () => { await withController(async ({ controller }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); const recovered = await controller.getRecoverySecret(PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); @@ -219,7 +263,7 @@ describe('MfaRecoveryController', () => { it('returns the highest consistent version when one escrow is down', async () => { await withController(async ({ controller, escrowB }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); escrowB.available = false; const recovered = await controller.getRecoverySecret(PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); @@ -228,7 +272,7 @@ describe('MfaRecoveryController', () => { it('continues reading when an escrow availability check fails', async () => { await withController(async ({ controller, escrowA }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); jest .spyOn(escrowA, 'isAvailable') .mockRejectedValue(new Error('health check failed')); @@ -241,7 +285,7 @@ describe('MfaRecoveryController', () => { it('continues reading when key-bound authorization fails for one escrow', async () => { await withController(async ({ controller, escrowA }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); jest .spyOn(escrowA, 'generateChallenge') .mockRejectedValue(new Error('challenge failed')); @@ -252,35 +296,23 @@ describe('MfaRecoveryController', () => { }); }); - it('continues reading when escrow-challenge authorization fails for one escrow', async () => { + it('continues reading when an escrow returns an unexpected wrap key id', async () => { await withController(async ({ controller, escrowA }) => { - await controller.register(SECRET, [EMAIL]); - jest - .spyOn(escrowA, 'beginIdentifierAuthentication') - .mockRejectedValue(new Error('challenge failed')); - - const recovered = await controller.getRecoverySecret(EMAIL); - - expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); - }); - }); - - it('continues reading when completing one escrow challenge fails', async () => { - await withController(async ({ controller, escrowA }) => { - await controller.register(SECRET, [EMAIL]); - jest - .spyOn(escrowA, 'completeIdentifierAuthentication') - .mockRejectedValue(new Error('grant failed')); - - const recovered = await controller.getRecoverySecret(EMAIL); - + await controller.register(SECRET, IDENTIFIERS); + jest.spyOn(escrowA, 'getSecret').mockResolvedValue({ + recoverySecret: { ciphertext: 'aa' }, + version: 1, + lastMutationId: '0x', + wrapKeyId: '0xdead', + }); + const recovered = await controller.getRecoverySecret(PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); it('throws when matching versions disagree', async () => { await withController(async ({ controller, escrowB }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); escrowB.patchRecord('profile-1', { lastMutationId: '0xdifferent', }); @@ -302,7 +334,7 @@ describe('MfaRecoveryController', () => { it('throws when every available escrow fails the read', async () => { await withController(async ({ controller, escrowA, escrowB }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); escrowA.failGetSecret = true; escrowB.failGetSecret = true; await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( @@ -318,7 +350,7 @@ describe('MfaRecoveryController', () => { async ({ controller, escrowA, escrowB, options }) => { options.escrows.splice(0, options.escrows.length); - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); expect(await escrowA.getRecoveryMetadata('profile-1')).toStrictEqual({ version: 1, @@ -335,13 +367,15 @@ describe('MfaRecoveryController', () => { describe('updateRecoverySecret', () => { it('replaces the secret at every escrow', async () => { - await withController(async ({ controller }) => { - await controller.register(SECRET, [PASSKEY]); + await withController(async ({ controller, escrowA }) => { + const generateChallenge = jest.spyOn(escrowA, 'generateChallenge'); + await controller.register(SECRET, IDENTIFIERS); await controller.updateRecoverySecret( PASSKEY, SECRET_2, REGISTERED_EPOCH, ); + expect(generateChallenge).toHaveBeenCalledWith(); const recovered = await controller.getRecoverySecret(PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); }); @@ -349,17 +383,19 @@ describe('MfaRecoveryController', () => { it('throws when a configured escrow is unavailable', async () => { await withController(async ({ controller, escrowB }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); escrowB.available = false; await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toThrow('All configured escrows are required for mutation'); + expect(await controller.getPhase()).toBe('authorizing'); + await controller.abort(); }); }); it('does not write when one escrow cannot be authorized', async () => { await withController(async ({ controller, escrowA, escrowB }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); jest .spyOn(escrowA, 'generateChallenge') .mockRejectedValue(new Error('challenge failed')); @@ -380,13 +416,13 @@ describe('MfaRecoveryController', () => { it('persists writing state when every escrow apply fails', async () => { await withController(async ({ controller, escrowA, escrowB }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); escrowA.failNextApplyCount = 1; escrowB.failNextApplyCount = 1; await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), - ).rejects.toBeInstanceOf(MutationRepairPendingError); + ).rejects.toBeInstanceOf(IncompleteMutationError); expect(await controller.getPhase()).toBe('writing'); await expect(controller.abort()).rejects.toThrow( 'Cannot abort a mutation once writing has begun', @@ -399,7 +435,7 @@ describe('MfaRecoveryController', () => { it('persists writing state before the first escrow write', async () => { await withController(async ({ controller, escrowA }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); let releaseApply = (): void => undefined; let resolveApplyStarted = (): void => undefined; @@ -432,35 +468,33 @@ describe('MfaRecoveryController', () => { ); releaseApply(); - await expect(mutation).rejects.toBeInstanceOf( - MutationRepairPendingError, - ); + await expect(mutation).rejects.toBeInstanceOf(IncompleteMutationError); }); }); it('throws when replicas disagree on version', async () => { await withController(async ({ controller, escrowB }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); escrowB.patchRecord('profile-1', { version: 2 }); await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), - ).rejects.toBeInstanceOf(MutationRepairPendingError); + ).rejects.toBeInstanceOf(IncompleteMutationError); }); }); it('throws when a replica is missing the recovery record', async () => { await withController(async ({ controller, escrowB }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); escrowB.clearRecord('profile-1'); await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), - ).rejects.toBeInstanceOf(MutationRepairPendingError); + ).rejects.toBeInstanceOf(IncompleteMutationError); }); }); it('throws when the supplied epoch is invalid', async () => { await withController(async ({ controller }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, 1.5), ).rejects.toThrow('Invalid epoch'); @@ -469,21 +503,25 @@ describe('MfaRecoveryController', () => { }); describe('updateIdentifiers', () => { - it('replaces the identifier set using escrow-challenge auth', async () => { + it('replaces the identifier set', async () => { await withController(async ({ controller }) => { - await controller.register(SECRET, [PASSKEY, EMAIL]); - await controller.updateIdentifiers(EMAIL, [EMAIL], REGISTERED_EPOCH); - const recovered = await controller.getRecoverySecret(EMAIL); + await controller.register(SECRET, IDENTIFIERS); + await controller.updateIdentifiers( + PASSKEY, + [OIDC, PASSKEY], + REGISTERED_EPOCH, + ); + const recovered = await controller.getRecoverySecret(OIDC); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); it('rejects an empty identifier list', async () => { await withController(async ({ controller }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); await expect( controller.updateIdentifiers(PASSKEY, [], REGISTERED_EPOCH), - ).rejects.toThrow('Identifier list must be non-empty'); + ).rejects.toThrow('Requires at least 2 identifiers'); }); }); }); @@ -491,11 +529,11 @@ describe('MfaRecoveryController', () => { describe('resume and abort', () => { it('resumes a persisted writing mutation after a replica failure', async () => { await withController(async ({ controller, escrowA }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); escrowA.failNextApplyCount = 1; await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), - ).rejects.toBeInstanceOf(MutationRepairPendingError); + ).rejects.toBeInstanceOf(IncompleteMutationError); expect(await controller.getPhase()).toBe('writing'); await controller.resume(); @@ -507,11 +545,11 @@ describe('MfaRecoveryController', () => { it('repairs only the missing replica when an acknowledged replica is offline', async () => { await withController(async ({ controller, escrowA, escrowB }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); escrowA.failNextApplyCount = 1; await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), - ).rejects.toBeInstanceOf(MutationRepairPendingError); + ).rejects.toBeInstanceOf(IncompleteMutationError); escrowB.available = false; @@ -528,14 +566,15 @@ describe('MfaRecoveryController', () => { it('clears a fully acknowledged mutation without checking escrow availability', async () => { await withController(async ({ controller, escrowA, escrowB }) => { - await controller.register(SECRET, [PASSKEY]); - const pending = await getValidWritingPending(); - pending.receipts = ['escrow-a', 'escrow-b'].map( - (escrowId): MutationReceipt => ({ + await controller.register(SECRET, IDENTIFIERS); + const pending = await getValidWritingPending([escrowA, escrowB]); + pending.receipts = [escrowA, escrowB].map( + (escrow): MutationReceipt => ({ mutationId: pending.mutation.id, requestHash: pending.mutation.requestHash, - escrowId, + escrowId: escrow.id, version: pending.mutation.newVersion, + receiptKeyId: wrapKeyId(escrow.wrapPublicKey), signature: '0xsignature', }), ); @@ -563,7 +602,7 @@ describe('MfaRecoveryController', () => { it('aborts an authorizing mutation and refuses to abort writing', async () => { await withController(async ({ options }) => { - const validPending = await getValidWritingPending(); + const validPending = await getValidWritingPending(options.escrows); const authorizing = await passthroughEncryptor.encrypt({ phase: 'authorizing', mutation: validPending.mutation, @@ -588,6 +627,7 @@ describe('MfaRecoveryController', () => { requestHash: validPending.mutation.requestHash, escrowId: 'escrow-a', version: validPending.mutation.newVersion, + receiptKeyId: '0xreceipt', signature: '0xsignature', }, ], @@ -609,8 +649,8 @@ describe('MfaRecoveryController', () => { await withController(async ({ options }) => { const payload = { epoch: 0, + identifiers: IDENTIFIERS, recoverySecret: bytesToHex(SECRET), - identifiers: [PASSKEY], }; const payloadHash = await hash(payload); const fields = { @@ -649,8 +689,8 @@ describe('MfaRecoveryController', () => { it('resumes a persisted authorizing updateIdentifiers mutation', async () => { await withController(async ({ controller, options }) => { - await controller.register(SECRET, [PASSKEY]); - const payload = { epoch: REGISTERED_EPOCH, identifiers: [EMAIL] }; + await controller.register(SECRET, IDENTIFIERS); + const payload = { epoch: REGISTERED_EPOCH, identifiers: IDENTIFIERS }; const payloadHash = await hash(payload); const mutationFields = { id: '0xmutidentifiers', @@ -683,7 +723,7 @@ describe('MfaRecoveryController', () => { expect(await resumed.getPhase()).toBe('idle'); expect( - bytesToHex((await resumed.getRecoverySecret(EMAIL)).recoverySecret), + bytesToHex((await resumed.getRecoverySecret(PASSKEY)).recoverySecret), ).toBe(bytesToHex(SECRET)); }); }); @@ -697,72 +737,74 @@ describe('MfaRecoveryController', () => { }); it('clears a writing mutation that already has every receipt', async () => { - await withController(async ({ controller, options }) => { - await controller.register(SECRET, [PASSKEY]); - const mutation = { - id: '0xmut', - profileId: 'profile-1', - operation: 'updateRecoverySecret' as const, - expectedVersion: 1, - newVersion: 2, - payloadHash: await hash({ + await withController( + async ({ controller, options, escrowA, escrowB }) => { + await controller.register(SECRET, IDENTIFIERS); + const payload = { epoch: REGISTERED_EPOCH, recoverySecret: bytesToHex(SECRET_2), - }), - audiences: ['escrow-a', 'escrow-b'], - requestHash: '0x', - }; - mutation.requestHash = await hash({ - id: mutation.id, - profileId: mutation.profileId, - operation: mutation.operation, - expectedVersion: mutation.expectedVersion, - newVersion: mutation.newVersion, - payloadHash: mutation.payloadHash, - audiences: mutation.audiences, - }); - const receipts = [ - { - mutationId: mutation.id, - requestHash: mutation.requestHash, - escrowId: 'escrow-a', - version: 2, - signature: '0x', - }, - { - mutationId: mutation.id, - requestHash: mutation.requestHash, - escrowId: 'escrow-b', - version: 2, - signature: '0x', - }, - ]; - const writing = await passthroughEncryptor.encrypt({ - phase: 'writing', - mutation, - authControllerToken: { + }; + const mutation = { + id: '0xmut', profileId: 'profile-1', - requestHash: mutation.requestHash, - twoFactor: true, - issuer: 'stub-auth', - expiresAt: Date.now() + 60_000, - signature: 'stub-auth-signature', - }, - payload: { - epoch: REGISTERED_EPOCH, - recoverySecret: bytesToHex(SECRET_2), - }, - identifier: PASSKEY, - receipts, - }); - const resumed = new MfaRecoveryController({ - ...options, - messenger: getMessenger(getRootMessenger()), - state: { pendingOperation: writing }, - }); - await resumed.resume(); - expect(await resumed.getPhase()).toBe('idle'); - }); + operation: 'updateRecoverySecret' as const, + expectedVersion: 1, + newVersion: 2, + payloadHash: await hash(payload), + audiences: ['escrow-a', 'escrow-b'], + requestHash: '0x', + }; + mutation.requestHash = await hash({ + id: mutation.id, + profileId: mutation.profileId, + operation: mutation.operation, + expectedVersion: mutation.expectedVersion, + newVersion: mutation.newVersion, + payloadHash: mutation.payloadHash, + audiences: mutation.audiences, + }); + const receipts = [ + { + mutationId: mutation.id, + requestHash: mutation.requestHash, + escrowId: 'escrow-a', + version: 2, + receiptKeyId: wrapKeyId(escrowA.wrapPublicKey), + signature: '0x', + }, + { + mutationId: mutation.id, + requestHash: mutation.requestHash, + escrowId: 'escrow-b', + version: 2, + receiptKeyId: wrapKeyId(escrowB.wrapPublicKey), + signature: '0x', + }, + ]; + const writing = await passthroughEncryptor.encrypt({ + phase: 'writing', + mutation, + authControllerToken: { + profileId: 'profile-1', + requestHash: mutation.requestHash, + twoFactor: true, + issuer: 'stub-auth', + expiresAt: unixNow() + 60, + signature: 'stub-auth-signature', + }, + payload, + identifier: PASSKEY, + receipts, + }); + const resumed = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: writing }, + }); + await resumed.resume(); + expect(await resumed.getPhase()).toBe('idle'); + }, + ); }); it('rejects a persisted mutation with the wrong escrow audience', async () => { @@ -782,7 +824,7 @@ describe('MfaRecoveryController', () => { payload: { epoch: 0, recoverySecret: bytesToHex(SECRET), - identifiers: [PASSKEY], + identifiers: IDENTIFIERS, }, identifier: null, }); @@ -815,13 +857,13 @@ describe('MfaRecoveryController', () => { profileId: 'profile-1', requestHash: '0x', issuer: 'stub-auth', - expiresAt: Date.now() + 60_000, + expiresAt: unixNow() + 60, signature: 'stub-auth-signature', }, payload: { epoch: 0, recoverySecret: bytesToHex(SECRET), - identifiers: [PASSKEY], + identifiers: IDENTIFIERS, }, identifier: null, receipts: [], @@ -841,8 +883,8 @@ describe('MfaRecoveryController', () => { await withController(async ({ options }) => { const payload = { epoch: 0, + identifiers: IDENTIFIERS, recoverySecret: bytesToHex(SECRET), - identifiers: [PASSKEY], }; const payloadHash = await hash(payload); const mutation = { @@ -875,7 +917,7 @@ describe('MfaRecoveryController', () => { ), identifierOwnershipApproved: true, issuer: 'stub-auth', - expiresAt: Date.now() + 60_000, + expiresAt: unixNow() + 60, signature: 'stub-auth-signature', }, payload, @@ -886,6 +928,7 @@ describe('MfaRecoveryController', () => { requestHash: mutation.requestHash, escrowId: 'escrow-z', version: 1, + receiptKeyId: '0xreceipt', signature: '0x', }, ], @@ -906,8 +949,8 @@ describe('MfaRecoveryController', () => { escrowA.invalidReceipts = true; const payload = { epoch: 0, + identifiers: IDENTIFIERS, recoverySecret: bytesToHex(SECRET), - identifiers: [PASSKEY], }; const payloadHash = await hash(payload); const mutation = { @@ -940,7 +983,7 @@ describe('MfaRecoveryController', () => { ), identifierOwnershipApproved: true, issuer: 'stub-auth', - expiresAt: Date.now() + 60_000, + expiresAt: unixNow() + 60, signature: 'stub-auth-signature', }, payload, @@ -951,6 +994,7 @@ describe('MfaRecoveryController', () => { requestHash: mutation.requestHash, escrowId: 'escrow-a', version: 1, + receiptKeyId: '0xreceipt', signature: '0x', }, ], @@ -969,7 +1013,7 @@ describe('MfaRecoveryController', () => { it('rejects a newly issued invalid receipt', async () => { await withController(async ({ controller, escrowA }) => { escrowA.invalidReceipts = true; - await expect(controller.register(SECRET, [PASSKEY])).rejects.toThrow( + await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( 'Invalid mutation receipt', ); }); @@ -979,7 +1023,7 @@ describe('MfaRecoveryController', () => { await withController(async ({ controller, escrowA, escrowB }) => { escrowA.invalidReceipts = true; - await expect(controller.register(SECRET, [PASSKEY])).rejects.toThrow( + await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( 'Invalid mutation receipt', ); @@ -1005,11 +1049,12 @@ describe('MfaRecoveryController', () => { requestHash: '0xrequest', escrowId: 'escrow-z', version: 1, + receiptKeyId: '0xreceipt', signature: '0xsignature', }); jest.spyOn(escrowA, 'verifyReceipt').mockReturnValue(true); - await expect(controller.register(SECRET, [PASSKEY])).rejects.toThrow( + await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( 'Invalid mutation receipt', ); }); @@ -1021,7 +1066,7 @@ describe('MfaRecoveryController', () => { throw new Error('verification failed'); }); - await expect(controller.register(SECRET, [PASSKEY])).rejects.toThrow( + await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( 'Invalid mutation receipt', ); }); @@ -1029,11 +1074,11 @@ describe('MfaRecoveryController', () => { it('refreshes an expired AuthController token while resuming', async () => { await withController(async ({ controller, escrowA, options }) => { - await controller.register(SECRET, [PASSKEY]); + await controller.register(SECRET, IDENTIFIERS); escrowA.failNextApplyCount = 1; await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), - ).rejects.toBeInstanceOf(MutationRepairPendingError); + ).rejects.toBeInstanceOf(IncompleteMutationError); const pending = JSON.parse( controller.state.pendingOperation as string, ) as { authControllerToken: { expiresAt: number } }; @@ -1105,6 +1150,15 @@ describe('MfaRecoveryController', () => { pending.payload = {}; }, }, + { + name: 'an invalid recovery secret encoding', + mutate: (pending: Record): void => { + pending.payload = { + epoch: REGISTERED_EPOCH, + recoverySecret: '0xzz', + }; + }, + }, { name: 'an identifier payload with invalid entries', mutate: (pending: Record): void => { @@ -1220,6 +1274,7 @@ describe('MfaRecoveryController', () => { requestHash: mutation.requestHash, escrowId: 'escrow-a', version: mutation.newVersion, + receiptKeyId: '0xreceipt', signature: '0xsignature', }; pending.receipts = [receipt, receipt]; @@ -1230,7 +1285,7 @@ describe('MfaRecoveryController', () => { async ({ mutate }) => { await withController(async ({ escrowA, escrowB, options }) => { const pending = JSON.parse( - JSON.stringify(await getValidWritingPending()), + JSON.stringify(await getValidWritingPending(options.escrows)), ) as Record; mutate(pending); const auth = jest.spyOn( @@ -1265,10 +1320,25 @@ describe('MfaRecoveryController', () => { await expect( controller.register(SECRET, [ { type: 'unknown', namespace: 'x', value: 'y', verifier: null }, + PASSKEY, ]), ).rejects.toThrow('Unknown identifier type'); }); }); + + it.each(['emailOtp', 'smsOtp'])( + 'rejects %s until escrow-challenge OTP is implemented', + async (type) => { + await withController(async ({ controller }) => { + await expect( + controller.register(SECRET, [ + { type, namespace: 'x', value: 'y', verifier: null }, + PASSKEY, + ]), + ).rejects.toThrow('Unknown identifier type'); + }); + }, + ); }); }); @@ -1310,11 +1380,12 @@ function getOptions( identifierAuthProvider: new StubIdentifierAuthProvider(), escrows: [escrowA, escrowB], pendingOperationEncryptor: passthroughEncryptor, - collectChallengeResponse: async (): Promise => 'otp', }; } -async function getValidWritingPending(): Promise { +async function getValidWritingPending( + escrows: RecoveryEscrowProvider[], +): Promise { const payload = { epoch: REGISTERED_EPOCH, recoverySecret: bytesToHex(SECRET_2), @@ -1327,7 +1398,7 @@ async function getValidWritingPending(): Promise { expectedVersion: 1, newVersion: 2, payloadHash, - audiences: ['escrow-a', 'escrow-b'], + audiences: escrows.map((escrow) => escrow.id), }; return { phase: 'writing', @@ -1340,7 +1411,7 @@ async function getValidWritingPending(): Promise { requestHash: await hash(fields), twoFactor: true, issuer: 'stub-auth', - expiresAt: Date.now() + 60_000, + expiresAt: unixNow() + 60, signature: 'stub-auth-signature', }, payload, diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index c625c3afb16..f467f0426b0 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -5,62 +5,60 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import { bytesToHex } from '@metamask/utils'; import { - bytesToSecretHex, + decodeHex, + decryptFromPublic, + encryptToPublic, generateSigningKey, hash, randomId, - sign, + unixNow, + wrapKeyId, } from './crypto.js'; -import { MfaRecoveryError, MutationRepairPendingError } from './errors.js'; +import { IncompleteMutationError, MfaRecoveryError } from './errors.js'; import { isFulfilledResult, isMutationReceipt, selectHighestConsistentVersion, verifyMutationReceipt, } from './escrow-utils.js'; -import { getIdentifierAuthMode } from './identifier-auth.js'; +import { + authorizeKeyBoundIdentifier, + getIdentifierAuthMode, + MIN_IDENTIFIERS, +} from './identifier-auth.js'; +import type { AuthorizedEscrow } from './identifier-auth.js'; import type { MfaRecoveryControllerMethodActions } from './MfaRecoveryController-method-action-types.js'; -import { assertValidPendingOperation } from './pending-operation-validation.js'; +import { + assertValidPendingOperation, + pendingPayloadHasIdentifiers, + pendingPayloadHasRecoverySecret, +} from './pending-operation-validation.js'; import { assertAbortAllowed, getRecoveryPhase } from './state-machine.js'; import type { AuthControllerToken, - EscrowAuthChallenge, + EcPublicJwk, Identifier, - IdentifierAuthorization, Mutation, MutationPayload, MutationReceipt, + PendingMutationPayload, PendingOperation, PendingOperationEncryptor, + PendingRegisterPayload, + PendingUpdateIdentifiersPayload, + PendingUpdateRecoverySecretPayload, RecoveryAuthProvider, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, RecoveryPhase, RecoveredSecret, - RegisterPayload, - UpdateIdentifiersPayload, + WrappedSecret, WritingPendingOperation, } from './types.js'; -export type { - AuthControllerToken, - EncryptedPendingOperation, - Identifier, - IdentifierAuthorization, - Mutation, - MutationPayload, - MutationReceipt, - PendingOperation, - PendingOperationEncryptor, - RecoveryAuthProvider, - RecoveryEscrowProvider, - RecoveryIdentifierAuthProvider, - RecoveryPhase, - RecoveredSecret, -} from './types.js'; - const CONTROLLER_NAME = 'MfaRecoveryController'; const MESSENGER_EXPOSED_METHODS = [ @@ -73,11 +71,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'getPhase', ] as const; -type AuthorizedEscrow = { - escrow: RecoveryEscrowProvider; - authorization: IdentifierAuthorization; -}; - export type MfaRecoveryControllerState = { /** * Encrypted pending mutation, or `null` when idle. @@ -163,19 +156,29 @@ export type MfaRecoveryControllerOptions = { pendingOperationEncryptor: PendingOperationEncryptor; /** - * Collects a provider-specific response for an escrow-challenge identifier - * (for example an Email/SMS OTP). - */ - collectChallengeResponse: ( - challenge: EscrowAuthChallenge, - ) => Promise; - - /** - * Clock used for AuthController token expiry checks. Defaults to `Date.now`. + * Clock used for AuthController token expiry checks. Unix seconds. + * Defaults to {@link unixNow}. */ now?: () => number; }; +type MutateParams = + | { + operation: 'register'; + payload: PendingRegisterPayload; + identifier: null; + } + | { + operation: 'updateRecoverySecret'; + payload: PendingUpdateRecoverySecretPayload; + identifier: Identifier; + } + | { + operation: 'updateIdentifiers'; + payload: PendingUpdateIdentifiersPayload; + identifier: Identifier; + }; + /** * Coordinates MFA recovery secret replication across independent escrows. */ @@ -190,14 +193,8 @@ export class MfaRecoveryController extends BaseController< readonly #escrows: RecoveryEscrowProvider[]; - readonly #escrowIds: string[]; - readonly #encryptor: PendingOperationEncryptor; - readonly #collectChallengeResponse: ( - challenge: EscrowAuthChallenge, - ) => Promise; - readonly #now: () => number; #lock: Promise = Promise.resolve(); @@ -209,8 +206,7 @@ export class MfaRecoveryController extends BaseController< identifierAuthProvider, escrows, pendingOperationEncryptor, - collectChallengeResponse, - now = (): number => Date.now(), + now = unixNow, }: MfaRecoveryControllerOptions) { if (escrows.length === 0) { throw new MfaRecoveryError( @@ -236,9 +232,7 @@ export class MfaRecoveryController extends BaseController< this.#authProvider = authProvider; this.#identifierAuthProvider = identifierAuthProvider; this.#escrows = [...escrows]; - this.#escrowIds = [...escrowIds]; this.#encryptor = pendingOperationEncryptor; - this.#collectChallengeResponse = collectChallengeResponse; this.#now = now; this.messenger.registerMethodActionHandlers( @@ -251,25 +245,21 @@ export class MfaRecoveryController extends BaseController< * Creates version 1 of a recovery record. * * @param recoverySecret - Secret replicated in full to every escrow. - * @param identifiers - Ownership-approved identifier set. Must be non-empty. + * @param identifiers - Ownership-approved identifier set. Must contain at + * least two identifiers. */ async register( recoverySecret: Uint8Array, identifiers: Identifier[], ): Promise { this.#assertKnownIdentifierTypes(identifiers); - if (identifiers.length === 0) { - throw new MfaRecoveryError( - 'Registration requires at least one identifier', - 'empty_identifiers', - ); - } - await this.#runRecoveryMutation({ + this.#assertMinIdentifiers(identifiers); + await this.#mutate({ operation: 'register', payload: { epoch: 0, - recoverySecret: bytesToSecretHex(recoverySecret), identifiers, + recoverySecret: bytesToHex(recoverySecret), }, identifier: null, }); @@ -288,11 +278,11 @@ export class MfaRecoveryController extends BaseController< epoch: number, ): Promise { this.#assertKnownIdentifierTypes([identifier]); - await this.#runRecoveryMutation({ + await this.#mutate({ operation: 'updateRecoverySecret', payload: { epoch, - recoverySecret: bytesToSecretHex(recoverySecret), + recoverySecret: bytesToHex(recoverySecret), }, identifier, }); @@ -302,7 +292,8 @@ export class MfaRecoveryController extends BaseController< * Replaces the complete identifier set. * * @param identifier - Currently registered identifier used to authorize. - * @param identifiers - New non-empty identifier set. + * @param identifiers - New identifier set. Must contain at least two + * identifiers. * @param epoch - Current recovery version, used as `expectedVersion`. */ async updateIdentifiers( @@ -311,13 +302,8 @@ export class MfaRecoveryController extends BaseController< epoch: number, ): Promise { this.#assertKnownIdentifierTypes([identifier, ...identifiers]); - if (identifiers.length === 0) { - throw new MfaRecoveryError( - 'Identifier list must be non-empty', - 'empty_identifiers', - ); - } - await this.#runRecoveryMutation({ + this.#assertMinIdentifiers(identifiers); + await this.#mutate({ operation: 'updateIdentifiers', payload: { epoch, identifiers }, identifier, @@ -336,9 +322,12 @@ export class MfaRecoveryController extends BaseController< return await this.#withLock(async () => { this.#assertKnownIdentifierTypes([identifier]); const requestId = randomId(); + const ephemeral = generateSigningKey(); + const pkE = JSON.parse(ephemeral.publicKey) as EcPublicJwk; const requestHash = await hash({ operation: 'getRecoverySecret', requestId, + pkE, }); const available = await this.#getAvailableEscrows(); if (available.length === 0) { @@ -353,10 +342,29 @@ export class MfaRecoveryController extends BaseController< requestHash, }); const results = await Promise.allSettled( - authorizedEscrows.map(async ({ escrow, authorization }) => ({ - escrowId: escrow.id, - ...(await escrow.getSecret(authorization, requestId)), - })), + authorizedEscrows.map(async ({ escrow, authorization }) => { + const response = await escrow.getSecret( + authorization, + requestId, + pkE, + ); + if (response.wrapKeyId !== wrapKeyId(escrow.wrapPublicKey)) { + throw new MfaRecoveryError( + 'Unexpected wrap key', + 'wrap_key_mismatch', + ); + } + return { + escrowId: escrow.id, + recoverySecret: decryptFromPublic( + ephemeral.privateKey, + escrow.wrapPublicKey, + response.recoverySecret.ciphertext, + ), + version: response.version, + lastMutationId: response.lastMutationId, + }; + }), ); const selected = selectHighestConsistentVersion(results); return { @@ -371,7 +379,7 @@ export class MfaRecoveryController extends BaseController< */ async resume(): Promise { await this.#withLock(async () => { - await this.#repairPendingMutation(this.#escrows); + await this.#repairPendingMutation(); }); } @@ -396,24 +404,17 @@ export class MfaRecoveryController extends BaseController< return getRecoveryPhase(await this.#loadPending()); } - async #runRecoveryMutation({ - operation, - payload, - identifier, - }: { - operation: Mutation['operation']; - payload: MutationPayload; - identifier: Identifier | null; - }): Promise { + async #mutate(params: MutateParams): Promise { + const { operation, payload, identifier } = params; await this.#withLock(async () => { - const configured = this.#escrows; - await this.#repairPendingMutation(configured); + await this.#repairPendingMutation(); + const profileId = await this.#authProvider.getAuthenticatedProfileId(); - const targets = await this.#requireAllEscrows(configured); - const audiences = [...this.#escrowIds]; + const audiences = this.#escrows.map((escrow) => escrow.id); const currentVersion = this.#resolveCurrentRecoveryVersion(payload); const payloadHash = await hash(payload); - const mutation = await this.#createMutation({ + + const mutationFields = { id: randomId(), profileId, operation, @@ -421,202 +422,199 @@ export class MfaRecoveryController extends BaseController< newVersion: currentVersion + 1, payloadHash, audiences, - }); + }; + const mutation: Mutation = { + ...mutationFields, + requestHash: await hash(mutationFields), + }; - await this.#persistPending({ + const pending: PendingOperation = { phase: 'authorizing', mutation, payload, identifier, - }); + }; + await this.#persistPending(pending); + const authControllerToken = await this.#authorizeMutation( + mutation, + payload, + ); await this.#replicateMutation({ - escrows: targets, - pending: { - phase: 'writing', - mutation, - authControllerToken: await this.#authorizeMutation(mutation, payload), - payload, - identifier, - receipts: [], - }, + ...pending, + phase: 'writing', + authControllerToken, + receipts: [], }); }); } - async #createMutation( - fields: Omit, - ): Promise { - return { - ...fields, - requestHash: await hash({ - id: fields.id, - profileId: fields.profileId, - operation: fields.operation, - expectedVersion: fields.expectedVersion, - newVersion: fields.newVersion, - payloadHash: fields.payloadHash, - audiences: fields.audiences, - }), - }; - } - async #authorizeMutation( mutation: Mutation, - payload: MutationPayload, + payload: PendingMutationPayload, ): Promise { - const identifiers = - mutation.operation === 'register' || - mutation.operation === 'updateIdentifiers' - ? (payload as RegisterPayload | UpdateIdentifiersPayload).identifiers - : undefined; return await this.#authProvider.authorizeRecoveryRequest({ requestHash: mutation.requestHash, ...(mutation.operation === 'register' ? {} : { requireTwoFactor: true }), - ...(identifiers === undefined ? {} : { identifiers }), + ...(pendingPayloadHasIdentifiers(payload) + ? { identifiers: payload.identifiers } + : {}), }); } - async #repairPendingMutation( - escrows: RecoveryEscrowProvider[], - ): Promise { - const saved = await this.#loadPending(); - if (!saved) { + async #repairPendingMutation(): Promise { + const pending = await this.#loadPending(); + if (!pending) { return; } - if (saved.phase === 'authorizing') { - const availableEscrows = await this.#requireAllEscrows(escrows); + + if (pending.phase === 'authorizing') { await this.#replicateMutation({ - escrows: availableEscrows, - pending: { - phase: 'writing', - mutation: saved.mutation, - authControllerToken: await this.#authorizeMutation( - saved.mutation, - saved.payload, - ), - payload: saved.payload, - identifier: saved.identifier, - receipts: [], - }, - availabilityChecked: true, + ...pending, + phase: 'writing', + authControllerToken: await this.#authorizeMutation( + pending.mutation, + pending.payload, + ), + receipts: [], }); return; } - await this.#replicateMutation({ escrows, pending: saved }); + // writing phase + await this.#replicateMutation(pending); } - async #replicateMutation({ - escrows, - pending, - availabilityChecked = false, - }: { - escrows: RecoveryEscrowProvider[]; - pending: WritingPendingOperation; - availabilityChecked?: boolean; - }): Promise { + async #replicateMutation(pending: WritingPendingOperation): Promise { const { mutation, payload, identifier } = pending; - const entries = escrows.map((escrow) => ({ - escrow, - id: this.#getConfiguredEscrowId(escrow), - })); - pending.receipts.forEach((receipt) => { - const entry = entries.find((item) => item.id === receipt.escrowId); - if (entry === undefined) { - throw new MfaRecoveryError( - 'Receipt escrow is not configured', - 'unknown_receipt_escrow', - ); - } - if (!verifyMutationReceipt(receipt, mutation, entry.escrow, entry.id)) { - throw new MfaRecoveryError( - 'Invalid mutation receipt', - 'invalid_receipt', - ); - } - }); - const acknowledged = new Set( - pending.receipts.map((receipt) => receipt.escrowId), - ); - const targets = entries - .filter((entry) => !acknowledged.has(entry.id)) - .map((entry) => entry.escrow); - if (targets.length === 0) { + const remainingEscrows = this.#getRemainingEscrows(pending); + if (remainingEscrows.length === 0) { await this.#clearPending(); return; } - const availableTargets = availabilityChecked - ? targets - : await this.#requireAllEscrows(targets); - const refreshedToken = this.#tokenNeedsRefresh(pending.authControllerToken) - ? await this.#authorizeMutation(mutation, payload) - : pending.authControllerToken; - const effectivePending = - refreshedToken === pending.authControllerToken - ? pending - : { ...pending, authControllerToken: refreshedToken }; - const authorizations = + + const availableEscrows = await this.#getAvailableEscrows(remainingEscrows); + if (availableEscrows.length !== remainingEscrows.length) { + throw new MfaRecoveryError( + 'All configured escrows are required for mutation', + 'escrow_unavailable', + ); + } + + const authControllerToken = + pending.authControllerToken.expiresAt <= this.#now() + ? await this.#authorizeMutation(mutation, payload) + : pending.authControllerToken; + const writing = { ...pending, authControllerToken }; + const authorizedEscrows = mutation.operation === 'register' - ? undefined + ? null : await this.#authorizeIdentifier({ - escrows: availableTargets, + escrows: remainingEscrows, identifier: identifier as Identifier, requestHash: mutation.requestHash, }); if ( - authorizations !== undefined && - authorizations.length !== availableTargets.length + authorizedEscrows !== null && + authorizedEscrows.length !== remainingEscrows.length ) { throw new MfaRecoveryError( 'Unable to authorize every escrow', 'identifier_auth_failed', ); } - await this.#persistPending(effectivePending); + const payloads = remainingEscrows.map((escrow) => + this.#payloadForEscrow(writing, escrow), + ); + + await this.#persistPending(writing); const results = await Promise.allSettled( - availableTargets.map((escrow, index) => + remainingEscrows.map((escrow, index) => escrow.applyMutation( mutation, - effectivePending.authControllerToken, - authorizations?.[index]?.authorization ?? null, - payload, + writing.authControllerToken, + authorizedEscrows?.[index]?.authorization ?? null, + payloads[index], ), ), ); - const receipts: MutationReceipt[] = [...effectivePending.receipts]; + const { receipts, hasInvalidReceipt } = this.#collectMutationReceipts( + results, + remainingEscrows, + mutation, + writing.receipts, + ); + await this.#persistPending({ ...writing, receipts }); + + if (hasInvalidReceipt) { + throw new MfaRecoveryError('Invalid mutation receipt', 'invalid_receipt'); + } + + if ( + new Set(receipts.map((receipt) => receipt.escrowId)).size !== + this.#escrows.length + ) { + throw new IncompleteMutationError(mutation.id); + } + await this.#clearPending(); + } + + #getRemainingEscrows( + pending: WritingPendingOperation, + ): RecoveryEscrowProvider[] { + const { mutation } = pending; + const remainingEscrows: RecoveryEscrowProvider[] = []; + for (const escrow of this.#escrows) { + const receipt = pending.receipts.find( + (item) => item.escrowId === escrow.id, + ); + if (receipt === undefined) { + remainingEscrows.push(escrow); + continue; + } + if (!verifyMutationReceipt(receipt, mutation, escrow)) { + throw new MfaRecoveryError( + 'Invalid mutation receipt', + 'invalid_receipt', + ); + } + } + if ( + remainingEscrows.length + pending.receipts.length !== + this.#escrows.length + ) { + throw new MfaRecoveryError( + 'Receipt escrow is not configured', + 'unknown_receipt_escrow', + ); + } + return remainingEscrows; + } + + #collectMutationReceipts( + results: PromiseSettledResult[], + escrows: RecoveryEscrowProvider[], + mutation: Mutation, + existingReceipts: MutationReceipt[], + ): { receipts: MutationReceipt[]; hasInvalidReceipt: boolean } { + const receipts = [...existingReceipts]; let hasInvalidReceipt = false; results.forEach((result, index) => { if (!isFulfilledResult(result)) { return; } - const entry = { - escrow: availableTargets[index], - id: this.#getConfiguredEscrowId(availableTargets[index]), - }; + const escrow = escrows[index]; if ( !isMutationReceipt(result.value) || - !verifyMutationReceipt(result.value, mutation, entry.escrow, entry.id) + !verifyMutationReceipt(result.value, mutation, escrow) ) { hasInvalidReceipt = true; return; } receipts.push(result.value); }); - await this.#persistPending({ ...effectivePending, receipts }); - - if (hasInvalidReceipt) { - throw new MfaRecoveryError('Invalid mutation receipt', 'invalid_receipt'); - } - - if ( - new Set(receipts.map((receipt) => receipt.escrowId)).size !== - escrows.length - ) { - throw new MutationRepairPendingError(mutation.id); - } - await this.#clearPending(); + return { receipts, hasInvalidReceipt }; } async #authorizeIdentifier({ @@ -628,92 +626,52 @@ export class MfaRecoveryController extends BaseController< identifier: Identifier; requestHash: string; }): Promise { - const mode = getIdentifierAuthMode(identifier.type); - if (mode === 'key-bound') { - const proofKey = await generateSigningKey(); - const token = - await this.#identifierAuthProvider.getKeyBoundIdentifierToken({ - identifier, - proofPublicKey: proofKey.publicKey, - requestHash, - }); - const challengeResults = await Promise.allSettled( - escrows.map((escrow) => escrow.generateChallenge(proofKey.publicKey)), - ); - const challenges = challengeResults.flatMap((result, index) => - isFulfilledResult(result) - ? [{ escrow: escrows[index], challenge: result.value }] - : [], - ); - const authorizationResults = await Promise.allSettled( - challenges.map(async ({ escrow, challenge }) => { - const message = await hash([token, challenge.id, requestHash]); - return { - escrow, - authorization: { - kind: 'key-bound' as const, - token, - proof: { - challengeId: challenge.id, - requestHash, - signature: await sign(proofKey.privateKey, message), - }, - }, - }; - }), - ); - return authorizationResults - .filter(isFulfilledResult) - .map((result) => result.value); - } + getIdentifierAuthMode(identifier.type); + return await authorizeKeyBoundIdentifier({ + escrows, + identifier, + requestHash, + identifierAuthProvider: this.#identifierAuthProvider, + }); + } - const challengeResults = await Promise.allSettled( - escrows.map((escrow) => - escrow.beginIdentifierAuthentication({ identifier, requestHash }), - ), - ); - const challenges = challengeResults.flatMap((result, index) => - isFulfilledResult(result) - ? [{ escrow: escrows[index], challenge: result.value }] - : [], - ); - const responseResults = await Promise.allSettled( - challenges.map(async (entry) => ({ - ...entry, - response: await this.#collectChallengeResponse(entry.challenge), - })), - ); - const responses = responseResults - .filter(isFulfilledResult) - .map((result) => result.value); - const grantResults = await Promise.allSettled( - responses.map(async ({ escrow, challenge, response }) => ({ - escrow, - grant: await escrow.completeIdentifierAuthentication( - challenge.id, - response, - ), - })), + #payloadForEscrow( + pending: WritingPendingOperation, + escrow: RecoveryEscrowProvider, + ): MutationPayload { + const { payload } = pending; + if (!pendingPayloadHasRecoverySecret(payload)) { + return { + epoch: payload.epoch, + identifiers: payload.identifiers, + }; + } + const recoverySecret = this.#wrapRecoverySecret( + decodeHex(payload.recoverySecret), + escrow.wrapPublicKey, ); - return grantResults.filter(isFulfilledResult).map(({ value }) => ({ - escrow: value.escrow, - authorization: { - kind: 'escrow-challenge' as const, - grant: value.grant, - }, - })); + if (pendingPayloadHasIdentifiers(payload)) { + return { + epoch: payload.epoch, + identifiers: payload.identifiers, + recoverySecret, + }; + } + return { epoch: payload.epoch, recoverySecret }; } - #resolveCurrentRecoveryVersion(payload: MutationPayload): number { + #resolveCurrentRecoveryVersion(payload: { epoch: number }): number { if (!Number.isInteger(payload.epoch) || payload.epoch < 0) { throw new MfaRecoveryError('Invalid epoch', 'invalid_epoch'); } return payload.epoch; } - async #getAvailableEscrows(): Promise { + async #getAvailableEscrows( + escrows: RecoveryEscrowProvider[] = this.#escrows, + ): Promise { const flags = await Promise.allSettled( - this.#escrows.map(async (escrow) => ({ + escrows.map(async (escrow) => ({ escrow, available: await escrow.isAvailable(), })), @@ -725,32 +683,34 @@ export class MfaRecoveryController extends BaseController< ); } - async #requireAllEscrows( - escrows: RecoveryEscrowProvider[], - ): Promise { - const available = await Promise.all( - escrows.map(async (escrow) => ({ - escrow, - available: await escrow.isAvailable(), - })), - ); - if (available.some((item) => !item.available)) { - throw new MfaRecoveryError( - 'All configured escrows are required for mutation', - 'escrow_unavailable', - ); + #assertKnownIdentifierTypes(identifiers: Identifier[]): void { + for (const identifier of identifiers) { + getIdentifierAuthMode(identifier.type); } - return escrows; } - #tokenNeedsRefresh(token: AuthControllerToken): boolean { - return token.expiresAt <= this.#now(); + #assertMinIdentifiers(identifiers: Identifier[]): void { + if (identifiers.length < MIN_IDENTIFIERS) { + throw new MfaRecoveryError( + `Requires at least ${MIN_IDENTIFIERS} identifiers`, + 'empty_identifiers', + ); + } } - #assertKnownIdentifierTypes(identifiers: Identifier[]): void { - for (const identifier of identifiers) { - getIdentifierAuthMode(identifier.type); - } + #wrapRecoverySecret( + plaintext: Uint8Array, + wrapPublicKey: string, + ): WrappedSecret { + const ephemeral = generateSigningKey(); + return { + pkE: JSON.parse(ephemeral.publicKey) as EcPublicJwk, + ciphertext: encryptToPublic( + ephemeral.privateKey, + wrapPublicKey, + plaintext, + ), + }; } async #loadPending(): Promise { @@ -761,21 +721,25 @@ export class MfaRecoveryController extends BaseController< const pending = (await this.#encryptor.decrypt( pendingOperation, )) as unknown; - await assertValidPendingOperation(pending, this.#escrowIds); + await this.#assertPending(pending); return pending as PendingOperation; } - #getConfiguredEscrowId(escrow: RecoveryEscrowProvider): string { - return this.#escrowIds[this.#escrows.indexOf(escrow)]; - } - async #persistPending(operation: PendingOperation): Promise { + await this.#assertPending(operation); const encrypted = await this.#encryptor.encrypt(operation); this.update((state) => { state.pendingOperation = encrypted; }); } + async #assertPending(pending: unknown): Promise { + await assertValidPendingOperation( + pending, + this.#escrows.map((escrow) => escrow.id), + ); + } + async #clearPending(): Promise { this.update((state) => { state.pendingOperation = null; diff --git a/packages/mfa-recovery-controller/src/crypto.test.ts b/packages/mfa-recovery-controller/src/crypto.test.ts index 9c1f29c53f8..4ea1108ec4a 100644 --- a/packages/mfa-recovery-controller/src/crypto.test.ts +++ b/packages/mfa-recovery-controller/src/crypto.test.ts @@ -1,18 +1,31 @@ import { bytesToHex } from '@metamask/utils'; import { - bytesToSecretHex, canonicalize, canonicalizeIdentifiers, + decodeHex, + decryptFromPublic, + encryptToPublic, generateSigningKey, hash, - secretHexToBytes, + hashMutationReceipt, sign, + unixNow, verifySignature, + wrapKeyId, } from './crypto.js'; import type { Identifier } from './types.js'; describe('crypto', () => { + it('returns unix time in seconds', () => { + const before = Math.floor(Date.now() / 1000); + const now = unixNow(); + const after = Math.floor(Date.now() / 1000); + expect(Number.isInteger(now)).toBe(true); + expect(now).toBeGreaterThanOrEqual(before); + expect(now).toBeLessThanOrEqual(after); + }); + it('canonicalizes objects with sorted keys and Uint8Array values', () => { expect(canonicalize({ b: 1, a: new Uint8Array([1, 2]) })).toBe( `{"a":"${bytesToHex(new Uint8Array([1, 2]))}","b":1}`, @@ -23,14 +36,15 @@ describe('crypto', () => { expect(await hash({ b: 1, a: 2 })).toBe(await hash({ a: 2, b: 1 })); }); - it('round-trips secret hex without a 0x prefix', () => { + it('decodes hex with or without a 0x prefix', () => { const bytes = new Uint8Array([255, 0, 16]); - const encoded = bytesToSecretHex(bytes); - expect(secretHexToBytes(encoded.slice(2))).toStrictEqual(bytes); + const encoded = bytesToHex(bytes); + expect(decodeHex(encoded.slice(2))).toStrictEqual(bytes); + expect(decodeHex(encoded)).toStrictEqual(bytes); }); it('round-trips a P-256 proof signature', async () => { - const key = await generateSigningKey(); + const key = generateSigningKey(); const signature = await sign(key.privateKey, 'hello'); expect(await verifySignature(key.publicKey, signature, 'hello')).toBe(true); @@ -64,4 +78,78 @@ describe('crypto', () => { canonicalize(canonicalizeIdentifiers([second, first])), ); }); + + it('round-trips wrap encryption to a wrap public key', () => { + const wrapKey = generateSigningKey(); + const ephemeral = generateSigningKey(); + const plaintext = new Uint8Array([1, 2, 3, 4, 5]); + const ciphertext = encryptToPublic( + ephemeral.privateKey, + wrapKey.publicKey, + plaintext, + ); + + expect(ciphertext.startsWith('0x')).toBe(false); + expect( + decryptFromPublic(wrapKey.privateKey, ephemeral.publicKey, ciphertext), + ).toStrictEqual(plaintext); + }); + + it('round-trips getSecret wrapping with an ephemeral client key', () => { + const wrapKey = generateSigningKey(); + const ephemeral = generateSigningKey(); + const plaintext = new Uint8Array([9, 8, 7]); + const ciphertext = encryptToPublic( + wrapKey.privateKey, + ephemeral.publicKey, + plaintext, + ); + + expect( + decryptFromPublic(ephemeral.privateKey, wrapKey.publicKey, ciphertext), + ).toStrictEqual(plaintext); + expect(wrapKeyId(wrapKey.publicKey)).toMatch(/^0x[0-9a-f]{64}$/u); + }); + + it('hashes an unsigned mutation receipt independently of key order', async () => { + const receipt = { + mutationId: '0x1', + requestHash: '0x2', + escrowId: 'cubist', + version: 1, + receiptKeyId: '0xabc', + }; + + expect(await hashMutationReceipt(receipt)).toBe( + await hashMutationReceipt({ + version: 1, + receiptKeyId: '0xabc', + requestHash: '0x2', + mutationId: '0x1', + escrowId: 'cubist', + }), + ); + expect(await hashMutationReceipt(receipt)).toBe( + await hash({ + escrowId: 'cubist', + mutationId: '0x1', + receiptKeyId: '0xabc', + requestHash: '0x2', + version: 1, + }), + ); + }); + + it('rejects truncated wrap ciphertext and invalid JWKs', () => { + const wrapKey = generateSigningKey(); + expect(() => + decryptFromPublic(wrapKey.privateKey, wrapKey.publicKey, '00'), + ).toThrow('Wrapped secret is truncated'); + expect(() => wrapKeyId('{"kty":"OKP"}')).toThrow( + 'Invalid P-256 public JWK', + ); + expect(() => + decryptFromPublic('{}', wrapKey.publicKey, '00'.repeat(32)), + ).toThrow('Invalid P-256 private JWK'); + }); }); diff --git a/packages/mfa-recovery-controller/src/crypto.ts b/packages/mfa-recovery-controller/src/crypto.ts index 7580f7a2a65..07a09b1d178 100644 --- a/packages/mfa-recovery-controller/src/crypto.ts +++ b/packages/mfa-recovery-controller/src/crypto.ts @@ -8,9 +8,16 @@ import { stringToBytes, } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import { p256 } from '@noble/curves/p256'; +import { chacha20poly1305 } from '@noble/ciphers/chacha'; +import { p256 } from '@noble/curves/nist'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 as sha256Sync } from '@noble/hashes/sha2'; -import type { Identifier } from './types.js'; +import type { EcPublicJwk, Identifier } from './types.js'; + +const WRAP_INFO = new TextEncoder().encode('escrow-wrap-v1'); +const CHACHA_KEY_LEN = 32; +const CHACHA_NONCE_LEN = 12; /** * Recursively sorts object keys so hashes are independent of property order. @@ -55,7 +62,16 @@ export function canonicalizeIdentifiers(identifiers: Identifier[]): unknown { * @returns A random 0x-prefixed id. */ export function randomId(): Hex { - return bytesToHex(globalThis.crypto.getRandomValues(new Uint8Array(16))); + return bytesToHex(randomBytes(16)); +} + +/** + * Unix time in seconds. AuthController tokens and PoP challenges use this unit. + * + * @returns Seconds since the Unix epoch. + */ +export function unixNow(): number { + return Math.floor(Date.now() / 1000); } /** @@ -63,11 +79,11 @@ export function randomId(): Hex { * * @returns JWK-encoded public and private keys. */ -export async function generateSigningKey(): Promise<{ +export function generateSigningKey(): { publicKey: string; privateKey: string; -}> { - const privateKeyBytes = p256.utils.randomPrivateKey(); +} { + const privateKeyBytes = p256.utils.randomSecretKey(); const publicKeyBytes = p256.getPublicKey(privateKeyBytes, false); const publicJwk = { kty: 'EC', @@ -136,52 +152,131 @@ export async function verifySignature( } /** - * Receipt domain-separated digest from the recovery ADR. + * Unsigned mutation-receipt digest. SHA-256 of canonical JSON of the receipt + * fields excluding `signature`. * - * @param receiptFields - Receipt fields covered by the signature. - * @param receiptFields.escrowId - Escrow that issued the receipt. - * @param receiptFields.mutationId - Mutation id. - * @param receiptFields.requestHash - Mutation request hash. - * @param receiptFields.version - Applied version. + * @param receipt - Receipt fields covered by the signature. + * @param receipt.escrowId - Escrow that issued the receipt. + * @param receipt.mutationId - Mutation id. + * @param receipt.receiptKeyId - SHA-256 of the uncompressed receipt public key. + * @param receipt.requestHash - Mutation request hash. + * @param receipt.version - Applied version. * @returns Hex digest. */ -export async function hashMutationReceipt(receiptFields: { +export async function hashMutationReceipt(receipt: { escrowId: string; mutationId: string; + receiptKeyId: string; requestHash: string; version: number; }): Promise { - return await hash([ - 'mfa-recovery-mutation-receipt-v1', - receiptFields.escrowId, - receiptFields.mutationId, - receiptFields.requestHash, - receiptFields.version, - ]); + return await hash({ + escrowId: receipt.escrowId, + mutationId: receipt.mutationId, + receiptKeyId: receipt.receiptKeyId, + requestHash: receipt.requestHash, + version: receipt.version, + }); +} + +/** + * Decodes hex that may or may not have a `0x` prefix. + * + * @param hexString - Hex string. + * @returns Raw bytes. + */ +export function decodeHex(hexString: string): Uint8Array { + return hexToBytes( + (hexString.startsWith('0x') ? hexString : `0x${hexString}`) as Hex, + ); } /** - * Converts bytes to 0x-prefixed hex. + * SHA-256 of the uncompressed P-256 wrap public key, as `0x` hex. * - * @param bytes - Secret bytes. - * @returns Hex string. + * @param wrapPublicKey - Wrap public JWK JSON. + * @returns Key id matching cubist `wrapKeyId`. */ -export function bytesToSecretHex(bytes: Uint8Array): Hex { - return bytesToHex(bytes); +export function wrapKeyId(wrapPublicKey: string): Hex { + return bytesToHex(sha256Sync(publicKeyBytesFromJwk(wrapPublicKey))); } /** - * Converts a 0x-prefixed hex secret to bytes. + * Encrypts plaintext to a P-256 public key using escrow-wrap-v1. * - * @param secretHex - Hex string. - * @returns Secret bytes. + * @param privateKey - Sender P-256 private JWK JSON. + * @param publicKey - Recipient P-256 public JWK JSON. + * @param plaintext - Bytes to encrypt. + * @returns Unprefixed hex of `nonce || ciphertext+tag`. */ -export function secretHexToBytes(secretHex: string): Uint8Array { - return hexToBytes( - (secretHex.startsWith('0x') ? secretHex : `0x${secretHex}`) as Hex, +export function encryptToPublic( + privateKey: string, + publicKey: string, + plaintext: Uint8Array, +): string { + const key = deriveTransportKey(privateKey, publicKey); + const nonce = randomBytes(CHACHA_NONCE_LEN); + const ciphertext = chacha20poly1305(key, nonce).encrypt(plaintext); + return bytesToHex(concatBytes([nonce, ciphertext])).slice(2); +} + +/** + * Decrypts escrow-wrap-v1 ciphertext produced by {@link encryptToPublic}. + * + * @param privateKey - Recipient P-256 private JWK JSON. + * @param publicKey - Sender P-256 public JWK JSON. + * @param ciphertextHex - Unprefixed or `0x` hex of `nonce || ciphertext+tag`. + * @returns Plaintext bytes. + */ +export function decryptFromPublic( + privateKey: string, + publicKey: string, + ciphertextHex: string, +): Uint8Array { + const key = deriveTransportKey(privateKey, publicKey); + const blob = decodeHex(ciphertextHex); + if (blob.length < CHACHA_NONCE_LEN) { + throw new Error('Wrapped secret is truncated'); + } + return chacha20poly1305(key, blob.subarray(0, CHACHA_NONCE_LEN)).decrypt( + blob.subarray(CHACHA_NONCE_LEN), ); } +function deriveTransportKey(privateKey: string, publicKey: string): Uint8Array { + const privateBytes = privateKeyBytesFromJwk(privateKey); + const publicBytes = publicKeyBytesFromJwk(publicKey); + const shared = p256.getSharedSecret(privateBytes, publicBytes); + // noble returns a compressed point (33 bytes). Cubist uses the 32-byte x-coordinate. + const ikm = shared.slice(1, 33); + return hkdf(sha256Sync, ikm, undefined, WRAP_INFO, CHACHA_KEY_LEN); +} + +function privateKeyBytesFromJwk(privateKey: string): Uint8Array { + const jwk = JSON.parse(privateKey) as { d?: string }; + if (typeof jwk.d !== 'string') { + throw new Error('Invalid P-256 private JWK'); + } + return fromBase64Url(jwk.d); +} + +function publicKeyBytesFromJwk(publicKey: string): Uint8Array { + const jwk = JSON.parse(publicKey) as EcPublicJwk; + if ( + jwk.kty !== 'EC' || + jwk.crv !== 'P-256' || + typeof jwk.x !== 'string' || + typeof jwk.y !== 'string' + ) { + throw new Error('Invalid P-256 public JWK'); + } + return concatBytes([ + new Uint8Array([0x04]), + fromBase64Url(jwk.x), + fromBase64Url(jwk.y), + ]); +} + /** * Encodes bytes as unpadded base64url (JWK coordinate format). * @@ -195,6 +290,14 @@ function toBase64Url(bytes: Uint8Array): string { .replace(/[=]+$/u, ''); } +/** + * @param length - Number of bytes to generate. + * @returns CSPRNG bytes. + */ +function randomBytes(length: number): Uint8Array { + return globalThis.crypto.getRandomValues(new Uint8Array(length)); +} + /** * Decodes unpadded base64url JWK coordinates. * diff --git a/packages/mfa-recovery-controller/src/errors.ts b/packages/mfa-recovery-controller/src/errors.ts index b6887291fca..f807207e878 100644 --- a/packages/mfa-recovery-controller/src/errors.ts +++ b/packages/mfa-recovery-controller/src/errors.ts @@ -1,5 +1,5 @@ /** - * Errors thrown by {@link MfaRecoveryController}. + * Errors thrown by `MfaRecoveryController`. */ export class MfaRecoveryError extends Error { readonly code: string; @@ -13,17 +13,18 @@ export class MfaRecoveryError extends Error { } /** - * Thrown when a mutation was applied at some but not all configured escrows. - * Call {@link MfaRecoveryController.resume} to retry the same mutation. + * Thrown when a mutation is not acknowledged by every configured escrow. + * Call `MfaRecoveryController.resume()` to retry the same mutation. */ -export class MutationRepairPendingError extends MfaRecoveryError { +export class IncompleteMutationError extends MfaRecoveryError { readonly mutationId: string; constructor(mutationId: string) { super( `Mutation ${mutationId} is not acknowledged by every escrow`, - 'mutation_repair_pending', + 'incomplete_mutation', ); + this.name = 'IncompleteMutationError'; this.mutationId = mutationId; } } diff --git a/packages/mfa-recovery-controller/src/escrow-utils.ts b/packages/mfa-recovery-controller/src/escrow-utils.ts index 7566fd68039..85ccfb5de7e 100644 --- a/packages/mfa-recovery-controller/src/escrow-utils.ts +++ b/packages/mfa-recovery-controller/src/escrow-utils.ts @@ -41,6 +41,7 @@ export function isMutationReceipt(value: unknown): value is MutationReceipt { typeof value.version === 'number' && Number.isInteger(value.version) && value.version >= 0 && + typeof value.receiptKeyId === 'string' && typeof value.signature === 'string' ); } @@ -54,20 +55,18 @@ export function isMutationReceipt(value: unknown): value is MutationReceipt { * @param receipt - Receipt returned by an escrow. * @param mutation - Mutation the receipt must acknowledge. * @param escrow - Expected target escrow provider. - * @param expectedEscrowId - Build-configured identity of the target escrow. * @returns Whether the receipt is valid for the target. */ export function verifyMutationReceipt( receipt: MutationReceipt, mutation: Mutation, escrow: RecoveryEscrowProvider, - expectedEscrowId: string, ): boolean { - if (receipt.escrowId !== expectedEscrowId) { + if (receipt.escrowId !== escrow.id) { return false; } try { - return escrow.verifyReceipt(receipt, mutation, expectedEscrowId); + return escrow.verifyReceipt(receipt, mutation, escrow.id); } catch { return false; } diff --git a/packages/mfa-recovery-controller/src/identifier-auth.ts b/packages/mfa-recovery-controller/src/identifier-auth.ts index e6b781634cc..e277e895193 100644 --- a/packages/mfa-recovery-controller/src/identifier-auth.ts +++ b/packages/mfa-recovery-controller/src/identifier-auth.ts @@ -1,18 +1,95 @@ +import { generateSigningKey, hash, sign } from './crypto.js'; import { MfaRecoveryError } from './errors.js'; -import type { IdentifierAuthMode } from './types.js'; +import { isFulfilledResult } from './escrow-utils.js'; +import type { + Identifier, + IdentifierAuthMode, + IdentifierAuthorization, + RecoveryEscrowProvider, + RecoveryIdentifierAuthProvider, +} from './types.js'; + +/** + * Minimum identifier count for register and updateIdentifiers. + */ +export const MIN_IDENTIFIERS = 2; /** * Trusted identifier-type registry. Escrows and the controller derive the * authentication mode from this table, never from a client-selected flag. + * + * TODO: MFA-605 / MFA-606 / MFA-568 — add `emailOtp` / `smsOtp` as + * `escrow-challenge` once escrow OTP commands exist. */ export const IDENTIFIER_AUTH_MODES: Record = { oidc: 'key-bound', passkey: 'key-bound', siwe: 'key-bound', - emailOtp: 'escrow-challenge', - smsOtp: 'escrow-challenge', }; +export type AuthorizedEscrow = { + escrow: RecoveryEscrowProvider; + authorization: IdentifierAuthorization; +}; + +/** + * Authorizes an identifier with each escrow that successfully provides a + * challenge. + * + * @param params - Identifier authorization parameters. + * @param params.escrows - Escrows to authorize. + * @param params.identifier - Identifier to authorize. + * @param params.requestHash - Request hash bound to the authorization. + * @param params.identifierAuthProvider - Provider for key-bound assertions. + * @returns Successful escrow authorizations. + */ +export async function authorizeKeyBoundIdentifier({ + escrows, + identifier, + requestHash, + identifierAuthProvider, +}: { + escrows: RecoveryEscrowProvider[]; + identifier: Identifier; + requestHash: string; + identifierAuthProvider: RecoveryIdentifierAuthProvider; +}): Promise { + const proofKey = generateSigningKey(); + const token = await identifierAuthProvider.getKeyBoundIdentifierToken({ + identifier, + proofPublicKey: proofKey.publicKey, + requestHash, + }); + const challengeResults = await Promise.allSettled( + escrows.map((escrow) => escrow.generateChallenge()), + ); + const challenges = challengeResults.flatMap((result, index) => + isFulfilledResult(result) + ? [{ escrow: escrows[index], challenge: result.value }] + : [], + ); + const authorizationResults = await Promise.allSettled( + challenges.map(async ({ escrow, challenge }) => { + const message = await hash([token, challenge.id, requestHash]); + return { + escrow, + authorization: { + kind: 'key-bound' as const, + token, + proof: { + challengeId: challenge.id, + requestHash, + signature: await sign(proofKey.privateKey, message), + }, + }, + }; + }), + ); + return authorizationResults + .filter(isFulfilledResult) + .map((result) => result.value); +} + /** * Returns the auth mode for an identifier type. * diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts index 842b5cc94a9..36fb4526842 100644 --- a/packages/mfa-recovery-controller/src/index.ts +++ b/packages/mfa-recovery-controller/src/index.ts @@ -20,24 +20,33 @@ export type { MfaRecoveryControllerUpdateIdentifiersAction, MfaRecoveryControllerUpdateRecoverySecretAction, } from './MfaRecoveryController-method-action-types.js'; -export { MfaRecoveryError, MutationRepairPendingError } from './errors.js'; +export { IncompleteMutationError, MfaRecoveryError } from './errors.js'; export { IDENTIFIER_AUTH_MODES, + MIN_IDENTIFIERS, getIdentifierAuthMode, } from './identifier-auth.js'; export type { AuthControllerToken, + EcPublicJwk, EncryptedPendingOperation, Identifier, IdentifierAuthorization, Mutation, MutationPayload, MutationReceipt, + PendingMutationPayload, PendingOperation, PendingOperationEncryptor, + PendingRecoverySecretHex, + PendingRegisterPayload, + PendingUpdateIdentifiersPayload, + PendingUpdateRecoverySecretPayload, RecoveryAuthProvider, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, RecoveryPhase, RecoveredSecret, + WrappedSecret, + WrappedSecretOutput, } from './types.js'; diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts index 542f1b183e4..12bd32aa4df 100644 --- a/packages/mfa-recovery-controller/src/pending-operation-validation.ts +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -1,17 +1,17 @@ import { hash } from './crypto.js'; import { MfaRecoveryError } from './errors.js'; import { isMutationReceipt } from './escrow-utils.js'; -import { getIdentifierAuthMode } from './identifier-auth.js'; +import { MIN_IDENTIFIERS, getIdentifierAuthMode } from './identifier-auth.js'; import { assertSameAudienceIds } from './state-machine.js'; import type { AuthControllerToken, Identifier, Mutation, - MutationPayload, MutationReceipt, - RegisterPayload, - UpdateIdentifiersPayload, - UpdateRecoverySecretPayload, + PendingMutationPayload, + PendingRegisterPayload, + PendingUpdateIdentifiersPayload, + PendingUpdateRecoverySecretPayload, } from './types.js'; /** @@ -61,15 +61,13 @@ export async function assertValidPendingOperation( assertSameAudienceIds(mutation.audiences, escrowIds); const { payload } = pending; - if (!isMutationPayload(mutation.operation, payload)) { + if (!isPendingPayload(mutation.operation, payload)) { throwInvalidPendingOperation(); } - const payloadIdentifiers = - mutation.operation === 'updateRecoverySecret' - ? [] - : (payload as RegisterPayload | UpdateIdentifiersPayload).identifiers; - for (const identifier of payloadIdentifiers) { - getIdentifierAuthMode(identifier.type); + if (pendingPayloadHasIdentifiers(payload)) { + for (const identifier of payload.identifiers) { + getIdentifierAuthMode(identifier.type); + } } if ( @@ -105,7 +103,7 @@ export async function assertValidPendingOperation( throwInvalidPendingOperation(); } - if (pending.phase !== 'writing') { + if (pending.phase === 'authorizing') { return; } if ( @@ -136,40 +134,62 @@ function isMutationOperation(value: unknown): value is Mutation['operation'] { ); } -function isMutationPayload( +/** + * @param payload - Logical pending mutation payload. + * @returns Whether the payload includes an identifier set. + */ +export function pendingPayloadHasIdentifiers( + payload: PendingMutationPayload, +): payload is PendingRegisterPayload | PendingUpdateIdentifiersPayload { + return Object.prototype.hasOwnProperty.call(payload, 'identifiers'); +} + +/** + * @param payload - Logical pending mutation payload. + * @returns Whether the payload includes a hex recovery secret. + */ +export function pendingPayloadHasRecoverySecret( + payload: PendingMutationPayload, +): payload is PendingRegisterPayload | PendingUpdateRecoverySecretPayload { + return Object.prototype.hasOwnProperty.call(payload, 'recoverySecret'); +} + +function isPendingPayload( operation: Mutation['operation'], payload: unknown, -): payload is MutationPayload { - if (!isRecord(payload)) { +): payload is PendingMutationPayload { + if (!isRecord(payload) || !isNonNegativeInteger(payload.epoch)) { return false; } - if (!isNonNegativeInteger(payload.epoch)) { + const keys = Object.keys(payload); + if (operation === 'updateRecoverySecret') { + return ( + keys.every((key) => key === 'epoch' || key === 'recoverySecret') && + isHexSecret(payload.recoverySecret) + ); + } + if ( + !Array.isArray(payload.identifiers) || + payload.identifiers.length < MIN_IDENTIFIERS || + !payload.identifiers.every(isIdentifier) + ) { return false; } - if (operation === 'updateRecoverySecret') { + if (operation === 'register') { return ( - Object.keys(payload).every( - (key) => key === 'epoch' || key === 'recoverySecret', - ) && - typeof (payload as UpdateRecoverySecretPayload).recoverySecret === - 'string' + payload.epoch === 0 && + isHexSecret(payload.recoverySecret) && + keys.every( + (key) => + key === 'epoch' || key === 'identifiers' || key === 'recoverySecret', + ) ); } - const { identifiers } = payload as RegisterPayload | UpdateIdentifiersPayload; - return ( - Object.keys(payload).every((key) => - operation === 'register' - ? key === 'epoch' || key === 'recoverySecret' || key === 'identifiers' - : key === 'epoch' || key === 'identifiers', - ) && - (operation === 'register' - ? payload.epoch === 0 && - typeof (payload as RegisterPayload).recoverySecret === 'string' - : true) && - Array.isArray(identifiers) && - identifiers.length > 0 && - identifiers.every(isIdentifier) - ); + return keys.every((key) => key === 'epoch' || key === 'identifiers'); +} + +function isHexSecret(value: unknown): value is string { + return typeof value === 'string' && /^0x(?:[0-9a-fA-F]{2})+$/u.test(value); } function isNonNegativeInteger(value: unknown): value is number { @@ -200,12 +220,12 @@ function isAuthControllerToken(value: unknown): value is AuthControllerToken { value.identifierOwnershipApproved === true) && typeof value.issuer === 'string' && typeof value.expiresAt === 'number' && - Number.isFinite(value.expiresAt) && + Number.isInteger(value.expiresAt) && + value.expiresAt >= 0 && typeof value.signature === 'string' ); } - function isMutationReceiptArray(value: unknown): value is MutationReceipt[] { if (!Array.isArray(value)) { return false; diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index 1671ca3a186..d9b07d9194a 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -12,10 +12,13 @@ export type Identifier = { export type PoPChallenge = { id: string; escrowId: string; + /** + * Unix time in seconds. + */ expiresAt: number; }; -export type IdentifierAuthMode = 'key-bound' | 'escrow-challenge'; +export type IdentifierAuthMode = 'key-bound'; export type KeyBoundIdentifierToken = { identifier: Identifier; @@ -30,29 +33,16 @@ export type ProofOfPossession = { signature: string; }; -export type EscrowAuthChallenge = { - id: string; - escrowId: string; - identifier: Identifier; - requestHash: string; - expiresAt: number; -}; - -export type EscrowIdentifierGrant = { - id: string; +/** + * Identifier proof presented to an escrow. Only key-bound proofs are wired; + * escrow-challenge grants land with MFA-605 / MFA-606 / MFA-568. + */ +export type IdentifierAuthorization = { + kind: 'key-bound'; + token: KeyBoundIdentifierToken; + proof: ProofOfPossession; }; -export type IdentifierAuthorization = - | { - kind: 'key-bound'; - token: KeyBoundIdentifierToken; - proof: ProofOfPossession; - } - | { - kind: 'escrow-challenge'; - grant: EscrowIdentifierGrant; - }; - export type MutationOperation = | 'register' | 'updateRecoverySecret' @@ -76,6 +66,9 @@ export type AuthControllerToken = { identifiersHash?: string; identifierOwnershipApproved?: true; issuer: string; + /** + * Unix time in seconds. + */ expiresAt: number; signature: string; }; @@ -85,18 +78,45 @@ export type MutationReceipt = { requestHash: string; escrowId: string; version: number; + /** + * SHA-256 of the uncompressed receipt public key, as `0x` hex. + */ + receiptKeyId: string; signature: string; }; +/** + * P-256 public JWK used as an ephemeral wrap key (`pkE`). + */ +export type EcPublicJwk = { + kty: 'EC'; + crv: 'P-256'; + x: string; + y: string; +}; + +/** + * Recovery secret wrapped to one escrow wrap key for transit. + * `ciphertext` is unprefixed hex of `nonce || ChaCha20-Poly1305(ciphertext+tag)`. + */ +export type WrappedSecret = { + pkE: EcPublicJwk; + ciphertext: string; +}; + +export type WrappedSecretOutput = { + ciphertext: string; +}; + export type RegisterPayload = { epoch: number; - recoverySecret: string; + recoverySecret: WrappedSecret; identifiers: Identifier[]; }; export type UpdateRecoverySecretPayload = { epoch: number; - recoverySecret: string; + recoverySecret: WrappedSecret; }; export type UpdateIdentifiersPayload = { @@ -104,24 +124,68 @@ export type UpdateIdentifiersPayload = { identifiers: Identifier[]; }; +/** + * Escrow `applyMutation` body. Recovery secrets are wrapped to that escrow's + * wrap key; do not confuse with {@link PendingMutationPayload}. + */ export type MutationPayload = | RegisterPayload | UpdateRecoverySecretPayload | UpdateIdentifiersPayload; -export type AuthorizingPendingOperation = { - phase: 'authorizing'; +/** + * 0x-hex of a plaintext recovery secret. Encrypted pending state stores this + * logical value; each escrow receives a {@link WrappedSecret} only at apply. + */ +export type PendingRecoverySecretHex = string; + +/** + * Logical register payload stored in encrypted pending state. + */ +export type PendingRegisterPayload = { + epoch: number; + identifiers: Identifier[]; + recoverySecret: PendingRecoverySecretHex; +}; + +/** + * Logical secret-update payload stored in encrypted pending state. + */ +export type PendingUpdateRecoverySecretPayload = { + epoch: number; + recoverySecret: PendingRecoverySecretHex; +}; + +/** + * Logical identifier-update payload stored in encrypted pending state. + */ +export type PendingUpdateIdentifiersPayload = { + epoch: number; + identifiers: Identifier[]; +}; + +/** + * Logical mutation payload persisted in encrypted pending state. Distinct from + * {@link MutationPayload}, which is the per-escrow apply body. + */ +export type PendingMutationPayload = + | PendingRegisterPayload + | PendingUpdateRecoverySecretPayload + | PendingUpdateIdentifiersPayload; + +type PendingOperationBase = { mutation: Mutation; - payload: MutationPayload; identifier: Identifier | null; + payload: PendingMutationPayload; +}; + +export type AuthorizingPendingOperation = PendingOperationBase & { + phase: 'authorizing'; }; -export type WritingPendingOperation = { +export type WritingPendingOperation = PendingOperationBase & { phase: 'writing'; - mutation: Mutation; authControllerToken: AuthControllerToken; - payload: MutationPayload; - identifier: Identifier | null; receipts: MutationReceipt[]; }; @@ -133,7 +197,8 @@ export type RecoveryPhase = 'idle' | PendingOperation['phase']; /** * Encoded ciphertext of {@link PendingOperation}. Wallet secure storage - * encrypts this value; a raw recovery secret is never persisted in the clear. + * encrypts this blob (including any hex recovery secret). A raw recovery + * secret is never persisted in BaseController state. * * The recovery spec's pseudocode represents this as `Uint8Array`, but the * controller stores the encoded ciphertext as a string so BaseController state @@ -141,10 +206,11 @@ export type RecoveryPhase = 'idle' | PendingOperation['phase']; */ export type EncryptedPendingOperation = string; -export type RecoverySecretResponse = { - recoverySecret: Uint8Array; +export type GetRecoverySecretResponse = { + recoverySecret: WrappedSecretOutput; version: number; lastMutationId: string; + wrapKeyId: string; }; /** @@ -183,20 +249,19 @@ export type RecoveryIdentifierAuthProvider = { */ export type RecoveryEscrowProvider = { readonly id: string; + /** + * P-256 wrap public JWK JSON. Mutation payloads are encrypted to this key; + * `getSecret` responses are encrypted to the request's ephemeral key using + * the corresponding wrap private key. + */ + readonly wrapPublicKey: string; isAvailable: () => Promise; - generateChallenge: (publicKey: string) => Promise; - beginIdentifierAuthentication: (params: { - identifier: Identifier; - requestHash: string; - }) => Promise; - completeIdentifierAuthentication: ( - challengeId: string, - response: unknown, - ) => Promise; + generateChallenge: () => Promise; getSecret: ( authorization: IdentifierAuthorization, requestId: string, - ) => Promise; + pkE: EcPublicJwk, + ) => Promise; applyMutation: ( mutation: Mutation, authControllerToken: AuthControllerToken, diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index 9270d6e5915..9b85735384f 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -1,16 +1,25 @@ +import { bytesToHex } from '@metamask/utils'; + import { canonicalizeIdentifiers, + decodeHex, + decryptFromPublic, + encryptToPublic, + generateSigningKey, hash, hashMutationReceipt, - secretHexToBytes, + unixNow, verifySignature, + wrapKeyId, } from '../src/crypto.js'; import { MfaRecoveryError } from '../src/errors.js'; -import { getIdentifierAuthMode } from '../src/identifier-auth.js'; +import { + MIN_IDENTIFIERS, + getIdentifierAuthMode, +} from '../src/identifier-auth.js'; import type { AuthControllerToken, - EscrowAuthChallenge, - EscrowIdentifierGrant, + EcPublicJwk, Identifier, IdentifierAuthorization, KeyBoundIdentifierToken, @@ -23,10 +32,11 @@ import type { RecoveryAuthProvider, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, - RecoverySecretResponse, + GetRecoverySecretResponse, RegisterPayload, UpdateIdentifiersPayload, UpdateRecoverySecretPayload, + WrappedSecret, } from '../src/types.js'; type RecoveryRecord = { @@ -40,13 +50,7 @@ type RecoveryRecord = { type StoredPoPChallenge = PoPChallenge & { consumed: boolean }; -type StoredEscrowAuth = EscrowAuthChallenge & { - completed: boolean; - consumed: boolean; - grant?: EscrowIdentifierGrant; -}; - -const CHALLENGE_TTL_MS = 5 * 60 * 1000; +const CHALLENGE_TTL_SECS = 5 * 60; /** * In-memory AuthController used in tests. @@ -54,7 +58,7 @@ const CHALLENGE_TTL_MS = 5 * 60 * 1000; export class StubAuthProvider implements RecoveryAuthProvider { profileId = 'profile-1'; - now: () => number = () => Date.now(); + now: () => number = unixNow; async getAuthenticatedProfileId(): Promise { return this.profileId; @@ -80,7 +84,7 @@ export class StubAuthProvider implements RecoveryAuthProvider { identifierOwnershipApproved: true as const, }), issuer: 'stub-auth', - expiresAt: this.now() + 60 * 60 * 1000, + expiresAt: this.now() + 60 * 60, signature: 'stub-auth-signature', }; } @@ -121,7 +125,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { invalidReceipts = false; - now: () => number = () => Date.now(); + now: () => number = unixNow; readonly #records = new Map(); @@ -129,22 +133,19 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { readonly #popChallenges = new Map(); - readonly #escrowAuth = new Map(); + readonly id: string; - readonly #grants = new Map< - string, - { - identifier: Identifier; - requestHash: string; - expiresAt: number; - consumed: boolean; - } - >(); + readonly wrapPublicKey: string; - readonly id: string; + readonly #wrapPrivateKey: string; - constructor(id: string) { + constructor( + id: string, + wrapKey: { publicKey: string; privateKey: string } = generateSigningKey(), + ) { this.id = id; + this.wrapPublicKey = wrapKey.publicKey; + this.#wrapPrivateKey = wrapKey.privateKey; } async isAvailable(): Promise { @@ -155,7 +156,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { const challenge: StoredPoPChallenge = { id: `${this.id}-pop-${this.#popChallenges.size + 1}`, escrowId: this.id, - expiresAt: this.now() + CHALLENGE_TTL_MS, + expiresAt: this.now() + CHALLENGE_TTL_SECS, consumed: false, }; this.#popChallenges.set(challenge.id, challenge); @@ -166,62 +167,6 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { }; } - async beginIdentifierAuthentication(params: { - identifier: Identifier; - requestHash: string; - }): Promise { - if (getIdentifierAuthMode(params.identifier.type) !== 'escrow-challenge') { - throw new MfaRecoveryError( - 'Identifier type is not escrow-challenge', - 'invalid_auth_mode', - ); - } - const challenge: StoredEscrowAuth = { - id: `${this.id}-otp-${this.#escrowAuth.size + 1}`, - escrowId: this.id, - identifier: params.identifier, - requestHash: params.requestHash, - expiresAt: this.now() + CHALLENGE_TTL_MS, - completed: false, - consumed: false, - }; - this.#escrowAuth.set(challenge.id, challenge); - return { - id: challenge.id, - escrowId: challenge.escrowId, - identifier: challenge.identifier, - requestHash: challenge.requestHash, - expiresAt: challenge.expiresAt, - }; - } - - async completeIdentifierAuthentication( - challengeId: string, - _response: unknown, - ): Promise { - const challenge = this.#escrowAuth.get(challengeId); - if ( - challenge === undefined || - challenge.expiresAt <= this.now() || - challenge.completed - ) { - throw new MfaRecoveryError( - 'Invalid escrow auth challenge', - 'invalid_challenge', - ); - } - challenge.completed = true; - const grant = { id: `${challenge.id}-grant` }; - challenge.grant = grant; - this.#grants.set(grant.id, { - identifier: challenge.identifier, - requestHash: challenge.requestHash, - expiresAt: challenge.expiresAt, - consumed: false, - }); - return grant; - } - /** * Test helper: inspect a replica's version without going through getSecret. * @@ -241,7 +186,8 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { async getSecret( authorization: IdentifierAuthorization, requestId: string, - ): Promise { + pkE: EcPublicJwk, + ): Promise { if (this.failGetSecret) { throw new MfaRecoveryError( 'Injected getSecret failure', @@ -251,6 +197,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { const requestHash = await hash({ operation: 'getRecoverySecret', requestId, + pkE, }); const { profileId } = await this.#verifyIdentifierAuthorization( authorization, @@ -261,9 +208,16 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { throw new MfaRecoveryError('No recovery record', 'not_registered'); } return { - recoverySecret: secretHexToBytes(record.recoverySecret), + recoverySecret: { + ciphertext: encryptToPublic( + this.#wrapPrivateKey, + JSON.stringify(pkE), + decodeHex(record.recoverySecret), + ), + }, version: record.version, lastMutationId: record.lastMutationId, + wrapKeyId: wrapKeyId(this.wrapPublicKey), }; } @@ -310,6 +264,13 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { throw new MfaRecoveryError('Invalid registration', 'invalid_register'); } const registerPayload = payload as RegisterPayload; + const recoverySecret = this.#unwrapPayloadSecret( + registerPayload.recoverySecret, + ); + await this.#assertPayloadHash(mutation, { + ...registerPayload, + recoverySecret, + }); await this.#assertIdentifierOwnership( authControllerToken, registerPayload.identifiers, @@ -321,7 +282,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { const next: RecoveryRecord = { profileId: mutation.profileId, identifiers: registerPayload.identifiers, - recoverySecret: registerPayload.recoverySecret, + recoverySecret, version: mutation.newVersion, lastMutationId: mutation.id, appliedMutations: { @@ -361,11 +322,17 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { } if (mutation.operation === 'updateRecoverySecret') { - record.recoverySecret = ( - payload as UpdateRecoverySecretPayload - ).recoverySecret; + const recoverySecret = this.#unwrapPayloadSecret( + (payload as UpdateRecoverySecretPayload).recoverySecret, + ); + await this.#assertPayloadHash(mutation, { + epoch: payload.epoch, + recoverySecret, + }); + record.recoverySecret = recoverySecret; } else { const { identifiers } = payload as UpdateIdentifiersPayload; + await this.#assertPayloadHash(mutation, payload); await this.#assertIdentifierOwnership(authControllerToken, identifiers); this.#assertIdentifiersAvailable(mutation.profileId, identifiers); this.#replaceIdentifierIndex( @@ -397,7 +364,8 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { receipt.requestHash === mutation.requestHash && receipt.escrowId === expectedEscrowId && expectedEscrowId === this.id && - receipt.version === mutation.newVersion + receipt.version === mutation.newVersion && + receipt.receiptKeyId === wrapKeyId(this.wrapPublicKey) ); } @@ -430,18 +398,16 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { } async #signReceipt(mutation: Mutation): Promise { - const signature = await hashMutationReceipt({ - escrowId: this.id, - mutationId: mutation.id, - requestHash: mutation.requestHash, - version: mutation.newVersion, - }); - return { + const unsigned = { mutationId: mutation.id, requestHash: mutation.requestHash, escrowId: this.id, version: mutation.newVersion, - signature, + receiptKeyId: wrapKeyId(this.wrapPublicKey), + }; + return { + ...unsigned, + signature: await hashMutationReceipt(unsigned), }; } @@ -487,7 +453,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { 'ownership_not_approved', ); } - if (identifiers.length === 0) { + if (identifiers.length < MIN_IDENTIFIERS) { throw new MfaRecoveryError('Empty identifier list', 'empty_identifiers'); } } @@ -496,63 +462,34 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { authorization: IdentifierAuthorization, requestHash: string, ): Promise<{ profileId: string }> { - let identifier: Identifier; - if (authorization.kind === 'key-bound') { - const { token, proof } = authorization; - identifier = token.identifier; - if (getIdentifierAuthMode(identifier.type) !== 'key-bound') { - throw new MfaRecoveryError( - 'Not a key-bound identifier', - 'invalid_auth_mode', - ); - } - if ( - token.requestHash !== requestHash || - proof.requestHash !== requestHash - ) { - throw new MfaRecoveryError( - 'Request hash mismatch', - 'request_hash_mismatch', - ); - } - const challenge = this.#popChallenges.get(proof.challengeId); - if ( - challenge === undefined || - challenge.consumed || - challenge.escrowId !== this.id || - challenge.expiresAt <= this.now() - ) { - throw new MfaRecoveryError( - 'Invalid PoP challenge', - 'invalid_challenge', - ); - } - const message = await hash([token, proof.challengeId, requestHash]); - if ( - !(await verifySignature(token.proofPublicKey, proof.signature, message)) - ) { - throw new MfaRecoveryError('Invalid PoP signature', 'invalid_pop'); - } - challenge.consumed = true; - } else { - const grant = this.#grants.get(authorization.grant.id); - if ( - grant === undefined || - grant.consumed || - grant.requestHash !== requestHash || - grant.expiresAt <= this.now() - ) { - throw new MfaRecoveryError('Invalid escrow grant', 'invalid_grant'); - } - grant.consumed = true; - identifier = grant.identifier; - if (getIdentifierAuthMode(identifier.type) !== 'escrow-challenge') { - throw new MfaRecoveryError( - 'Not an escrow-challenge identifier', - 'invalid_auth_mode', - ); - } + const { token, proof } = authorization; + const { identifier } = token; + getIdentifierAuthMode(identifier.type); + if ( + token.requestHash !== requestHash || + proof.requestHash !== requestHash + ) { + throw new MfaRecoveryError( + 'Request hash mismatch', + 'request_hash_mismatch', + ); + } + const challenge = this.#popChallenges.get(proof.challengeId); + if ( + challenge === undefined || + challenge.consumed || + challenge.escrowId !== this.id || + challenge.expiresAt <= this.now() + ) { + throw new MfaRecoveryError('Invalid PoP challenge', 'invalid_challenge'); + } + const message = await hash([token, proof.challengeId, requestHash]); + if ( + !(await verifySignature(token.proofPublicKey, proof.signature, message)) + ) { + throw new MfaRecoveryError('Invalid PoP signature', 'invalid_pop'); } + challenge.consumed = true; const profileId = this.#identifierIndex.get( canonicalIdentifier(identifier), @@ -590,6 +527,28 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { this.#identifierIndex.set(canonicalIdentifier(identifier), profileId); } } + + async #assertPayloadHash( + mutation: Mutation, + payload: unknown, + ): Promise { + if ((await hash(payload)) !== mutation.payloadHash) { + throw new MfaRecoveryError( + 'Mutation payload does not match payloadHash', + 'payload_mismatch', + ); + } + } + + #unwrapPayloadSecret(wrapped: WrappedSecret): string { + return bytesToHex( + decryptFromPublic( + this.#wrapPrivateKey, + JSON.stringify(wrapped.pkE), + wrapped.ciphertext, + ), + ); + } } /** diff --git a/packages/mfa-recovery-controller/tsconfig.json b/packages/mfa-recovery-controller/tsconfig.json index 890b592b55a..5060138530a 100644 --- a/packages/mfa-recovery-controller/tsconfig.json +++ b/packages/mfa-recovery-controller/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.packages.json", "compilerOptions": { - "baseUrl": "./" + "baseUrl": "./", + "types": ["jest"] }, "references": [ { diff --git a/yarn.lock b/yarn.lock index 2af2a2079ba..083e2bd9529 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7708,7 +7708,9 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" + "@noble/ciphers": "npm:^1.3.0" "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" From 0c0b7d98c7bc795d158ff83286cd9290332f7873 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:03:52 +0800 Subject: [PATCH 10/55] feat(mfa-recovery-controller): hash logical pending payloads and keep epoch off the mutation body --- .../mfa-recovery-controller/ARCHITECTURE.md | 21 ++-- .../src/MfaRecoveryController.test.ts | 101 +++++++----------- .../src/MfaRecoveryController.ts | 34 +++--- .../src/crypto.test.ts | 53 +++++---- .../mfa-recovery-controller/src/crypto.ts | 27 +++-- .../src/escrow-utils.ts | 4 +- .../src/identifier-auth.ts | 4 +- .../src/pending-operation-validation.ts | 22 ++-- packages/mfa-recovery-controller/src/types.ts | 35 +++--- .../mfa-recovery-controller/tests/stubs.ts | 87 +++++++++++---- 10 files changed, 210 insertions(+), 178 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 219f22e686d..531539fb062 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -45,10 +45,10 @@ is not wired yet (MFA-605 / MFA-606 / MFA-568). `register` and `updateIdentifiers` require at least two identifiers. Recovery secrets are wrapped in transit with escrow-wrap-v1 (P-256 ECDH, HKDF, -ChaCha20-Poly1305). Encrypted pending state stores `PendingMutationPayload` -(secret as 0x-hex). At apply, each escrow receives a `MutationPayload` with -only the `{ pkE, ciphertext }` pair wrapped to its own wrap key. Reads send -the client's ephemeral `pkE` in the `getSecret` request hash. +ChaCha20-Poly1305). Pending state stores the secret as 0x-hex; `payloadHash` +is the hash of that pending payload. At apply, each escrow gets +`{ pkE, ciphertext }` wrapped to its own wrap key. Reads bind the client's +ephemeral `pkE` into the `getSecret` request hash. Public surface: `register`, `updateRecoverySecret`, `updateIdentifiers`, `getRecoverySecret`, `resume`, `abort`, `getPhase`. @@ -95,7 +95,7 @@ sequenceDiagram M->>M: lock + repair any pending mutation M->>A: getAuthenticatedProfileId M->>E: require every escrow available - M->>M: expectedVersion from payload.epoch + M->>M: expectedVersion from caller epoch (register uses 0) M->>M: persist pending = authorizing M->>A: authorizeRecoveryRequest (AuthController token) alt not register @@ -115,9 +115,16 @@ sequenceDiagram Versioning: -- `register` uses payload `epoch` `0` → version `0 → 1` -- updates use the caller-supplied payload `epoch` as the current version, then +- Version lives on the mutation (`expectedVersion` / `newVersion`), which is + what escrows apply. `epoch` is only the client-facing name for the current + replica version returned by `getRecoverySecret`. +- `register` uses expectedVersion `0` → version `0 → 1` +- updates take the caller-supplied `epoch` as `expectedVersion`, then `n → n+1`. Escrows reject a mismatch. +- Mutation `audiences` is the full configured replica-id list, in that order. + Each replica should require an exact match to its configured set (a + cubist-only deploy is `["cubist"]`; adding another escrow means every replica + is rebuilt with the expanded list). `resume()` is the same write path without creating a new mutation: authorizing pending gets a fresh token then replicates; writing pending retries only diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 0064a483c5d..47f275eeb55 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -13,7 +13,12 @@ import { StubIdentifierAuthProvider, passthroughEncryptor, } from '../tests/stubs.js'; -import { canonicalizeIdentifiers, hash, unixNow, wrapKeyId } from './crypto.js'; +import { + canonicalizeIdentifiers, + generateSigningKey, + hash, + unixNow, +} from './crypto.js'; import { IncompleteMutationError } from './errors.js'; import type { MfaRecoveryControllerMessenger, @@ -22,7 +27,6 @@ import type { import { MfaRecoveryController } from './MfaRecoveryController.js'; import type { Identifier, - MutationReceipt, RecoveryEscrowProvider, RegisterPayload, WritingPendingOperation, @@ -568,15 +572,10 @@ describe('MfaRecoveryController', () => { await withController(async ({ controller, escrowA, escrowB }) => { await controller.register(SECRET, IDENTIFIERS); const pending = await getValidWritingPending([escrowA, escrowB]); - pending.receipts = [escrowA, escrowB].map( - (escrow): MutationReceipt => ({ - mutationId: pending.mutation.id, - requestHash: pending.mutation.requestHash, - escrowId: escrow.id, - version: pending.mutation.newVersion, - receiptKeyId: wrapKeyId(escrow.wrapPublicKey), - signature: '0xsignature', - }), + pending.receipts = await Promise.all( + [escrowA, escrowB].map(async (escrow) => + escrow.createReceipt(pending.mutation), + ), ); const encrypted = await passthroughEncryptor.encrypt(pending); const availabilityA = jest.spyOn(escrowA, 'isAvailable'); @@ -648,11 +647,10 @@ describe('MfaRecoveryController', () => { it('resumes an authorizing mutation', async () => { await withController(async ({ options }) => { const payload = { - epoch: 0, identifiers: IDENTIFIERS, recoverySecret: bytesToHex(SECRET), }; - const payloadHash = await hash(payload); + const payloadHash = hash(payload); const fields = { id: '0xmutauth', profileId: 'profile-1', @@ -664,7 +662,7 @@ describe('MfaRecoveryController', () => { }; const mutation = { ...fields, - requestHash: await hash(fields), + requestHash: hash(fields), }; const authorizing = await passthroughEncryptor.encrypt({ phase: 'authorizing', @@ -690,8 +688,8 @@ describe('MfaRecoveryController', () => { it('resumes a persisted authorizing updateIdentifiers mutation', async () => { await withController(async ({ controller, options }) => { await controller.register(SECRET, IDENTIFIERS); - const payload = { epoch: REGISTERED_EPOCH, identifiers: IDENTIFIERS }; - const payloadHash = await hash(payload); + const payload = { identifiers: IDENTIFIERS }; + const payloadHash = hash(payload); const mutationFields = { id: '0xmutidentifiers', profileId: 'profile-1', @@ -703,7 +701,7 @@ describe('MfaRecoveryController', () => { }; const mutation = { ...mutationFields, - requestHash: await hash(mutationFields), + requestHash: hash(mutationFields), }; const authorizing = { phase: 'authorizing' as const, @@ -741,7 +739,6 @@ describe('MfaRecoveryController', () => { async ({ controller, options, escrowA, escrowB }) => { await controller.register(SECRET, IDENTIFIERS); const payload = { - epoch: REGISTERED_EPOCH, recoverySecret: bytesToHex(SECRET_2), }; const mutation = { @@ -750,11 +747,11 @@ describe('MfaRecoveryController', () => { operation: 'updateRecoverySecret' as const, expectedVersion: 1, newVersion: 2, - payloadHash: await hash(payload), + payloadHash: hash(payload), audiences: ['escrow-a', 'escrow-b'], requestHash: '0x', }; - mutation.requestHash = await hash({ + mutation.requestHash = hash({ id: mutation.id, profileId: mutation.profileId, operation: mutation.operation, @@ -764,22 +761,8 @@ describe('MfaRecoveryController', () => { audiences: mutation.audiences, }); const receipts = [ - { - mutationId: mutation.id, - requestHash: mutation.requestHash, - escrowId: 'escrow-a', - version: 2, - receiptKeyId: wrapKeyId(escrowA.wrapPublicKey), - signature: '0x', - }, - { - mutationId: mutation.id, - requestHash: mutation.requestHash, - escrowId: 'escrow-b', - version: 2, - receiptKeyId: wrapKeyId(escrowB.wrapPublicKey), - signature: '0x', - }, + await escrowA.createReceipt(mutation), + await escrowB.createReceipt(mutation), ]; const writing = await passthroughEncryptor.encrypt({ phase: 'writing', @@ -822,7 +805,6 @@ describe('MfaRecoveryController', () => { requestHash: '0x', }, payload: { - epoch: 0, recoverySecret: bytesToHex(SECRET), identifiers: IDENTIFIERS, }, @@ -861,7 +843,6 @@ describe('MfaRecoveryController', () => { signature: 'stub-auth-signature', }, payload: { - epoch: 0, recoverySecret: bytesToHex(SECRET), identifiers: IDENTIFIERS, }, @@ -882,11 +863,10 @@ describe('MfaRecoveryController', () => { it('rejects a receipt from an unknown escrow', async () => { await withController(async ({ options }) => { const payload = { - epoch: 0, identifiers: IDENTIFIERS, recoverySecret: bytesToHex(SECRET), }; - const payloadHash = await hash(payload); + const payloadHash = hash(payload); const mutation = { id: '0xmut', profileId: 'profile-1', @@ -897,7 +877,7 @@ describe('MfaRecoveryController', () => { audiences: ['escrow-a', 'escrow-b'], requestHash: '', }; - mutation.requestHash = await hash({ + mutation.requestHash = hash({ id: mutation.id, profileId: mutation.profileId, operation: mutation.operation, @@ -912,7 +892,7 @@ describe('MfaRecoveryController', () => { authControllerToken: { profileId: 'profile-1', requestHash: mutation.requestHash, - identifiersHash: await hash( + identifiersHash: hash( canonicalizeIdentifiers(payload.identifiers), ), identifierOwnershipApproved: true, @@ -948,11 +928,10 @@ describe('MfaRecoveryController', () => { await withController(async ({ options, escrowA }) => { escrowA.invalidReceipts = true; const payload = { - epoch: 0, identifiers: IDENTIFIERS, recoverySecret: bytesToHex(SECRET), }; - const payloadHash = await hash(payload); + const payloadHash = hash(payload); const mutation = { id: '0xmut', profileId: 'profile-1', @@ -963,7 +942,7 @@ describe('MfaRecoveryController', () => { audiences: ['escrow-a', 'escrow-b'], requestHash: '', }; - mutation.requestHash = await hash({ + mutation.requestHash = hash({ id: mutation.id, profileId: mutation.profileId, operation: mutation.operation, @@ -978,7 +957,7 @@ describe('MfaRecoveryController', () => { authControllerToken: { profileId: 'profile-1', requestHash: mutation.requestHash, - identifiersHash: await hash( + identifiersHash: hash( canonicalizeIdentifiers(payload.identifiers), ), identifierOwnershipApproved: true, @@ -1154,7 +1133,6 @@ describe('MfaRecoveryController', () => { name: 'an invalid recovery secret encoding', mutate: (pending: Record): void => { pending.payload = { - epoch: REGISTERED_EPOCH, recoverySecret: '0xzz', }; }, @@ -1173,7 +1151,6 @@ describe('MfaRecoveryController', () => { const mutation = pending.mutation as Record; mutation.operation = 'register'; pending.payload = { - epoch: 0, recoverySecret: bytesToHex(SECRET), identifiers: [PASSKEY], }; @@ -1205,14 +1182,6 @@ describe('MfaRecoveryController', () => { mutation.newVersion = 6; }, }, - { - name: 'an epoch that does not match expectedVersion', - mutate: (pending: Record): void => { - const mutation = pending.mutation as Record; - mutation.expectedVersion = 2; - mutation.newVersion = 3; - }, - }, { name: 'a mismatched payload hash', mutate: (pending: Record): void => { @@ -1387,10 +1356,9 @@ async function getValidWritingPending( escrows: RecoveryEscrowProvider[], ): Promise { const payload = { - epoch: REGISTERED_EPOCH, recoverySecret: bytesToHex(SECRET_2), }; - const payloadHash = await hash(payload); + const payloadHash = hash(payload); const fields = { id: '0xvalid-pending', profileId: 'profile-1', @@ -1404,11 +1372,11 @@ async function getValidWritingPending( phase: 'writing', mutation: { ...fields, - requestHash: await hash(fields), + requestHash: hash(fields), }, authControllerToken: { profileId: 'profile-1', - requestHash: await hash(fields), + requestHash: hash(fields), twoFactor: true, issuer: 'stub-auth', expiresAt: unixNow() + 60, @@ -1425,8 +1393,17 @@ async function withController( ): Promise { const rootMessenger = getRootMessenger(); const controllerMessenger = getMessenger(rootMessenger); - const escrowA = new StubEscrowProvider('escrow-a'); - const escrowB = new StubEscrowProvider('escrow-b'); + const replicaIds = ['escrow-a', 'escrow-b']; + const escrowA = new StubEscrowProvider( + 'escrow-a', + generateSigningKey(), + replicaIds, + ); + const escrowB = new StubEscrowProvider( + 'escrow-b', + generateSigningKey(), + replicaIds, + ); const options = { ...getOptions(escrowA, escrowB), messenger: controllerMessenger, diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index f467f0426b0..7ce7bff2872 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -162,7 +162,9 @@ export type MfaRecoveryControllerOptions = { now?: () => number; }; -type MutateParams = +type MutateParams = { + epoch: number; +} & ( | { operation: 'register'; payload: PendingRegisterPayload; @@ -177,7 +179,8 @@ type MutateParams = operation: 'updateIdentifiers'; payload: PendingUpdateIdentifiersPayload; identifier: Identifier; - }; + } +); /** * Coordinates MFA recovery secret replication across independent escrows. @@ -257,11 +260,11 @@ export class MfaRecoveryController extends BaseController< await this.#mutate({ operation: 'register', payload: { - epoch: 0, identifiers, recoverySecret: bytesToHex(recoverySecret), }, identifier: null, + epoch: 0, }); } @@ -281,10 +284,10 @@ export class MfaRecoveryController extends BaseController< await this.#mutate({ operation: 'updateRecoverySecret', payload: { - epoch, recoverySecret: bytesToHex(recoverySecret), }, identifier, + epoch, }); } @@ -305,8 +308,9 @@ export class MfaRecoveryController extends BaseController< this.#assertMinIdentifiers(identifiers); await this.#mutate({ operation: 'updateIdentifiers', - payload: { epoch, identifiers }, + payload: { identifiers }, identifier, + epoch, }); } @@ -324,7 +328,7 @@ export class MfaRecoveryController extends BaseController< const requestId = randomId(); const ephemeral = generateSigningKey(); const pkE = JSON.parse(ephemeral.publicKey) as EcPublicJwk; - const requestHash = await hash({ + const requestHash = hash({ operation: 'getRecoverySecret', requestId, pkE, @@ -405,14 +409,14 @@ export class MfaRecoveryController extends BaseController< } async #mutate(params: MutateParams): Promise { - const { operation, payload, identifier } = params; + const { operation, payload, identifier, epoch } = params; await this.#withLock(async () => { await this.#repairPendingMutation(); const profileId = await this.#authProvider.getAuthenticatedProfileId(); const audiences = this.#escrows.map((escrow) => escrow.id); - const currentVersion = this.#resolveCurrentRecoveryVersion(payload); - const payloadHash = await hash(payload); + const currentVersion = this.#resolveCurrentRecoveryVersion(epoch); + const payloadHash = hash(payload); const mutationFields = { id: randomId(), @@ -425,7 +429,7 @@ export class MfaRecoveryController extends BaseController< }; const mutation: Mutation = { ...mutationFields, - requestHash: await hash(mutationFields), + requestHash: hash(mutationFields), }; const pending: PendingOperation = { @@ -642,7 +646,6 @@ export class MfaRecoveryController extends BaseController< const { payload } = pending; if (!pendingPayloadHasRecoverySecret(payload)) { return { - epoch: payload.epoch, identifiers: payload.identifiers, }; } @@ -652,19 +655,18 @@ export class MfaRecoveryController extends BaseController< ); if (pendingPayloadHasIdentifiers(payload)) { return { - epoch: payload.epoch, identifiers: payload.identifiers, recoverySecret, }; } - return { epoch: payload.epoch, recoverySecret }; + return { recoverySecret }; } - #resolveCurrentRecoveryVersion(payload: { epoch: number }): number { - if (!Number.isInteger(payload.epoch) || payload.epoch < 0) { + #resolveCurrentRecoveryVersion(epoch: number): number { + if (!Number.isInteger(epoch) || epoch < 0) { throw new MfaRecoveryError('Invalid epoch', 'invalid_epoch'); } - return payload.epoch; + return epoch; } async #getAvailableEscrows( diff --git a/packages/mfa-recovery-controller/src/crypto.test.ts b/packages/mfa-recovery-controller/src/crypto.test.ts index 4ea1108ec4a..408d186396a 100644 --- a/packages/mfa-recovery-controller/src/crypto.test.ts +++ b/packages/mfa-recovery-controller/src/crypto.test.ts @@ -33,7 +33,7 @@ describe('crypto', () => { }); it('hashes independently of key order', async () => { - expect(await hash({ b: 1, a: 2 })).toBe(await hash({ a: 2, b: 1 })); + expect(hash({ b: 1, a: 2 })).toBe(hash({ a: 2, b: 1 })); }); it('decodes hex with or without a 0x prefix', () => { @@ -45,20 +45,14 @@ describe('crypto', () => { it('round-trips a P-256 proof signature', async () => { const key = generateSigningKey(); - const signature = await sign(key.privateKey, 'hello'); + const signature = sign(key.privateKey, 'hello'); - expect(await verifySignature(key.publicKey, signature, 'hello')).toBe(true); - expect(await verifySignature(key.publicKey, signature, 'other')).toBe( - false, - ); - expect(await verifySignature(key.publicKey, '0x00', 'hello')).toBe(false); - expect(await verifySignature(key.publicKey, 'not-hex', 'hello')).toBe( - false, - ); - expect(await verifySignature('{}', signature, 'hello')).toBe(false); - await expect(sign('{}', 'hello')).rejects.toThrow( - 'Invalid P-256 private JWK', - ); + expect(verifySignature(key.publicKey, signature, 'hello')).toBe(true); + expect(verifySignature(key.publicKey, signature, 'other')).toBe(false); + expect(verifySignature(key.publicKey, '0x00', 'hello')).toBe(false); + expect(verifySignature(key.publicKey, 'not-hex', 'hello')).toBe(false); + expect(verifySignature('{}', signature, 'hello')).toBe(false); + expect(() => sign('{}', 'hello')).toThrow('Invalid P-256 private JWK'); }); it('sorts identifiers for ownership hashes', () => { @@ -120,8 +114,8 @@ describe('crypto', () => { receiptKeyId: '0xabc', }; - expect(await hashMutationReceipt(receipt)).toBe( - await hashMutationReceipt({ + expect(hashMutationReceipt(receipt)).toBe( + hashMutationReceipt({ version: 1, receiptKeyId: '0xabc', requestHash: '0x2', @@ -129,8 +123,8 @@ describe('crypto', () => { escrowId: 'cubist', }), ); - expect(await hashMutationReceipt(receipt)).toBe( - await hash({ + expect(hashMutationReceipt(receipt)).toBe( + hash({ escrowId: 'cubist', mutationId: '0x1', receiptKeyId: '0xabc', @@ -140,6 +134,29 @@ describe('crypto', () => { ); }); + it('verifies a mutation receipt signature', async () => { + const key = generateSigningKey(); + const unsigned = { + mutationId: '0x1', + requestHash: '0x2', + escrowId: 'cubist', + version: 1, + receiptKeyId: wrapKeyId(key.publicKey), + }; + const signature = sign(key.privateKey, hashMutationReceipt(unsigned)); + + expect( + verifySignature(key.publicKey, signature, hashMutationReceipt(unsigned)), + ).toBe(true); + expect( + verifySignature( + key.publicKey, + signature, + hashMutationReceipt({ ...unsigned, version: 2 }), + ), + ).toBe(false); + }); + it('rejects truncated wrap ciphertext and invalid JWKs', () => { const wrapKey = generateSigningKey(); expect(() => diff --git a/packages/mfa-recovery-controller/src/crypto.ts b/packages/mfa-recovery-controller/src/crypto.ts index 07a09b1d178..58fa25d51be 100644 --- a/packages/mfa-recovery-controller/src/crypto.ts +++ b/packages/mfa-recovery-controller/src/crypto.ts @@ -4,7 +4,6 @@ import { bytesToHex, concatBytes, hexToBytes, - sha256, stringToBytes, } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; @@ -35,8 +34,8 @@ export function canonicalize(value: unknown): string { * @param value - Value to hash. * @returns Hex digest. */ -export async function hash(value: unknown): Promise { - return bytesToHex(await sha256(stringToBytes(canonicalize(value)))); +export function hash(value: unknown): Hex { + return bytesToHex(sha256Sync(stringToBytes(canonicalize(value)))); } /** @@ -107,39 +106,39 @@ export function generateSigningKey(): { * @param message - Message string; hashed with SHA-256 before ECDSA. * @returns Compact IEEE P1363 hex signature (`r‖s`). */ -export async function sign(privateKey: string, message: string): Promise { +export function sign(privateKey: string, message: string): Hex { const jwk = JSON.parse(privateKey) as { d?: string }; if (typeof jwk.d !== 'string') { throw new Error('Invalid P-256 private JWK'); } - const digest = await sha256(stringToBytes(message)); return bytesToHex( - p256.sign(digest, fromBase64Url(jwk.d)).toCompactRawBytes(), + p256 + .sign(sha256Sync(stringToBytes(message)), fromBase64Url(jwk.d)) + .toBytes('compact'), ); } /** - * Verifies a proof signature against a public key. + * Verifies a P-256 signature against a public key. * * @param publicKey - JWK JSON public key. * @param signature - Compact IEEE P1363 hex signature. * @param message - Message string; hashed with SHA-256 before ECDSA. * @returns Whether the signature is valid. */ -export async function verifySignature( +export function verifySignature( publicKey: string, signature: string, message: string, -): Promise { +): boolean { const jwk = JSON.parse(publicKey) as { x?: string; y?: string }; if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') { return false; } try { - const digest = await sha256(stringToBytes(message)); return p256.verify( hexToBytes(signature as Hex), - digest, + sha256Sync(stringToBytes(message)), concatBytes([ new Uint8Array([0x04]), fromBase64Url(jwk.x), @@ -163,14 +162,14 @@ export async function verifySignature( * @param receipt.version - Applied version. * @returns Hex digest. */ -export async function hashMutationReceipt(receipt: { +export function hashMutationReceipt(receipt: { escrowId: string; mutationId: string; receiptKeyId: string; requestHash: string; version: number; -}): Promise { - return await hash({ +}): Hex { + return hash({ escrowId: receipt.escrowId, mutationId: receipt.mutationId, receiptKeyId: receipt.receiptKeyId, diff --git a/packages/mfa-recovery-controller/src/escrow-utils.ts b/packages/mfa-recovery-controller/src/escrow-utils.ts index 85ccfb5de7e..fa04acf1c3e 100644 --- a/packages/mfa-recovery-controller/src/escrow-utils.ts +++ b/packages/mfa-recovery-controller/src/escrow-utils.ts @@ -49,8 +49,8 @@ export function isMutationReceipt(value: unknown): value is MutationReceipt { /** * Verifies a receipt against its expected target escrow. * - * The provider performs cryptographic verification using its build-pinned - * configuration; the target identity check is performed here as well. + * The provider checks the ECDSA receipt signature with its build-pinned + * receipt key; the target identity check is performed here as well. * * @param receipt - Receipt returned by an escrow. * @param mutation - Mutation the receipt must acknowledge. diff --git a/packages/mfa-recovery-controller/src/identifier-auth.ts b/packages/mfa-recovery-controller/src/identifier-auth.ts index e277e895193..4a2677abe1a 100644 --- a/packages/mfa-recovery-controller/src/identifier-auth.ts +++ b/packages/mfa-recovery-controller/src/identifier-auth.ts @@ -70,7 +70,7 @@ export async function authorizeKeyBoundIdentifier({ ); const authorizationResults = await Promise.allSettled( challenges.map(async ({ escrow, challenge }) => { - const message = await hash([token, challenge.id, requestHash]); + const message = hash([token, challenge.id, requestHash]); return { escrow, authorization: { @@ -79,7 +79,7 @@ export async function authorizeKeyBoundIdentifier({ proof: { challengeId: challenge.id, requestHash, - signature: await sign(proofKey.privateKey, message), + signature: sign(proofKey.privateKey, message), }, }, }; diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts index 12bd32aa4df..9df7dd7ca48 100644 --- a/packages/mfa-recovery-controller/src/pending-operation-validation.ts +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -81,16 +81,16 @@ export async function assertValidPendingOperation( getIdentifierAuthMode(pending.identifier.type); } - if (payload.epoch !== mutation.expectedVersion) { + if (mutation.operation === 'register' && mutation.expectedVersion !== 0) { throwInvalidPendingOperation(); } - if ((await hash(payload)) !== mutation.payloadHash) { + if ((hash(payload)) !== mutation.payloadHash) { throw new MfaRecoveryError( 'Pending payload does not match mutation', 'payload_mismatch', ); } - const requestHash = await hash({ + const requestHash = hash({ id: mutation.id, profileId: mutation.profileId, operation: mutation.operation, @@ -158,13 +158,13 @@ function isPendingPayload( operation: Mutation['operation'], payload: unknown, ): payload is PendingMutationPayload { - if (!isRecord(payload) || !isNonNegativeInteger(payload.epoch)) { + if (!isRecord(payload)) { return false; } const keys = Object.keys(payload); if (operation === 'updateRecoverySecret') { return ( - keys.every((key) => key === 'epoch' || key === 'recoverySecret') && + keys.every((key) => key === 'recoverySecret') && isHexSecret(payload.recoverySecret) ); } @@ -177,25 +177,17 @@ function isPendingPayload( } if (operation === 'register') { return ( - payload.epoch === 0 && isHexSecret(payload.recoverySecret) && - keys.every( - (key) => - key === 'epoch' || key === 'identifiers' || key === 'recoverySecret', - ) + keys.every((key) => key === 'identifiers' || key === 'recoverySecret') ); } - return keys.every((key) => key === 'epoch' || key === 'identifiers'); + return keys.every((key) => key === 'identifiers'); } function isHexSecret(value: unknown): value is string { return typeof value === 'string' && /^0x(?:[0-9a-fA-F]{2})+$/u.test(value); } -function isNonNegativeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isInteger(value) && value >= 0; -} - function isIdentifier(value: unknown): value is Identifier { return ( isRecord(value) && diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index d9b07d9194a..7d74e7159b2 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -109,20 +109,16 @@ export type WrappedSecretOutput = { }; export type RegisterPayload = { - epoch: number; recoverySecret: WrappedSecret; identifiers: Identifier[]; }; -export type UpdateRecoverySecretPayload = { - epoch: number; - recoverySecret: WrappedSecret; -}; +export type UpdateRecoverySecretPayload = Pick< + RegisterPayload, + 'recoverySecret' +>; -export type UpdateIdentifiersPayload = { - epoch: number; - identifiers: Identifier[]; -}; +export type UpdateIdentifiersPayload = Pick; /** * Escrow `applyMutation` body. Recovery secrets are wrapped to that escrow's @@ -143,7 +139,6 @@ export type PendingRecoverySecretHex = string; * Logical register payload stored in encrypted pending state. */ export type PendingRegisterPayload = { - epoch: number; identifiers: Identifier[]; recoverySecret: PendingRecoverySecretHex; }; @@ -151,18 +146,18 @@ export type PendingRegisterPayload = { /** * Logical secret-update payload stored in encrypted pending state. */ -export type PendingUpdateRecoverySecretPayload = { - epoch: number; - recoverySecret: PendingRecoverySecretHex; -}; +export type PendingUpdateRecoverySecretPayload = Pick< + PendingRegisterPayload, + 'recoverySecret' +>; /** * Logical identifier-update payload stored in encrypted pending state. */ -export type PendingUpdateIdentifiersPayload = { - epoch: number; - identifiers: Identifier[]; -}; +export type PendingUpdateIdentifiersPayload = Pick< + PendingRegisterPayload, + 'identifiers' +>; /** * Logical mutation payload persisted in encrypted pending state. Distinct from @@ -269,8 +264,8 @@ export type RecoveryEscrowProvider = { payload: MutationPayload, ) => Promise; /** - * Verifies a receipt cryptographically using the build-pinned key for the - * expected escrow and confirms it targets that escrow. + * Verifies a receipt cryptographically using the build-pinned receipt key + * and confirms it targets the expected escrow. * * @param receipt - Receipt returned by an escrow. * @param mutation - Mutation acknowledged by the receipt. diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index 9b85735384f..2bfc5df1dc7 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -8,6 +8,7 @@ import { generateSigningKey, hash, hashMutationReceipt, + sign, unixNow, verifySignature, wrapKeyId, @@ -72,7 +73,7 @@ export class StubAuthProvider implements RecoveryAuthProvider { const identifiersHash = params.identifiers === undefined ? undefined - : await hash(canonicalizeIdentifiers(params.identifiers)); + : hash(canonicalizeIdentifiers(params.identifiers)); return { profileId: this.profileId, requestHash: params.requestHash, @@ -104,7 +105,7 @@ export class StubIdentifierAuthProvider implements RecoveryIdentifierAuthProvide proofPublicKey: params.proofPublicKey, requestHash: params.requestHash, providerAssertion: { - bound: await hash({ + bound: hash({ proofPublicKey: params.proofPublicKey, requestHash: params.requestHash, }), @@ -137,15 +138,30 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { readonly wrapPublicKey: string; + /** + * P-256 receipt public JWK JSON. Distinct from {@link wrapPublicKey}, matching + * cubist WRAP_KEY vs RECEIPT_KEY. + */ + readonly receiptPublicKey: string; + readonly #wrapPrivateKey: string; + readonly #receiptPrivateKey: string; + + readonly #replicaIds: string[]; + constructor( id: string, wrapKey: { publicKey: string; privateKey: string } = generateSigningKey(), + replicaIds: string[] = [id], ) { this.id = id; this.wrapPublicKey = wrapKey.publicKey; this.#wrapPrivateKey = wrapKey.privateKey; + const receiptKey = generateSigningKey(); + this.receiptPublicKey = receiptKey.publicKey; + this.#receiptPrivateKey = receiptKey.privateKey; + this.#replicaIds = [...replicaIds]; } async isAvailable(): Promise { @@ -194,7 +210,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { 'get_secret_failed', ); } - const requestHash = await hash({ + const requestHash = hash({ operation: 'getRecoverySecret', requestId, pkE, @@ -268,7 +284,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { registerPayload.recoverySecret, ); await this.#assertPayloadHash(mutation, { - ...registerPayload, + identifiers: registerPayload.identifiers, recoverySecret, }); await this.#assertIdentifierOwnership( @@ -326,7 +342,6 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { (payload as UpdateRecoverySecretPayload).recoverySecret, ); await this.#assertPayloadHash(mutation, { - epoch: payload.epoch, recoverySecret, }); record.recoverySecret = recoverySecret; @@ -359,16 +374,39 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { if (this.invalidReceipts) { return false; } - return ( - receipt.mutationId === mutation.id && - receipt.requestHash === mutation.requestHash && - receipt.escrowId === expectedEscrowId && - expectedEscrowId === this.id && - receipt.version === mutation.newVersion && - receipt.receiptKeyId === wrapKeyId(this.wrapPublicKey) + if ( + receipt.mutationId !== mutation.id || + receipt.requestHash !== mutation.requestHash || + receipt.escrowId !== expectedEscrowId || + expectedEscrowId !== this.id || + receipt.version !== mutation.newVersion || + receipt.receiptKeyId !== wrapKeyId(this.receiptPublicKey) + ) { + return false; + } + return verifySignature( + this.receiptPublicKey, + receipt.signature, + hashMutationReceipt({ + escrowId: receipt.escrowId, + mutationId: receipt.mutationId, + receiptKeyId: receipt.receiptKeyId, + requestHash: receipt.requestHash, + version: receipt.version, + }), ); } + /** + * Test helper: sign a mutation receipt with this replica's receipt key. + * + * @param mutation - Mutation the receipt acknowledges. + * @returns Signed receipt. + */ + async createReceipt(mutation: Mutation): Promise { + return await this.#signReceipt(mutation); + } + /** * Test helper: remove a replica record. * @@ -403,11 +441,11 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { requestHash: mutation.requestHash, escrowId: this.id, version: mutation.newVersion, - receiptKeyId: wrapKeyId(this.wrapPublicKey), + receiptKeyId: wrapKeyId(this.receiptPublicKey), }; return { ...unsigned, - signature: await hashMutationReceipt(unsigned), + signature: sign(this.#receiptPrivateKey, hashMutationReceipt(unsigned)), }; } @@ -415,7 +453,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { mutation: Mutation, token: AuthControllerToken, ): Promise { - const requestHash = await hash({ + const requestHash = hash({ id: mutation.id, profileId: mutation.profileId, operation: mutation.operation, @@ -430,7 +468,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { token.profileId !== mutation.profileId || token.signature !== 'stub-auth-signature' || token.expiresAt <= this.now() || - !mutation.audiences.includes(this.id) + !this.#hasExactAudiences(mutation.audiences) ) { throw new MfaRecoveryError( 'Invalid mutation authorization', @@ -439,6 +477,13 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { } } + #hasExactAudiences(audiences: string[]): boolean { + return ( + audiences.length === this.#replicaIds.length && + audiences.every((id, index) => id === this.#replicaIds[index]) + ); + } + async #assertIdentifierOwnership( token: AuthControllerToken, identifiers: Identifier[], @@ -446,7 +491,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { if ( token.identifierOwnershipApproved !== true || token.identifiersHash !== - (await hash(canonicalizeIdentifiers(identifiers))) + (hash(canonicalizeIdentifiers(identifiers))) ) { throw new MfaRecoveryError( 'Identifier ownership not approved', @@ -483,10 +528,8 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { ) { throw new MfaRecoveryError('Invalid PoP challenge', 'invalid_challenge'); } - const message = await hash([token, proof.challengeId, requestHash]); - if ( - !(await verifySignature(token.proofPublicKey, proof.signature, message)) - ) { + const message = hash([token, proof.challengeId, requestHash]); + if (!verifySignature(token.proofPublicKey, proof.signature, message)) { throw new MfaRecoveryError('Invalid PoP signature', 'invalid_pop'); } challenge.consumed = true; @@ -532,7 +575,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { mutation: Mutation, payload: unknown, ): Promise { - if ((await hash(payload)) !== mutation.payloadHash) { + if ((hash(payload)) !== mutation.payloadHash) { throw new MfaRecoveryError( 'Mutation payload does not match payloadHash', 'payload_mismatch', From c96e9eec752b7af1a34f41c493709fedd9e0d6c2 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:08:29 +0800 Subject: [PATCH 11/55] chore: update changelog --- packages/mfa-recovery-controller/CHANGELOG.md | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index b37923e06f9..9e451e41ab5 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -9,24 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `MfaRecoveryController` with injectable `RecoveryAuthProvider` and `RecoveryEscrowProvider` interfaces, a persisted `idle` / `authorizing` / `writing` mutation state machine, and `register`, `updateRecoverySecret`, `updateIdentifiers`, `getRecoverySecret`, `resume`, and `abort` methods - -### Changed - -- **BREAKING:** Wrap recovery secrets in mutation payloads and `getSecret` responses with escrow-wrap-v1 (`pkE` + ChaCha20-Poly1305 ciphertext), and bind `getSecret` request hashes to the client's ephemeral `pkE` -- **BREAKING:** Wrap mutation recovery secrets to each escrow's wrap key at apply time, store the logical secret as hex inside encrypted pending state, and send each escrow only its own `{ pkE, ciphertext }` wrap -- **BREAKING:** Require at least two identifiers on `register` and `updateIdentifiers`, reject `emailOtp` / `smsOtp` until escrow-challenge OTP is implemented, and omit `RecoveryEscrowProvider` OTP challenge methods until then -- **BREAKING:** Return `{ recoverySecret, epoch }` from `getRecoverySecret` so clients can pass the selected version into later mutations -- **BREAKING:** Require `RecoveryEscrowProvider.verifyReceipt` to receive the expected escrow id so receipt verification is explicitly bound to the configured escrow target -- **BREAKING:** Bind mutation version allocation to payload `epoch` on `register`, `updateRecoverySecret`, and `updateIdentifiers`, and remove unauthenticated `RecoveryEscrowProvider.getRecoveryMetadata` lookups -- Expose controller state updates through the non-deprecated `MfaRecoveryController:stateChanged` messenger event -- Retry only escrows without persisted mutation receipts and validate persisted mutation state before resuming it - -### Fixed - -- Clear fully acknowledged pending mutations without checking unavailable acknowledged escrows -- Persist valid receipts from concurrent escrow writes before reporting an invalid receipt response -- Persist `writing` mutation state before the first escrow write so ambiguous - failures remain resumable while identifier-auth failures remain abortable +- Add `MfaRecoveryController` for replicating an MFA recovery secret across injected escrow replicas + - Public methods: `register`, `updateRecoverySecret`, `updateIdentifiers`, `getRecoverySecret`, `resume`, `abort`, and `getPhase` + - Inject `RecoveryAuthProvider`, `RecoveryIdentifierAuthProvider`, `RecoveryEscrowProvider[]`, and `PendingOperationEncryptor` + - Persist only an encrypted `authorizing` / `writing` pending mutation (`idle` when `pendingOperation` is `null`) + - `register` and `updateIdentifiers` require at least two identifiers (`MIN_IDENTIFIERS`) + - Identifier types `passkey`, `oidc`, and `siwe` are key-bound; `emailOtp` / `smsOtp` are not wired yet + - `getRecoverySecret` returns `{ recoverySecret, epoch }`; pass that `epoch` into later `updateRecoverySecret` / `updateIdentifiers` calls (register uses `0`) + - Mutation `payloadHash` is the hash of the logical pending payload (`identifiers` and/or `0x`-hex secret). At apply, each escrow receives `{ pkE, ciphertext }` wrapped to its own wrap key with escrow-wrap-v1 + - `getSecret` request hashes bind the client's ephemeral `pkE`; responses are `{ ciphertext }` plus `wrapKeyId` + - `Mutation.audiences` is the configured replica-id list, in that order. Each replica should require an exact match + - `MutationReceipt` includes `receiptKeyId`. `RecoveryEscrowProvider.verifyReceipt` takes the expected escrow id and verifies with that replica's receipt key + - `AuthControllerToken.expiresAt` and `PoPChallenge.expiresAt` are Unix seconds + - `abort()` is allowed only while `authorizing`. `writing` is persisted before the first escrow apply and must be finished with `resume()` [Unreleased]: https://github.com/MetaMask/core/ From 97c2c6a53152eff7db4730a6685c8f74a112c69c Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:46:19 +0800 Subject: [PATCH 12/55] feat(mfa-recovery-controller): refuse overlapping mutations and abort only before receipts New register/update calls require idle pending state. abort() is allowed in writing when no escrow has acknowledged the mutation, and identifiers must be distinct. --- README.md | 3 + .../mfa-recovery-controller/ARCHITECTURE.md | 16 ++--- packages/mfa-recovery-controller/CHANGELOG.md | 6 +- packages/mfa-recovery-controller/README.md | 4 +- ...aRecoveryController-method-action-types.ts | 4 +- .../src/MfaRecoveryController.test.ts | 60 +++++++++++++++---- .../src/MfaRecoveryController.ts | 21 +++++-- .../src/state-machine.ts | 17 +++--- .../mfa-recovery-controller/tests/stubs.ts | 5 +- yarn.lock | 9 +-- 10 files changed, 97 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index f6fbf34a18f..349de2609e0 100644 --- a/README.md +++ b/README.md @@ -476,6 +476,9 @@ linkStyle default opacity:0.5 message_manager --> messenger; message_manager --> utils; messenger_cli --> utils; + mfa_recovery_controller --> base_controller; + mfa_recovery_controller --> messenger; + mfa_recovery_controller --> utils; money_account_api_data_service --> base_data_service; money_account_api_data_service --> controller_utils; money_account_api_data_service --> messenger; diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 531539fb062..7aa6562e5a1 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -65,20 +65,21 @@ stateDiagram-v2 authorizing --> writing: identifier auth succeeds, before first apply writing --> writing: more receipts, still missing replicas writing --> idle: every configured escrow has a valid receipt + writing --> idle: abort() with no receipts writing --> writing: resume() retries only missing receipts authorizing --> writing: resume() authorizes then writes ``` Rules: -- **`abort()`** is allowed only in `authorizing` (no escrow write yet). -- **`writing`** must be finished with `resume()`, not aborted. +- **`abort()`** is allowed in `authorizing`, and in `writing` when there are + no receipts. Receipts must be finished with `resume()`. - `writing` is persisted before the **first escrow apply** after identifier authorization succeeds, so an ambiguous apply failure remains resumable while pre-write authorization failures stay abortable. -- Today, a new mutation also calls `#repairPendingMutation` first, then starts - a **second** mutation if repair succeeds. `resume()` is the dedicated repair - API. +- `register` / `updateRecoverySecret` / `updateIdentifiers` require idle + pending state. `resume()` finishes a persisted mutation; `abort()` drops + `authorizing`, or `writing` with no receipts. ## Mutation sequence (`register` / updates) @@ -92,7 +93,7 @@ sequenceDiagram participant E as Escrows C->>M: register / updateRecoverySecret / updateIdentifiers - M->>M: lock + repair any pending mutation + M->>M: lock; reject if a mutation is already pending M->>A: getAuthenticatedProfileId M->>E: require every escrow available M->>M: expectedVersion from caller epoch (register uses 0) @@ -186,7 +187,8 @@ flowchart TD AbortOrResume -->|abort| Idle AbortOrResume -->|resume| Auth[re-issue AuthController token] Auth --> Write[replicate] - Phase -->|writing| ResumeOnly[resume only] + Phase -->|writing, no receipts| AbortOrResume + Phase -->|writing, has receipts| ResumeOnly[resume only] ResumeOnly --> Skip[skip escrows that already have receipts] Skip --> Write Write --> All{receipts for every configured escrow?} diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 9e451e41ab5..9d5bf290454 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -9,11 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `MfaRecoveryController` for replicating an MFA recovery secret across injected escrow replicas +- Add `MfaRecoveryController` for replicating an MFA recovery secret across injected escrow replicas ([#10022](https://github.com/MetaMask/core/pull/10022)) - Public methods: `register`, `updateRecoverySecret`, `updateIdentifiers`, `getRecoverySecret`, `resume`, `abort`, and `getPhase` - Inject `RecoveryAuthProvider`, `RecoveryIdentifierAuthProvider`, `RecoveryEscrowProvider[]`, and `PendingOperationEncryptor` - Persist only an encrypted `authorizing` / `writing` pending mutation (`idle` when `pendingOperation` is `null`) - - `register` and `updateIdentifiers` require at least two identifiers (`MIN_IDENTIFIERS`) + - `register` and `updateIdentifiers` require at least two distinct identifiers (`MIN_IDENTIFIERS`) - Identifier types `passkey`, `oidc`, and `siwe` are key-bound; `emailOtp` / `smsOtp` are not wired yet - `getRecoverySecret` returns `{ recoverySecret, epoch }`; pass that `epoch` into later `updateRecoverySecret` / `updateIdentifiers` calls (register uses `0`) - Mutation `payloadHash` is the hash of the logical pending payload (`identifiers` and/or `0x`-hex secret). At apply, each escrow receives `{ pkE, ciphertext }` wrapped to its own wrap key with escrow-wrap-v1 @@ -21,6 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Mutation.audiences` is the configured replica-id list, in that order. Each replica should require an exact match - `MutationReceipt` includes `receiptKeyId`. `RecoveryEscrowProvider.verifyReceipt` takes the expected escrow id and verifies with that replica's receipt key - `AuthControllerToken.expiresAt` and `PoPChallenge.expiresAt` are Unix seconds - - `abort()` is allowed only while `authorizing`. `writing` is persisted before the first escrow apply and must be finished with `resume()` + - Mutations require idle pending state (`resume()` / `abort()` first). `abort()` is allowed while `authorizing` or `writing` with no receipts; `writing` with receipts must be finished with `resume()` [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md index 59b1d2d5fe6..cb1d525f44a 100644 --- a/packages/mfa-recovery-controller/README.md +++ b/packages/mfa-recovery-controller/README.md @@ -5,8 +5,8 @@ injected so the same controller can run against different identity providers and escrow backends. Pending mutations are persisted as encoded encrypted `authorizing` / `writing` -state so a crash can `resume()` the same mutation. `abort()` is allowed only -before the first escrow write. The encoded ciphertext is stored as a string so +state so a crash can `resume()` the same mutation. `abort()` is allowed before any +escrow receipt is stored. The encoded ciphertext is stored as a string so BaseController state serialization preserves the encrypted value. ## Installation 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 c698457b934..f4e17163469 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts @@ -64,8 +64,8 @@ export type MfaRecoveryControllerResumeAction = { }; /** - * Drops a mutation that has not yet begun writing. Writing mutations must be - * resumed instead. + * Drops a pending mutation that no escrow has acknowledged. Once a receipt + * exists, call {@link resume} instead. */ export type MfaRecoveryControllerAbortAction = { type: `MfaRecoveryController:abort`; diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 96be4d48f8f..5e0c6262374 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -211,12 +211,27 @@ describe('MfaRecoveryController', () => { }); }); + it('rejects duplicate identifiers', async () => { + await withController(async ({ controller }) => { + await expect( + controller.register(SECRET, [PASSKEY, PASSKEY]), + ).rejects.toThrow('Requires at least 2 identifiers'); + }); + }); + it('rejects a second registration for the same profile', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, IDENTIFIERS); await expect( controller.register(SECRET, IDENTIFIERS), ).rejects.toBeInstanceOf(IncompleteMutationError); + expect(await controller.getPhase()).toBe('writing'); + await controller.abort(); + await controller.updateRecoverySecret( + PASSKEY, + SECRET_2, + REGISTERED_EPOCH, + ); }); }); @@ -428,10 +443,6 @@ describe('MfaRecoveryController', () => { controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toBeInstanceOf(IncompleteMutationError); expect(await controller.getPhase()).toBe('writing'); - await expect(controller.abort()).rejects.toThrow( - 'Cannot abort a mutation once writing has begun', - ); - expect(await controller.getPhase()).toBe('writing'); await controller.resume(); expect(await controller.getPhase()).toBe('idle'); }); @@ -483,6 +494,37 @@ describe('MfaRecoveryController', () => { await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toBeInstanceOf(IncompleteMutationError); + expect(await controller.getPhase()).toBe('writing'); + }); + }); + + it('can abort a writing mutation that has no receipts', async () => { + await withController(async ({ controller }) => { + await controller.register(SECRET, IDENTIFIERS); + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2, 0), + ).rejects.toBeInstanceOf(IncompleteMutationError); + expect(await controller.getPhase()).toBe('writing'); + await controller.abort(); + expect(await controller.getPhase()).toBe('idle'); + }); + }); + + it('refuses a new mutation while one is pending', async () => { + await withController(async ({ controller, escrowA }) => { + await controller.register(SECRET, IDENTIFIERS); + escrowA.failNextApplyCount = 1; + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), + ).rejects.toBeInstanceOf(IncompleteMutationError); + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), + ).rejects.toThrow( + 'A pending mutation must be resumed or aborted first', + ); + expect(await controller.getPhase()).toBe('writing'); + await controller.resume(); + expect(await controller.getPhase()).toBe('idle'); }); }); @@ -638,7 +680,7 @@ describe('MfaRecoveryController', () => { state: { pendingOperation: writing }, }); await expect(writingController.abort()).rejects.toThrow( - 'Cannot abort a mutation once writing has begun', + 'Cannot abort a mutation once an escrow has acknowledged it', ); expect(await writingController.getPhase()).toBe('writing'); }); @@ -892,9 +934,7 @@ describe('MfaRecoveryController', () => { authControllerToken: { profileId: 'profile-1', requestHash: mutation.requestHash, - identifiersHash: hash( - canonicalizeIdentifiers(payload.identifiers), - ), + identifiersHash: hash(canonicalizeIdentifiers(payload.identifiers)), identifierOwnershipApproved: true, issuer: 'stub-auth', expiresAt: unixNow() + 60, @@ -957,9 +997,7 @@ describe('MfaRecoveryController', () => { authControllerToken: { profileId: 'profile-1', requestHash: mutation.requestHash, - identifiersHash: hash( - canonicalizeIdentifiers(payload.identifiers), - ), + identifiersHash: hash(canonicalizeIdentifiers(payload.identifiers)), identifierOwnershipApproved: true, issuer: 'stub-auth', expiresAt: unixNow() + 60, diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 7ce7bff2872..0fb67f5f7b9 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -388,13 +388,13 @@ export class MfaRecoveryController extends BaseController< } /** - * Drops a mutation that has not yet begun writing. Writing mutations must be - * resumed instead. + * Drops a pending mutation that no escrow has acknowledged. Once a receipt + * exists, call {@link resume} instead. */ async abort(): Promise { await this.#withLock(async () => { const pending = await this.#loadPending(); - assertAbortAllowed(getRecoveryPhase(pending)); + assertAbortAllowed(pending); if (pending) { await this.#clearPending(); } @@ -411,7 +411,12 @@ export class MfaRecoveryController extends BaseController< async #mutate(params: MutateParams): Promise { const { operation, payload, identifier, epoch } = params; await this.#withLock(async () => { - await this.#repairPendingMutation(); + if (await this.#loadPending()) { + throw new MfaRecoveryError( + 'A pending mutation must be resumed or aborted first', + 'pending_mutation', + ); + } const profileId = await this.#authProvider.getAuthenticatedProfileId(); const audiences = this.#escrows.map((escrow) => escrow.id); @@ -692,7 +697,13 @@ export class MfaRecoveryController extends BaseController< } #assertMinIdentifiers(identifiers: Identifier[]): void { - if (identifiers.length < MIN_IDENTIFIERS) { + const unique = new Set( + identifiers.map( + (identifier) => + `${identifier.type}\0${identifier.namespace}\0${identifier.value}`, + ), + ); + if (unique.size < MIN_IDENTIFIERS) { throw new MfaRecoveryError( `Requires at least ${MIN_IDENTIFIERS} identifiers`, 'empty_identifiers', diff --git a/packages/mfa-recovery-controller/src/state-machine.ts b/packages/mfa-recovery-controller/src/state-machine.ts index 63fa5da7367..15e9b135d88 100644 --- a/packages/mfa-recovery-controller/src/state-machine.ts +++ b/packages/mfa-recovery-controller/src/state-machine.ts @@ -14,16 +14,19 @@ export function getRecoveryPhase( } /** - * Abort is allowed only before the first escrow write. Once `writing` begins - * the same mutation must be retried to completion. + * Abort is allowed while idle, authorizing, or writing with no receipts. + * Receipts mean an escrow already applied; finish with resume() instead. * - * @param phase - Current phase. - * @throws If the mutation is already being written. + * TODO: empty receipts is not proof the apply never landed (timeout after a + * successful write). Record explicit rejects vs ambiguous failures. + * + * @param pending - Loaded pending operation, if any. + * @throws If an escrow has already acknowledged the mutation. */ -export function assertAbortAllowed(phase: RecoveryPhase): void { - if (phase === 'writing') { +export function assertAbortAllowed(pending: PendingOperation | null): void { + if (pending?.phase === 'writing' && pending.receipts.length > 0) { throw new MfaRecoveryError( - 'Cannot abort a mutation once writing has begun', + 'Cannot abort a mutation once an escrow has acknowledged it', 'abort_not_allowed', ); } diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index 2bfc5df1dc7..ba61b4f2634 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -490,8 +490,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { ): Promise { if ( token.identifierOwnershipApproved !== true || - token.identifiersHash !== - (hash(canonicalizeIdentifiers(identifiers))) + token.identifiersHash !== hash(canonicalizeIdentifiers(identifiers)) ) { throw new MfaRecoveryError( 'Identifier ownership not approved', @@ -575,7 +574,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { mutation: Mutation, payload: unknown, ): Promise { - if ((hash(payload)) !== mutation.payloadHash) { + if (hash(payload) !== mutation.payloadHash) { throw new MfaRecoveryError( 'Mutation payload does not match payloadHash', 'payload_mismatch', diff --git a/yarn.lock b/yarn.lock index 7bc060d56e2..30cd59ffad4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12105,20 +12105,13 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:*": +"@types/lodash@npm:*, @types/lodash@npm:^4.17.20": version: 4.17.25 resolution: "@types/lodash@npm:4.17.25" checksum: 10/f786e05664439cc8d327fd97c62c060d81b3c4ed52127d01fbc926139e1a669c1554bf3cd0dbb43f8fcc7d9b5c6c23c543cc36de8dc815c9f289ea852b26ebd6 languageName: node linkType: hard -"@types/lodash@npm:^4.17.20": - version: 4.17.20 - resolution: "@types/lodash@npm:4.17.20" - checksum: 10/8cd8ad3bd78d2e06a93ae8d6c9907981d5673655fec7cb274a4d9a59549aab5bb5b3017361280773b8990ddfccf363e14d1b37c97af8a9fe363de677f9a61524 - languageName: node - linkType: hard - "@types/mdast@npm:^4.0.0, @types/mdast@npm:^4.0.2": version: 4.0.4 resolution: "@types/mdast@npm:4.0.4" From d9eb38099f54b6a199676e700ee91f4cecdfd853 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:51:54 +0800 Subject: [PATCH 13/55] feat(mfa-recovery-controller): enforce mutation state checks in getRecoverySecret --- .../mfa-recovery-controller/ARCHITECTURE.md | 4 ++-- packages/mfa-recovery-controller/CHANGELOG.md | 2 +- ...faRecoveryController-method-action-types.ts | 2 +- .../src/MfaRecoveryController.test.ts | 18 ++++++++++++++++++ .../src/MfaRecoveryController.ts | 8 +++++++- 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 7aa6562e5a1..76c2c327bb2 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -160,8 +160,8 @@ sequenceDiagram end ``` -If a mutation is stuck in `writing`, replicas can already disagree. A read in -that window can look like corruption; finish with `resume()` first. +`getRecoverySecret` refuses while phase is `writing`. Replicas can disagree +in that window; finish with `resume()` first. ## Identifier auth (inside writes and reads) diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 9d5bf290454..260917702a7 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Persist only an encrypted `authorizing` / `writing` pending mutation (`idle` when `pendingOperation` is `null`) - `register` and `updateIdentifiers` require at least two distinct identifiers (`MIN_IDENTIFIERS`) - Identifier types `passkey`, `oidc`, and `siwe` are key-bound; `emailOtp` / `smsOtp` are not wired yet - - `getRecoverySecret` returns `{ recoverySecret, epoch }`; pass that `epoch` into later `updateRecoverySecret` / `updateIdentifiers` calls (register uses `0`) + - `getRecoverySecret` returns `{ recoverySecret, epoch }`; pass that `epoch` into later `updateRecoverySecret` / `updateIdentifiers` calls (register uses `0`). Refuses while a mutation is `writing` (`resume()` / `abort()` first) - Mutation `payloadHash` is the hash of the logical pending payload (`identifiers` and/or `0x`-hex secret). At apply, each escrow receives `{ pkE, ciphertext }` wrapped to its own wrap key with escrow-wrap-v1 - `getSecret` request hashes bind the client's ephemeral `pkE`; responses are `{ ciphertext }` plus `wrapKeyId` - `Mutation.audiences` is the configured replica-id list, in that order. Each replica should require an exact match 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 f4e17163469..1fb359642db 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts @@ -45,7 +45,7 @@ export type MfaRecoveryControllerUpdateIdentifiersAction = { /** * Reads the recovery secret from available escrows and returns the highest * consistent version. `epoch` is the current recovery version and is required - * by later mutations. + * by later mutations. Refuses while a mutation is `writing`. * * @param identifier - Identifier used to authorize the read. * @returns Recovered secret bytes and the selected epoch. diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 5e0c6262374..94b5f517043 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -259,6 +259,24 @@ describe('MfaRecoveryController', () => { }); describe('getRecoverySecret', () => { + it('refuses to read while a mutation is writing', async () => { + await withController(async ({ controller, escrowA }) => { + await controller.register(SECRET, IDENTIFIERS); + escrowA.failNextApplyCount = 1; + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), + ).rejects.toBeInstanceOf(IncompleteMutationError); + + await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( + 'A pending mutation must be resumed or aborted first', + ); + + await controller.resume(); + const recovered = await controller.getRecoverySecret(PASSKEY); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); + }); + }); + it('returns the selected epoch so a later update can be formed', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, IDENTIFIERS); diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 0fb67f5f7b9..72fbfb8fe4d 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -317,13 +317,19 @@ export class MfaRecoveryController extends BaseController< /** * Reads the recovery secret from available escrows and returns the highest * consistent version. `epoch` is the current recovery version and is required - * by later mutations. + * by later mutations. Refuses while a mutation is `writing`. * * @param identifier - Identifier used to authorize the read. * @returns Recovered secret bytes and the selected epoch. */ async getRecoverySecret(identifier: Identifier): Promise { return await this.#withLock(async () => { + if ((await this.#loadPending())?.phase === 'writing') { + throw new MfaRecoveryError( + 'A pending mutation must be resumed or aborted first', + 'pending_mutation', + ); + } this.#assertKnownIdentifierTypes([identifier]); const requestId = randomId(); const ephemeral = generateSigningKey(); From bd63ecb7fd686dca0427f776e04b3d55654942fa Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:03:42 +0800 Subject: [PATCH 14/55] feat(mfa-recovery-controller): add distinct identifier counting and update validation logic --- .../src/MfaRecoveryController.test.ts | 23 +++++++++++++++++++ .../src/MfaRecoveryController.ts | 15 +++++------- .../src/identifier-auth.ts | 14 +++++++++++ .../src/pending-operation-validation.ts | 12 ++++++---- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 94b5f517043..26d163109b9 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -1201,6 +1201,29 @@ describe('MfaRecoveryController', () => { pending.payload = { identifiers: [{}] }; }, }, + { + name: 'duplicate identifiers', + mutate: (pending: Record): void => { + const mutation = pending.mutation as Record; + const payload = { identifiers: [PASSKEY, PASSKEY] }; + const payloadHash = hash(payload); + const fields = { + id: mutation.id, + profileId: mutation.profileId, + operation: 'updateIdentifiers', + expectedVersion: mutation.expectedVersion, + newVersion: mutation.newVersion, + payloadHash, + audiences: mutation.audiences, + }; + pending.mutation = { ...fields, requestHash: hash(fields) }; + pending.payload = payload; + const token = pending.authControllerToken as Record; + token.requestHash = hash(fields); + token.identifierOwnershipApproved = true; + token.identifiersHash = hash(payload.identifiers); + }, + }, { name: 'a non-null register identifier', mutate: (pending: Record): void => { diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 72fbfb8fe4d..d3c2974de7e 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -26,6 +26,7 @@ import { } from './escrow-utils.js'; import { authorizeKeyBoundIdentifier, + countDistinctIdentifiers, getIdentifierAuthMode, MIN_IDENTIFIERS, } from './identifier-auth.js'; @@ -411,7 +412,9 @@ export class MfaRecoveryController extends BaseController< * @returns Current recovery phase. */ async getPhase(): Promise { - return getRecoveryPhase(await this.#loadPending()); + return await this.#withLock(async () => + getRecoveryPhase(await this.#loadPending()), + ); } async #mutate(params: MutateParams): Promise { @@ -703,16 +706,10 @@ export class MfaRecoveryController extends BaseController< } #assertMinIdentifiers(identifiers: Identifier[]): void { - const unique = new Set( - identifiers.map( - (identifier) => - `${identifier.type}\0${identifier.namespace}\0${identifier.value}`, - ), - ); - if (unique.size < MIN_IDENTIFIERS) { + if (countDistinctIdentifiers(identifiers) < MIN_IDENTIFIERS) { throw new MfaRecoveryError( `Requires at least ${MIN_IDENTIFIERS} identifiers`, - 'empty_identifiers', + 'too_few_identifiers', ); } } diff --git a/packages/mfa-recovery-controller/src/identifier-auth.ts b/packages/mfa-recovery-controller/src/identifier-auth.ts index 4a2677abe1a..e89115b7c44 100644 --- a/packages/mfa-recovery-controller/src/identifier-auth.ts +++ b/packages/mfa-recovery-controller/src/identifier-auth.ts @@ -14,6 +14,20 @@ import type { */ export const MIN_IDENTIFIERS = 2; +/** + * Distinct identifier identities (`type` + `namespace` + `value`). + * + * @param identifiers - Identifier list. + * @returns Distinct identity count. + */ +export function countDistinctIdentifiers(identifiers: Identifier[]): number { + return new Set( + identifiers.map( + ({ type, namespace, value }) => `${type}\0${namespace}\0${value}`, + ), + ).size; +} + /** * Trusted identifier-type registry. Escrows and the controller derive the * authentication mode from this table, never from a client-selected flag. diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts index 9df7dd7ca48..203cecc7387 100644 --- a/packages/mfa-recovery-controller/src/pending-operation-validation.ts +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -1,7 +1,11 @@ import { hash } from './crypto.js'; import { MfaRecoveryError } from './errors.js'; import { isMutationReceipt } from './escrow-utils.js'; -import { MIN_IDENTIFIERS, getIdentifierAuthMode } from './identifier-auth.js'; +import { + countDistinctIdentifiers, + getIdentifierAuthMode, + MIN_IDENTIFIERS, +} from './identifier-auth.js'; import { assertSameAudienceIds } from './state-machine.js'; import type { AuthControllerToken, @@ -84,7 +88,7 @@ export async function assertValidPendingOperation( if (mutation.operation === 'register' && mutation.expectedVersion !== 0) { throwInvalidPendingOperation(); } - if ((hash(payload)) !== mutation.payloadHash) { + if (hash(payload) !== mutation.payloadHash) { throw new MfaRecoveryError( 'Pending payload does not match mutation', 'payload_mismatch', @@ -170,8 +174,8 @@ function isPendingPayload( } if ( !Array.isArray(payload.identifiers) || - payload.identifiers.length < MIN_IDENTIFIERS || - !payload.identifiers.every(isIdentifier) + !payload.identifiers.every(isIdentifier) || + countDistinctIdentifiers(payload.identifiers) < MIN_IDENTIFIERS ) { return false; } From b3061cbd2d6852d2f07e484baae0b71eb5538557 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:47:30 +0800 Subject: [PATCH 15/55] fix(mfa-recovery): prevent aborting writing mutations --- packages/mfa-recovery-controller/ARCHITECTURE.md | 10 +++------- packages/mfa-recovery-controller/CHANGELOG.md | 4 ++-- packages/mfa-recovery-controller/README.md | 4 ++-- .../MfaRecoveryController-method-action-types.ts | 3 +-- .../src/MfaRecoveryController.test.ts | 16 ++++++---------- .../src/MfaRecoveryController.ts | 3 +-- .../mfa-recovery-controller/src/state-machine.ts | 12 ++++-------- 7 files changed, 19 insertions(+), 33 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 76c2c327bb2..1b4ffab1eff 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -65,21 +65,18 @@ stateDiagram-v2 authorizing --> writing: identifier auth succeeds, before first apply writing --> writing: more receipts, still missing replicas writing --> idle: every configured escrow has a valid receipt - writing --> idle: abort() with no receipts writing --> writing: resume() retries only missing receipts authorizing --> writing: resume() authorizes then writes ``` Rules: -- **`abort()`** is allowed in `authorizing`, and in `writing` when there are - no receipts. Receipts must be finished with `resume()`. +- **`abort()`** drops `authorizing` only. `writing` must `resume()`. - `writing` is persisted before the **first escrow apply** after identifier authorization succeeds, so an ambiguous apply failure remains resumable while pre-write authorization failures stay abortable. - `register` / `updateRecoverySecret` / `updateIdentifiers` require idle - pending state. `resume()` finishes a persisted mutation; `abort()` drops - `authorizing`, or `writing` with no receipts. + pending state. `resume()` finishes a persisted mutation. ## Mutation sequence (`register` / updates) @@ -187,8 +184,7 @@ flowchart TD AbortOrResume -->|abort| Idle AbortOrResume -->|resume| Auth[re-issue AuthController token] Auth --> Write[replicate] - Phase -->|writing, no receipts| AbortOrResume - Phase -->|writing, has receipts| ResumeOnly[resume only] + Phase -->|writing| ResumeOnly[resume only] ResumeOnly --> Skip[skip escrows that already have receipts] Skip --> Write Write --> All{receipts for every configured escrow?} diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 260917702a7..74e1fb1d2a0 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -15,12 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Persist only an encrypted `authorizing` / `writing` pending mutation (`idle` when `pendingOperation` is `null`) - `register` and `updateIdentifiers` require at least two distinct identifiers (`MIN_IDENTIFIERS`) - Identifier types `passkey`, `oidc`, and `siwe` are key-bound; `emailOtp` / `smsOtp` are not wired yet - - `getRecoverySecret` returns `{ recoverySecret, epoch }`; pass that `epoch` into later `updateRecoverySecret` / `updateIdentifiers` calls (register uses `0`). Refuses while a mutation is `writing` (`resume()` / `abort()` first) + - `getRecoverySecret` returns `{ recoverySecret, epoch }`; pass that `epoch` into later `updateRecoverySecret` / `updateIdentifiers` calls (register uses `0`). Refuses while a mutation is `writing` (`resume()` first) - Mutation `payloadHash` is the hash of the logical pending payload (`identifiers` and/or `0x`-hex secret). At apply, each escrow receives `{ pkE, ciphertext }` wrapped to its own wrap key with escrow-wrap-v1 - `getSecret` request hashes bind the client's ephemeral `pkE`; responses are `{ ciphertext }` plus `wrapKeyId` - `Mutation.audiences` is the configured replica-id list, in that order. Each replica should require an exact match - `MutationReceipt` includes `receiptKeyId`. `RecoveryEscrowProvider.verifyReceipt` takes the expected escrow id and verifies with that replica's receipt key - `AuthControllerToken.expiresAt` and `PoPChallenge.expiresAt` are Unix seconds - - Mutations require idle pending state (`resume()` / `abort()` first). `abort()` is allowed while `authorizing` or `writing` with no receipts; `writing` with receipts must be finished with `resume()` + - Mutations require idle pending state (`resume()` / `abort()` first). `abort()` drops `authorizing` only; `writing` must be finished with `resume()` [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md index cb1d525f44a..bbd5ca03500 100644 --- a/packages/mfa-recovery-controller/README.md +++ b/packages/mfa-recovery-controller/README.md @@ -5,8 +5,8 @@ injected so the same controller can run against different identity providers and escrow backends. Pending mutations are persisted as encoded encrypted `authorizing` / `writing` -state so a crash can `resume()` the same mutation. `abort()` is allowed before any -escrow receipt is stored. The encoded ciphertext is stored as a string so +state so a crash can `resume()` the same mutation. `abort()` drops `authorizing` +only; `writing` must `resume()`. The encoded ciphertext is stored as a string so BaseController state serialization preserves the encrypted value. ## Installation 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 1fb359642db..d42c5d6d5fd 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts @@ -64,8 +64,7 @@ export type MfaRecoveryControllerResumeAction = { }; /** - * Drops a pending mutation that no escrow has acknowledged. Once a receipt - * exists, call {@link resume} instead. + * Drops `authorizing` pending. `writing` must be finished with {@link resume}. */ export type MfaRecoveryControllerAbortAction = { type: `MfaRecoveryController:abort`; diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 26d163109b9..38c479e7430 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -226,12 +226,6 @@ describe('MfaRecoveryController', () => { controller.register(SECRET, IDENTIFIERS), ).rejects.toBeInstanceOf(IncompleteMutationError); expect(await controller.getPhase()).toBe('writing'); - await controller.abort(); - await controller.updateRecoverySecret( - PASSKEY, - SECRET_2, - REGISTERED_EPOCH, - ); }); }); @@ -516,15 +510,17 @@ describe('MfaRecoveryController', () => { }); }); - it('can abort a writing mutation that has no receipts', async () => { + it('cannot abort a writing mutation', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, IDENTIFIERS); await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, 0), ).rejects.toBeInstanceOf(IncompleteMutationError); expect(await controller.getPhase()).toBe('writing'); - await controller.abort(); - expect(await controller.getPhase()).toBe('idle'); + await expect(controller.abort()).rejects.toThrow( + 'Cannot abort a mutation that may have been applied', + ); + expect(await controller.getPhase()).toBe('writing'); }); }); @@ -698,7 +694,7 @@ describe('MfaRecoveryController', () => { state: { pendingOperation: writing }, }); await expect(writingController.abort()).rejects.toThrow( - 'Cannot abort a mutation once an escrow has acknowledged it', + 'Cannot abort a mutation that may have been applied', ); expect(await writingController.getPhase()).toBe('writing'); }); diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index d3c2974de7e..24b2aeb98fa 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -395,8 +395,7 @@ export class MfaRecoveryController extends BaseController< } /** - * Drops a pending mutation that no escrow has acknowledged. Once a receipt - * exists, call {@link resume} instead. + * Drops `authorizing` pending. `writing` must be finished with {@link resume}. */ async abort(): Promise { await this.#withLock(async () => { diff --git a/packages/mfa-recovery-controller/src/state-machine.ts b/packages/mfa-recovery-controller/src/state-machine.ts index 15e9b135d88..528cb062645 100644 --- a/packages/mfa-recovery-controller/src/state-machine.ts +++ b/packages/mfa-recovery-controller/src/state-machine.ts @@ -14,19 +14,15 @@ export function getRecoveryPhase( } /** - * Abort is allowed while idle, authorizing, or writing with no receipts. - * Receipts mean an escrow already applied; finish with resume() instead. - * - * TODO: empty receipts is not proof the apply never landed (timeout after a - * successful write). Record explicit rejects vs ambiguous failures. + * Abort is allowed while idle or authorizing. `writing` must `resume()`. * * @param pending - Loaded pending operation, if any. - * @throws If an escrow has already acknowledged the mutation. + * @throws If a mutation is already writing. */ export function assertAbortAllowed(pending: PendingOperation | null): void { - if (pending?.phase === 'writing' && pending.receipts.length > 0) { + if (pending?.phase === 'writing') { throw new MfaRecoveryError( - 'Cannot abort a mutation once an escrow has acknowledged it', + 'Cannot abort a mutation that may have been applied', 'abort_not_allowed', ); } From 1ba2045a57ac384460d02af9a8639c66546ec3f8 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:39:29 +0800 Subject: [PATCH 16/55] fix(mfa-recovery-controller): update getRecoverySecret behavior to return highest version without being blocked by a pending write --- packages/mfa-recovery-controller/ARCHITECTURE.md | 5 +++-- packages/mfa-recovery-controller/CHANGELOG.md | 2 +- .../src/MfaRecoveryController-method-action-types.ts | 2 +- .../src/MfaRecoveryController.test.ts | 10 ++++------ .../src/MfaRecoveryController.ts | 8 +------- 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 1b4ffab1eff..f16737499ae 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -157,8 +157,9 @@ sequenceDiagram end ``` -`getRecoverySecret` refuses while phase is `writing`. Replicas can disagree -in that window; finish with `resume()` first. +`getRecoverySecret` does not block on a pending mutation. During `writing`, +replicas may hold different versions; the read takes the highest one. The +next mutation still requires `resume()` first. ## Identifier auth (inside writes and reads) diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 74e1fb1d2a0..dc30fe54953 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Persist only an encrypted `authorizing` / `writing` pending mutation (`idle` when `pendingOperation` is `null`) - `register` and `updateIdentifiers` require at least two distinct identifiers (`MIN_IDENTIFIERS`) - Identifier types `passkey`, `oidc`, and `siwe` are key-bound; `emailOtp` / `smsOtp` are not wired yet - - `getRecoverySecret` returns `{ recoverySecret, epoch }`; pass that `epoch` into later `updateRecoverySecret` / `updateIdentifiers` calls (register uses `0`). Refuses while a mutation is `writing` (`resume()` first) + - `getRecoverySecret` returns `{ recoverySecret, epoch }`; pass that `epoch` into later `updateRecoverySecret` / `updateIdentifiers` calls (register uses `0`). Does not block on a pending mutation; returns the highest consistent version across available escrows - Mutation `payloadHash` is the hash of the logical pending payload (`identifiers` and/or `0x`-hex secret). At apply, each escrow receives `{ pkE, ciphertext }` wrapped to its own wrap key with escrow-wrap-v1 - `getSecret` request hashes bind the client's ephemeral `pkE`; responses are `{ ciphertext }` plus `wrapKeyId` - `Mutation.audiences` is the configured replica-id list, in that order. Each replica should require an exact match 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 d42c5d6d5fd..204688a79f1 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts @@ -45,7 +45,7 @@ export type MfaRecoveryControllerUpdateIdentifiersAction = { /** * Reads the recovery secret from available escrows and returns the highest * consistent version. `epoch` is the current recovery version and is required - * by later mutations. Refuses while a mutation is `writing`. + * by later mutations. Does not wait for a pending mutation to be repaired. * * @param identifier - Identifier used to authorize the read. * @returns Recovered secret bytes and the selected epoch. diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 38c479e7430..480762d5b32 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -253,7 +253,7 @@ describe('MfaRecoveryController', () => { }); describe('getRecoverySecret', () => { - it('refuses to read while a mutation is writing', async () => { + it('reads the highest version while a mutation is writing', async () => { await withController(async ({ controller, escrowA }) => { await controller.register(SECRET, IDENTIFIERS); escrowA.failNextApplyCount = 1; @@ -261,13 +261,11 @@ describe('MfaRecoveryController', () => { controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toBeInstanceOf(IncompleteMutationError); - await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( - 'A pending mutation must be resumed or aborted first', - ); - - await controller.resume(); const recovered = await controller.getRecoverySecret(PASSKEY); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); + expect(recovered.epoch).toBe(REGISTERED_EPOCH + 1); + expect(await controller.getPhase()).toBe('writing'); }); }); diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 24b2aeb98fa..ae57db4174a 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -318,19 +318,13 @@ export class MfaRecoveryController extends BaseController< /** * Reads the recovery secret from available escrows and returns the highest * consistent version. `epoch` is the current recovery version and is required - * by later mutations. Refuses while a mutation is `writing`. + * by later mutations. Does not wait for a pending mutation to be repaired. * * @param identifier - Identifier used to authorize the read. * @returns Recovered secret bytes and the selected epoch. */ async getRecoverySecret(identifier: Identifier): Promise { return await this.#withLock(async () => { - if ((await this.#loadPending())?.phase === 'writing') { - throw new MfaRecoveryError( - 'A pending mutation must be resumed or aborted first', - 'pending_mutation', - ); - } this.#assertKnownIdentifierTypes([identifier]); const requestId = randomId(); const ephemeral = generateSigningKey(); From c642e400ec44b4fc743c5025e11f2a441da1b31b Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:57:35 +0800 Subject: [PATCH 17/55] feat(mfa-recovery-controller): integrate Cubist escrow commands and add new scripts for invoking and registering with the controller - Added `CubistEscrowProvider` to handle Cubist escrow commands. - Introduced scripts for invoking escrow and registering with the controller. - Updated `package.json` to include new dependencies and scripts. - Enhanced TypeScript configuration to include scripts in the build process. - Updated changelog to reflect the new features and changes. --- eslint.config.mjs | 9 +- packages/mfa-recovery-controller/CHANGELOG.md | 1 + .../jest.config.ts5.cjs | 19 + packages/mfa-recovery-controller/package.json | 4 + .../scripts/invoke-escrow.ts | 23 ++ .../scripts/register-with-controller.ts | 155 +++++++ .../cubist-escrow-provider.test.ts | 378 ++++++++++++++++++ .../cubist-escrow-provider.ts | 283 +++++++++++++ packages/mfa-recovery-controller/src/index.ts | 2 + .../mfa-recovery-controller/tsconfig.json | 2 +- yarn.lock | 64 +++ 11 files changed, 936 insertions(+), 4 deletions(-) create mode 100644 packages/mfa-recovery-controller/jest.config.ts5.cjs create mode 100644 packages/mfa-recovery-controller/scripts/invoke-escrow.ts create mode 100644 packages/mfa-recovery-controller/scripts/register-with-controller.ts create mode 100644 packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.test.ts create mode 100644 packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 516295a5445..49b8ee01f62 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -288,9 +288,12 @@ const config = createConfig([ }, }, { - // The UKYC test-token minter is a dev-only Node CLI, so it may use Node - // builtins and globals unlike the platform-agnostic package source. - files: ['packages/kyc-controller/scripts/**/*.ts'], + // Dev-only Node CLIs may use Node builtins and globals unlike the + // platform-agnostic package source. + files: [ + 'packages/kyc-controller/scripts/**/*.ts', + 'packages/mfa-recovery-controller/scripts/**/*.ts', + ], rules: { 'import-x/no-nodejs-modules': 'off', 'no-restricted-globals': 'off', diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index dc30fe54953..c9f0b6f6823 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -22,5 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `MutationReceipt` includes `receiptKeyId`. `RecoveryEscrowProvider.verifyReceipt` takes the expected escrow id and verifies with that replica's receipt key - `AuthControllerToken.expiresAt` and `PoPChallenge.expiresAt` are Unix seconds - Mutations require idle pending state (`resume()` / `abort()` first). `abort()` drops `authorizing` only; `writing` must be finished with `resume()` + - `CubistEscrowProvider` invokes Cubist escrow commands through `@cubist-labs/cubesigner-sdk` (`generateChallenge`, `applyMutation`, `getSecret` on `cubist_secret_escrow`) and verifies receipts with a pinned receipt public key [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/mfa-recovery-controller/jest.config.ts5.cjs b/packages/mfa-recovery-controller/jest.config.ts5.cjs new file mode 100644 index 00000000000..e4b6c4ff7f3 --- /dev/null +++ b/packages/mfa-recovery-controller/jest.config.ts5.cjs @@ -0,0 +1,19 @@ +const merge = require('deepmerge'); + +const baseConfig = require('./jest.config.cjs'); + +const config = merge(baseConfig, {}); +config.transform = { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: { + module: 'CommonJS', + moduleResolution: 'Node', + verbatimModuleSyntax: false, + }, + }, + ], +}; + +module.exports = config; diff --git a/packages/mfa-recovery-controller/package.json b/packages/mfa-recovery-controller/package.json index b49279885dc..5badac5cb0e 100644 --- a/packages/mfa-recovery-controller/package.json +++ b/packages/mfa-recovery-controller/package.json @@ -39,6 +39,8 @@ "build:only-clean": "rimraf './dist' './tsconfig.build.tsbuildinfo'", "changelog:update": "../../scripts/update-changelog.sh @metamask/mfa-recovery-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/mfa-recovery-controller", + "cubesigner:invoke-escrow": "tsx scripts/invoke-escrow.ts", + "cubesigner:register": "tsx scripts/register-with-controller.ts", "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", @@ -50,6 +52,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@cubist-labs/cubesigner-sdk": "^0.4.284", "@metamask/base-controller": "^10.0.0", "@metamask/messenger": "^3.0.0", "@metamask/utils": "^12.0.0", @@ -58,6 +61,7 @@ "@noble/hashes": "^1.8.0" }, "devDependencies": { + "@cubist-labs/cubesigner-sdk-fs-storage": "^0.4.284", "@metamask/auto-changelog": "^6.1.0", "@types/jest": "^30.0.0", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/packages/mfa-recovery-controller/scripts/invoke-escrow.ts b/packages/mfa-recovery-controller/scripts/invoke-escrow.ts new file mode 100644 index 00000000000..12b0fb0d985 --- /dev/null +++ b/packages/mfa-recovery-controller/scripts/invoke-escrow.ts @@ -0,0 +1,23 @@ +/** + * Local CubeSigner probe for the `cubist_secret_escrow` function. + * + * Requires a CubeSigner user session on disk (`defaultUserSessionManager`). + * + * Usage: + * yarn workspace @metamask/mfa-recovery-controller run cubesigner:invoke-escrow + */ +import { CubeSignerClient } from '@cubist-labs/cubesigner-sdk'; +import { defaultUserSessionManager } from '@cubist-labs/cubesigner-sdk-fs-storage'; + +const client = await CubeSignerClient.create(defaultUserSessionManager()); +const fn = await client.org().getFunction('cubist_secret_escrow'); +// or: getFunction("NamedPolicy#e0a26d77-6b47-4507-abc5-ab9a3485e22f") + +const result = await fn.invoke( + undefined, + 'latest', // or "v0" + { cmd: 'generateChallenge' }, // or applyMutation or getSecret +); + +console.log(result.response); +console.log(new TextDecoder().decode(result.stdoutBytes)); diff --git a/packages/mfa-recovery-controller/scripts/register-with-controller.ts b/packages/mfa-recovery-controller/scripts/register-with-controller.ts new file mode 100644 index 00000000000..8ccd3809c7d --- /dev/null +++ b/packages/mfa-recovery-controller/scripts/register-with-controller.ts @@ -0,0 +1,155 @@ +import { CubeSignerClient } from '@cubist-labs/cubesigner-sdk'; +import type { Version } from '@cubist-labs/cubesigner-sdk'; +import { defaultUserSessionManager } from '@cubist-labs/cubesigner-sdk-fs-storage'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { bytesToHex } from '@metamask/utils'; +import { randomBytes } from 'node:crypto'; +import process from 'node:process'; + +import { CubistEscrowProvider } from '../src/escrow-providers/cubist-escrow-provider.js'; +import { MfaRecoveryController } from '../src/MfaRecoveryController.js'; +import type { MfaRecoveryControllerMessenger } from '../src/MfaRecoveryController.js'; +import type { Identifier } from '../src/types.js'; +import { + StubAuthProvider, + StubIdentifierAuthProvider, + passthroughEncryptor, +} from '../tests/stubs.js'; + +/** + * Runs the MFA recovery controller against the live Cubist escrow C2F. + * + * Usage: + * yarn workspace @metamask/mfa-recovery-controller run cubesigner:register -- \ + * --wrap-key '' \ + * --receipt-key '' \ + * [--function-id ] [--version latest|v0] + */ +const USAGE = + 'Usage: cubesigner:register --wrap-key --receipt-key [--function-id ] [--version latest|v0]'; + +const PASSKEY: Identifier = { + type: 'passkey', + namespace: 'example.com', + value: 'cred-1', + verifier: { publicKey: 'mock-passkey' }, +}; + +const OIDC: Identifier = { + type: 'oidc', + namespace: 'https://accounts.google.com', + value: 'sub-1', + verifier: { auds: ['mock-client'] }, +}; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function parseFlags(argv: string[]): Record { + const flags: Record = {}; + let index = 0; + + while (index < argv.length) { + const argument = argv[index]; + if (!argument.startsWith('--')) { + index += 1; + continue; + } + + const body = argument.slice(2); + const equalsIndex = body.indexOf('='); + if (equalsIndex !== -1) { + flags[body.slice(0, equalsIndex)] = body.slice(equalsIndex + 1); + index += 1; + continue; + } + + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith('--')) { + flags[body] = next; + index += 2; + } else { + flags[body] = 'true'; + index += 1; + } + } + + return flags; +} + +function parseVersion(value: string | undefined): Version { + if (value === undefined || value === 'latest') { + return 'latest'; + } + if (/^v\d+$/u.test(value)) { + return value as Version; + } + throw new Error(`Invalid CubeSigner version: ${value}`); +} + +function getControllerMessenger(): MfaRecoveryControllerMessenger { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + return new Messenger({ + namespace: 'MfaRecoveryController', + parent: rootMessenger, + }); +} + +async function main(flags: Record): Promise { + const wrapPublicKey = flags['wrap-key']; + const receiptPublicKey = flags['receipt-key']; + if (wrapPublicKey === undefined || receiptPublicKey === undefined) { + console.error(USAGE); + process.exitCode = 1; + return; + } + + const client = await CubeSignerClient.create(defaultUserSessionManager()); + const escrow = new CubistEscrowProvider({ + client, + wrapPublicKey, + receiptPublicKey, + ...(flags['function-id'] === undefined + ? {} + : { functionId: flags['function-id'] }), + version: parseVersion(flags.version), + }); + const controller = new MfaRecoveryController({ + messenger: getControllerMessenger(), + authProvider: new StubAuthProvider(), + identifierAuthProvider: new StubIdentifierAuthProvider(), + escrows: [escrow], + pendingOperationEncryptor: passthroughEncryptor, + }); + + const recoverySecret = new Uint8Array(randomBytes(32)); + await controller.register(recoverySecret, [PASSKEY, OIDC]); + + const recovered = await controller.getRecoverySecret(PASSKEY); + const registeredSecret = bytesToHex(recoverySecret); + const recoveredSecret = bytesToHex(recovered.recoverySecret); + + console.log( + JSON.stringify( + { + epoch: recovered.epoch, + recoverySecret: recoveredSecret, + matchesRegisteredSecret: recoveredSecret === registeredSecret, + }, + null, + 2, + ), + ); +} + +await main(parseFlags(process.argv.slice(2))); 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 new file mode 100644 index 00000000000..f9002800eae --- /dev/null +++ b/packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.test.ts @@ -0,0 +1,378 @@ +import type { CubeSignerClient } from '@cubist-labs/cubesigner-sdk'; + +import { + generateSigningKey, + hashMutationReceipt, + sign, + wrapKeyId, +} from '../crypto.js'; +import { MfaRecoveryError } from '../errors.js'; +import type { + AuthControllerToken, + EcPublicJwk, + IdentifierAuthorization, + Mutation, + MutationPayload, + MutationReceipt, +} from '../types.js'; +import { CubistEscrowProvider } from './cubist-escrow-provider.js'; + +const wrapKey = generateSigningKey(); +const receiptKey = generateSigningKey(); + +const MUTATION: Mutation = { + id: 'mut-1', + profileId: 'profile-1', + operation: 'register', + expectedVersion: 0, + newVersion: 1, + payloadHash: '0xabc', + audiences: ['cubist'], + requestHash: '0xdef', +}; + +const TOKEN: AuthControllerToken = { + profileId: 'profile-1', + requestHash: '0xdef', + issuer: 'auth', + expiresAt: 1_800_000_000, + signature: '00', +}; + +const PAYLOAD: MutationPayload = { + identifiers: [], + recoverySecret: { + pkE: { kty: 'EC', crv: 'P-256', x: 'eA', y: 'eB' }, + ciphertext: 'aa', + }, +}; + +const AUTHORIZATION: IdentifierAuthorization = { + kind: 'key-bound', + token: { + identifier: { + type: 'passkey', + namespace: 'example.com', + value: 'cred-1', + verifier: { publicKey: 'pk' }, + }, + proofPublicKey: '{}', + requestHash: '0x1', + providerAssertion: {}, + }, + proof: { + challengeId: 'challenge-1', + requestHash: '0x1', + signature: '11', + }, +}; + +const PK_E: EcPublicJwk = { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' }; + +const CHALLENGE = { + id: 'challenge-1', + escrowId: 'cubist', + expiresAt: 1_800_000_300, +}; + +describe('CubistEscrowProvider', () => { + it('uses the cubist escrow id and the pinned wrap key', () => { + const { provider } = createProvider(); + expect(provider.id).toBe('cubist'); + expect(provider.wrapPublicKey).toBe(wrapKey.publicKey); + }); + + describe('isAvailable', () => { + it('returns true when the C2F function can be loaded', async () => { + const { provider, getFunction } = createProvider(); + expect(await provider.isAvailable()).toBe(true); + expect(getFunction).toHaveBeenCalledWith('cubist_secret_escrow'); + }); + + it('loads a caller-specified function id', async () => { + const { provider, getFunction } = createProvider({ + functionId: 'NamedPolicy#escrow', + }); + expect(await provider.isAvailable()).toBe(true); + expect(getFunction).toHaveBeenCalledWith('NamedPolicy#escrow'); + }); + + it('returns false when getFunction throws', async () => { + const { provider } = createProvider({ + getFunction: async () => { + throw new Error('offline'); + }, + }); + expect(await provider.isAvailable()).toBe(false); + }); + }); + + describe('generateChallenge', () => { + it('invokes generateChallenge and returns the challenge from stdout', async () => { + const { provider, invoke } = createProvider({ + stdout: CHALLENGE, + }); + expect(await provider.generateChallenge()).toStrictEqual(CHALLENGE); + expect(invoke).toHaveBeenCalledWith(undefined, 'latest', { + cmd: 'generateChallenge', + }); + }); + + it('invokes a caller-specified function version', async () => { + const { provider, invoke } = createProvider({ version: 'v0' }); + await provider.generateChallenge(); + expect(invoke.mock.calls[0]?.[1]).toBe('v0'); + }); + + it.each([ + ['not an object', 1], + ['null', null], + ['missing id', { escrowId: 'cubist', expiresAt: 1 }], + ['wrong escrow', { ...CHALLENGE, escrowId: 'other' }], + ['non-numeric expiry', { ...CHALLENGE, expiresAt: '1' }], + ['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( + MfaRecoveryError, + ); + await expect(provider.generateChallenge()).rejects.toMatchObject({ + code: 'escrow_response_invalid', + }); + }); + }); + + describe('applyMutation', () => { + it('invokes applyMutation, including a null identifier authorization', async () => { + const receipt = signedReceipt(); + const { provider, invoke } = createProvider({ stdout: receipt }); + expect( + await provider.applyMutation(MUTATION, TOKEN, null, PAYLOAD), + ).toStrictEqual(receipt); + expect(invoke).toHaveBeenCalledWith(undefined, 'latest', { + cmd: 'applyMutation', + mutation: MUTATION, + authControllerToken: TOKEN, + identifierAuthorization: null, + payload: PAYLOAD, + }); + }); + + it('rejects a response that is not a receipt', async () => { + const { provider } = createProvider({ stdout: { nope: true } }); + await expect( + provider.applyMutation(MUTATION, TOKEN, AUTHORIZATION, PAYLOAD), + ).rejects.toMatchObject({ code: 'escrow_response_invalid' }); + }); + }); + + describe('getSecret', () => { + const secretResponse = { + recoverySecret: { ciphertext: 'cc' }, + version: 1, + lastMutationId: 'mut-1', + wrapKeyId: '0xabc', + }; + + it('invokes getSecret and returns the wrapped secret', async () => { + const { provider, invoke } = createProvider({ stdout: secretResponse }); + expect( + await provider.getSecret(AUTHORIZATION, 'req-1', PK_E), + ).toStrictEqual(secretResponse); + expect(invoke).toHaveBeenCalledWith(undefined, 'latest', { + cmd: 'getSecret', + identifierAuthorization: AUTHORIZATION, + requestId: 'req-1', + pkE: PK_E, + }); + }); + + it.each([ + ['not an object', 1], + ['null', null], + ['missing wrap object', { ...secretResponse, recoverySecret: 'x' }], + ['missing ciphertext', { ...secretResponse, recoverySecret: {} }], + ['non-numeric version', { ...secretResponse, version: '1' }], + ['fractional version', { ...secretResponse, version: 1.2 }], + ['negative version', { ...secretResponse, version: -1 }], + ['missing mutation id', { ...secretResponse, lastMutationId: 1 }], + ['missing wrap key id', { ...secretResponse, wrapKeyId: 1 }], + ])('rejects a secret response that is %s', async (_label, body) => { + const { provider } = createProvider({ stdout: body }); + await expect( + provider.getSecret(AUTHORIZATION, 'req-1', PK_E), + ).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({ + code: 'escrow_request_failed', + message: 'not allowed', + }); + }); + + it('throws when the policy engine errors', async () => { + const { provider } = createProvider({ + policy: { response: 'Error', error: 'wasm panic' }, + }); + await expect(provider.generateChallenge()).rejects.toMatchObject({ + code: 'escrow_request_failed', + message: 'wasm panic', + }); + }); + + it('throws when stdout is not JSON', async () => { + const { provider } = createProvider({ + stdoutBytes: new TextEncoder().encode('nope'), + }); + await expect(provider.generateChallenge()).rejects.toMatchObject({ + code: 'escrow_response_invalid', + }); + }); + + it('throws the invoke error message when the request fails', async () => { + const { provider } = createProvider({ + invoke: async () => { + throw new Error('offline'); + }, + }); + await expect(provider.generateChallenge()).rejects.toMatchObject({ + code: 'escrow_request_failed', + message: 'offline', + }); + }); + + it('throws a generic message when invoke rejects with a non-Error', async () => { + const { provider } = createProvider({ + invoke: async () => { + // Cover the non-Error rejection path in `#invoke`. + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 0; + }, + }); + await expect(provider.generateChallenge()).rejects.toMatchObject({ + code: 'escrow_request_failed', + message: 'Escrow request failed', + }); + }); + }); + + describe('verifyReceipt', () => { + const { provider } = createProvider(); + + it('accepts a receipt signed by the pinned receipt key', () => { + expect( + provider.verifyReceipt(signedReceipt(), MUTATION, 'cubist'), + ).toBe(true); + }); + + it.each([ + ['mutation id', { mutationId: 'other' }], + ['request hash', { requestHash: '0xother' }], + ['escrow id', { escrowId: 'other' }], + ['version', { version: 2 }], + ['receipt key id', { receiptKeyId: '0xother' }], + ] as const)('rejects a receipt with a different %s', (_label, patch) => { + expect( + provider.verifyReceipt( + { ...signedReceipt(), ...patch }, + MUTATION, + 'cubist', + ), + ).toBe(false); + }); + + it('rejects a receipt aimed at a different escrow id', () => { + expect( + provider.verifyReceipt( + { ...signedReceipt(), escrowId: 'other' }, + MUTATION, + 'other', + ), + ).toBe(false); + }); + + it('rejects a receipt with a bad signature', () => { + expect( + provider.verifyReceipt( + { ...signedReceipt(), signature: '00' }, + MUTATION, + 'cubist', + ), + ).toBe(false); + }); + }); +}); + +function createProvider( + options: { + functionId?: string; + version?: 'latest' | `v${number}`; + stdout?: unknown; + stdoutBytes?: Uint8Array; + policy?: + | { response: 'Allow' } + | { response: 'Deny'; reason: string } + | { response: 'Error'; error: string }; + invoke?: () => Promise; + getFunction?: () => Promise<{ invoke: jest.Mock }>; + } = {}, +): { + provider: CubistEscrowProvider; + invoke: jest.Mock; + getFunction: jest.Mock; +} { + const invoke = + options.invoke === undefined + ? jest.fn(async () => ({ + response: options.policy ?? { response: 'Allow' }, + stdoutBytes: + options.stdoutBytes ?? + new TextEncoder().encode( + JSON.stringify( + Object.hasOwn(options, 'stdout') ? options.stdout : CHALLENGE, + ), + ), + })) + : jest.fn(options.invoke); + const getFunction = + options.getFunction === undefined + ? jest.fn(async () => ({ invoke })) + : jest.fn(options.getFunction); + const client = { + org: () => ({ getFunction }), + } as unknown as CubeSignerClient; + return { + provider: new CubistEscrowProvider({ + client, + wrapPublicKey: wrapKey.publicKey, + receiptPublicKey: receiptKey.publicKey, + functionId: options.functionId, + version: options.version, + }), + invoke, + getFunction, + }; +} + +function signedReceipt( + patch: Partial = {}, +): MutationReceipt { + const unsigned = { + mutationId: MUTATION.id, + requestHash: MUTATION.requestHash, + escrowId: 'cubist', + version: MUTATION.newVersion, + receiptKeyId: wrapKeyId(receiptKey.publicKey), + ...patch, + }; + return { + ...unsigned, + signature: sign(receiptKey.privateKey, hashMutationReceipt(unsigned)), + }; +} 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 new file mode 100644 index 00000000000..c2aca895d8f --- /dev/null +++ b/packages/mfa-recovery-controller/src/escrow-providers/cubist-escrow-provider.ts @@ -0,0 +1,283 @@ +import type { + C2FResponse, + CubeSignerClient, + JsonValue, + Version, +} from '@cubist-labs/cubesigner-sdk'; + +import { + hashMutationReceipt, + verifySignature, + wrapKeyId, +} from '../crypto.js'; +import { MfaRecoveryError } from '../errors.js'; +import { isMutationReceipt } from '../escrow-utils.js'; +import type { + AuthControllerToken, + EcPublicJwk, + GetRecoverySecretResponse, + IdentifierAuthorization, + Mutation, + MutationPayload, + MutationReceipt, + PoPChallenge, + RecoveryEscrowProvider, +} from '../types.js'; + +const ESCROW_ID = 'cubist'; +const DEFAULT_FUNCTION_ID = 'cubist_secret_escrow'; +const DEFAULT_VERSION: Version = 'latest'; + +/** + * CubeSigner C2F client for the Cubist recovery escrow. + * + * Commands are invoked as `{ cmd, ...fields }` on `cubist_secret_escrow`. + * Escrow JSON is read from C2F stdout. Receipt signatures are checked locally + * with the pinned receipt public key. + */ +export type CubistEscrowProviderOptions = { + /** + * Authenticated CubeSigner client used to load and invoke the escrow C2F. + */ + client: CubeSignerClient; + /** + * C2F name or named-policy id. Defaults to `cubist_secret_escrow`. + */ + functionId?: string; + /** + * C2F version to invoke. Defaults to `latest`. + */ + version?: Version; + /** + * P-256 public JWK JSON for the escrow wrap key. + */ + wrapPublicKey: string; + /** + * P-256 public JWK JSON for the escrow receipt key. + */ + receiptPublicKey: string; +}; + +export class CubistEscrowProvider implements RecoveryEscrowProvider { + readonly id = ESCROW_ID; + + readonly wrapPublicKey: string; + + readonly #receiptPublicKey: string; + + readonly #client: CubeSignerClient; + + readonly #functionId: string; + + readonly #version: Version; + + /** + * @param options - CubeSigner client, pinned keys, and optional C2F id/version. + */ + constructor(options: CubistEscrowProviderOptions) { + this.wrapPublicKey = options.wrapPublicKey; + this.#receiptPublicKey = options.receiptPublicKey; + this.#client = options.client; + this.#functionId = options.functionId ?? DEFAULT_FUNCTION_ID; + this.#version = options.version ?? DEFAULT_VERSION; + } + + /** + * @returns Whether the escrow C2F can be loaded. + */ + async isAvailable(): Promise { + try { + await this.#client.org().getFunction(this.#functionId); + return true; + } catch { + return false; + } + } + + /** + * @returns A proof-of-possession challenge for this escrow. + */ + async generateChallenge(): Promise { + return await this.#invoke('generateChallenge', {}, parseChallenge); + } + + /** + * @param authorization - Identifier proof for this read. + * @param requestId - Client request id bound into the read hash. + * @param pkE - Ephemeral wrap public key for the response. + * @returns The wrapped recovery secret and its version. + */ + async getSecret( + authorization: IdentifierAuthorization, + requestId: string, + pkE: EcPublicJwk, + ): Promise { + return await this.#invoke( + 'getSecret', + { + identifierAuthorization: authorization, + requestId, + pkE, + }, + parseGetSecretResponse, + ); + } + + /** + * @param mutation - Mutation to apply. + * @param authControllerToken - Request-bound AuthController token. + * @param identifierAuthorization - Identifier proof. `null` for register. + * @param payload - Per-escrow mutation payload. + * @returns The escrow's mutation receipt. + */ + async applyMutation( + mutation: Mutation, + authControllerToken: AuthControllerToken, + identifierAuthorization: IdentifierAuthorization | null, + payload: MutationPayload, + ): Promise { + return await this.#invoke( + 'applyMutation', + { + mutation, + authControllerToken, + identifierAuthorization, + payload, + }, + parseReceipt, + ); + } + + /** + * Verifies a receipt with the pinned receipt public key. + * + * @param receipt - Receipt returned by the escrow. + * @param mutation - Mutation the receipt must acknowledge. + * @param expectedEscrowId - Escrow identity expected by the caller. + * @returns Whether the receipt is valid for this escrow. + */ + verifyReceipt( + receipt: MutationReceipt, + mutation: Mutation, + expectedEscrowId: string, + ): boolean { + if ( + receipt.mutationId !== mutation.id || + receipt.requestHash !== mutation.requestHash || + receipt.escrowId !== expectedEscrowId || + expectedEscrowId !== this.id || + receipt.version !== mutation.newVersion || + receipt.receiptKeyId !== wrapKeyId(this.#receiptPublicKey) + ) { + return false; + } + return verifySignature( + this.#receiptPublicKey, + receipt.signature, + hashMutationReceipt({ + escrowId: receipt.escrowId, + mutationId: receipt.mutationId, + receiptKeyId: receipt.receiptKeyId, + requestHash: receipt.requestHash, + version: receipt.version, + }), + ); + } + + async #invoke( + cmd: string, + body: Record, + parse: (value: unknown) => Result, + ): Promise { + let policy: C2FResponse; + let stdoutBytes: Uint8Array; + try { + const fn = await this.#client.org().getFunction(this.#functionId); + const result = await fn.invoke(undefined, this.#version, { + cmd, + ...body, + } as JsonValue); + policy = result.response; + stdoutBytes = result.stdoutBytes; + } catch (error) { + throw new MfaRecoveryError( + error instanceof Error ? error.message : 'Escrow request failed', + 'escrow_request_failed', + ); + } + if (policy.response !== 'Allow') { + throw new MfaRecoveryError( + policy.response === 'Deny' ? policy.reason : policy.error, + 'escrow_request_failed', + ); + } + let json: unknown; + try { + json = JSON.parse(new TextDecoder().decode(stdoutBytes)); + } catch { + throw new MfaRecoveryError( + 'Escrow response was not JSON', + 'escrow_response_invalid', + ); + } + return parse(json); + } +} + +function parseChallenge(value: unknown): PoPChallenge { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + value.escrowId !== ESCROW_ID || + typeof value.expiresAt !== 'number' || + !Number.isInteger(value.expiresAt) + ) { + throw new MfaRecoveryError( + 'Invalid PoP challenge', + 'escrow_response_invalid', + ); + } + return { + id: value.id, + escrowId: value.escrowId, + expiresAt: value.expiresAt, + }; +} + +function parseGetSecretResponse(value: unknown): GetRecoverySecretResponse { + if ( + !isRecord(value) || + !isRecord(value.recoverySecret) || + typeof value.recoverySecret.ciphertext !== 'string' || + typeof value.version !== 'number' || + !Number.isInteger(value.version) || + value.version < 0 || + typeof value.lastMutationId !== 'string' || + typeof value.wrapKeyId !== 'string' + ) { + throw new MfaRecoveryError( + 'Invalid recovery secret response', + 'escrow_response_invalid', + ); + } + return { + recoverySecret: { ciphertext: value.recoverySecret.ciphertext }, + version: value.version, + lastMutationId: value.lastMutationId, + wrapKeyId: value.wrapKeyId, + }; +} + +function parseReceipt(value: unknown): MutationReceipt { + if (!isMutationReceipt(value)) { + throw new MfaRecoveryError( + 'Invalid mutation receipt', + 'escrow_response_invalid', + ); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts index 36fb4526842..1733842c36e 100644 --- a/packages/mfa-recovery-controller/src/index.ts +++ b/packages/mfa-recovery-controller/src/index.ts @@ -20,6 +20,8 @@ export type { MfaRecoveryControllerUpdateIdentifiersAction, MfaRecoveryControllerUpdateRecoverySecretAction, } from './MfaRecoveryController-method-action-types.js'; +export { CubistEscrowProvider } from './escrow-providers/cubist-escrow-provider.js'; +export type { CubistEscrowProviderOptions } from './escrow-providers/cubist-escrow-provider.js'; export { IncompleteMutationError, MfaRecoveryError } from './errors.js'; export { IDENTIFIER_AUTH_MODES, diff --git a/packages/mfa-recovery-controller/tsconfig.json b/packages/mfa-recovery-controller/tsconfig.json index b58304edead..719d902d0a4 100644 --- a/packages/mfa-recovery-controller/tsconfig.json +++ b/packages/mfa-recovery-controller/tsconfig.json @@ -11,5 +11,5 @@ "path": "../utils" } ], - "include": ["../../types", "./src", "./tests"] + "include": ["../../types", "./src", "./scripts", "./tests"] } diff --git a/yarn.lock b/yarn.lock index 30cd59ffad4..b06a1cc6baa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2538,6 +2538,29 @@ __metadata: languageName: node linkType: hard +"@cubist-labs/cubesigner-sdk-fs-storage@npm:^0.4.284": + version: 0.4.284 + resolution: "@cubist-labs/cubesigner-sdk-fs-storage@npm:0.4.284" + peerDependencies: + "@cubist-labs/cubesigner-sdk": ^0.4.284 + checksum: 10/4eb342a8d27ed6a4a26f55ea7bd41f6a7930d3ea853b905c912d84bcee9d0d637f4d63cf53c73a367833992d93b18a54c65d997736027dbc554954b72216e19f + languageName: node + linkType: hard + +"@cubist-labs/cubesigner-sdk@npm:^0.4.284": + version: 0.4.284 + resolution: "@cubist-labs/cubesigner-sdk@npm:0.4.284" + dependencies: + "@hpke/core": "npm:^1.2.7" + openapi-fetch: "npm:0.8.2" + zod: "npm:^4.3.6" + dependenciesMeta: + "@hpke/core": + optional: true + checksum: 10/cb98a4b0062dedc7bb4754ca96b49c5f8057ad91a8f7719f33090ccb6df32005d22efcf62ee848aaa690bba41b0ddb2d7f5fb980c6f612a76747ce63f373fd64 + languageName: node + linkType: hard + "@discoveryjs/json-ext@npm:0.5.7": version: 0.5.7 resolution: "@discoveryjs/json-ext@npm:0.5.7" @@ -4613,6 +4636,22 @@ __metadata: languageName: node linkType: hard +"@hpke/common@npm:^1.10.0": + version: 1.10.1 + resolution: "@hpke/common@npm:1.10.1" + checksum: 10/5279be394dbab880bbb5635de493e2bcd688bb16789026257a8a101c309432af03b202f9748c568467be9e0d5f2342cd7f8c80511374bab4ef098fd5dfd20922 + languageName: node + linkType: hard + +"@hpke/core@npm:^1.2.7": + version: 1.9.0 + resolution: "@hpke/core@npm:1.9.0" + dependencies: + "@hpke/common": "npm:^1.10.0" + checksum: 10/e859c8a41ee2b92968415184dd42be305fdbba315b2b60c930e3c1f764bee6fbb819a1ef5a3e6541173233afe44beb59806f0c3ccccb89aa6dc0c2e2cd84ac02 + languageName: node + linkType: hard + "@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" @@ -7827,6 +7866,8 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/mfa-recovery-controller@workspace:packages/mfa-recovery-controller" dependencies: + "@cubist-labs/cubesigner-sdk": "npm:^0.4.284" + "@cubist-labs/cubesigner-sdk-fs-storage": "npm:^0.4.284" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^10.0.0" "@metamask/messenger": "npm:^3.0.0" @@ -22947,6 +22988,22 @@ __metadata: languageName: node linkType: hard +"openapi-fetch@npm:0.8.2": + version: 0.8.2 + resolution: "openapi-fetch@npm:0.8.2" + dependencies: + openapi-typescript-helpers: "npm:^0.0.5" + checksum: 10/28da01ec9c748e1fa085e9d79d991c852c1291f33e103d8b1f1b17e5c2b51c41d94b56a7ed676e4485e83cd557df8e187374092bdf0b310d4504404aa49e5bc5 + languageName: node + linkType: hard + +"openapi-typescript-helpers@npm:^0.0.5": + version: 0.0.5 + resolution: "openapi-typescript-helpers@npm:0.0.5" + checksum: 10/1c1024e552af9f3bdf3ab7a76bd7208f783f9af53b9cfebc86984e700e85df071a73cf5f560cb295a547a0dc88ccd87a689a7fd286781e3b04716af8aa583898 + languageName: node + linkType: hard + "opener@npm:^1.5.2": version: 1.5.2 resolution: "opener@npm:1.5.2" @@ -29064,6 +29121,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^4.3.6": + version: 4.6.5 + resolution: "zod@npm:4.6.5" + checksum: 10/db004952e4dc6eab38b70f0cdef9251a65da84f5d08c5a0ef85e2590cfa9c477544ce734161ff5c05f89a15f0967137607a7feb16716195e04965f030db47213 + languageName: node + linkType: hard + "zwitch@npm:^2.0.0": version: 2.0.4 resolution: "zwitch@npm:2.0.4" From ac91259bfa7ca55d10f8f70af24f9a547b914ef5 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:26:38 +0800 Subject: [PATCH 18/55] feat(mfa-recovery-controller): implement identifier authentication and session management - Added `authenticateIdentifier` method to handle identifier authentication and return an ephemeral session. - Introduced `IdentifierSession` type to encapsulate session data required for recovery secret retrieval. - Updated `getRecoverySecret` to accept an `IdentifierSession` instead of an identifier, ensuring session validation. - Enhanced tests to cover identifier authentication scenarios, including tampering checks and session reuse. - Refactored authorization logic to utilize key-bound tokens for improved security and session management. --- ...aRecoveryController-method-action-types.ts | 15 +- .../src/MfaRecoveryController.test.ts | 137 +++++++++++++++--- .../src/MfaRecoveryController.ts | 64 ++++++-- .../src/identifier-auth.ts | 78 ++++++++-- packages/mfa-recovery-controller/src/index.ts | 2 + packages/mfa-recovery-controller/src/types.ts | 14 ++ 6 files changed, 270 insertions(+), 40 deletions(-) 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 204688a79f1..59e046054a5 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts @@ -42,12 +42,24 @@ export type MfaRecoveryControllerUpdateIdentifiersAction = { handler: MfaRecoveryController['updateIdentifiers']; }; +/** + * Authenticates an identifier for a subsequent recovery-secret read. + * + * @param identifier - Identifier to authenticate. + * @returns Ephemeral session data required by {@link getRecoverySecret}. + */ +export type MfaRecoveryControllerAuthenticateIdentifierAction = { + type: `MfaRecoveryController:authenticateIdentifier`; + handler: MfaRecoveryController['authenticateIdentifier']; +}; + /** * Reads the recovery secret from available escrows and returns the highest * consistent version. `epoch` is the current recovery version and is required * by later mutations. Does not wait for a pending mutation to be repaired. * - * @param identifier - Identifier used to authorize the read. + * @param session - Identifier authentication session used to authorize the + * read. * @returns Recovered secret bytes and the selected epoch. */ export type MfaRecoveryControllerGetRecoverySecretAction = { @@ -86,6 +98,7 @@ export type MfaRecoveryControllerMethodActions = | MfaRecoveryControllerRegisterAction | MfaRecoveryControllerUpdateRecoverySecretAction | MfaRecoveryControllerUpdateIdentifiersAction + | MfaRecoveryControllerAuthenticateIdentifierAction | MfaRecoveryControllerGetRecoverySecretAction | MfaRecoveryControllerResumeAction | MfaRecoveryControllerAbortAction diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 480762d5b32..564166a91ef 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -27,6 +27,8 @@ import type { import { MfaRecoveryController } from './MfaRecoveryController.js'; import type { Identifier, + IdentifierSession, + RecoveredSecret, RecoveryEscrowProvider, RegisterPayload, WritingPendingOperation, @@ -175,7 +177,7 @@ describe('MfaRecoveryController', () => { ]); expect(wrappedA.ciphertext).not.toBe(wrappedB.ciphertext); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -252,6 +254,98 @@ describe('MfaRecoveryController', () => { }); }); + describe('authenticateIdentifier', () => { + it('rejects unknown identifier types', async () => { + await withController(async ({ controller }) => { + await expect( + controller.authenticateIdentifier({ + type: 'unknown', + namespace: 'example.com', + value: 'unknown', + verifier: null, + }), + ).rejects.toThrow('Unknown identifier type'); + }); + }); + + it('binds the returned token to the read request', async () => { + await withController(async ({ controller }) => { + const session = await controller.authenticateIdentifier(PASSKEY); + + expect(session.token.requestHash).toBe( + hash({ + operation: 'getRecoverySecret', + requestId: session.requestId, + pkE: session.pkE, + }), + ); + }); + }); + + it.each([ + { + name: 'request id', + update: (session: IdentifierSession): IdentifierSession => ({ + ...session, + requestId: `${session.requestId}-tampered`, + }), + }, + { + name: 'ephemeral public key', + update: ( + session: Awaited< + ReturnType + >, + ): IdentifierSession => ({ + ...session, + pkE: { ...session.pkE, x: `${session.pkE.x}-tampered` }, + }), + }, + ])('rejects a tampered $name', async ({ update }) => { + await withController(async ({ controller }) => { + const session = await controller.authenticateIdentifier(PASSKEY); + + await expect( + controller.getRecoverySecret(update(session)), + ).rejects.toMatchObject({ + code: 'invalid_identifier_session', + }); + }); + }); + + it('does not request another token when reading with a session', async () => { + await withController(async ({ controller, options }) => { + await controller.register(SECRET, IDENTIFIERS); + const getToken = jest.spyOn( + options.identifierAuthProvider, + 'getKeyBoundIdentifierToken', + ); + + const session = await controller.authenticateIdentifier(PASSKEY); + expect(getToken).toHaveBeenCalledTimes(1); + + await controller.getRecoverySecret(session); + expect(getToken).toHaveBeenCalledTimes(1); + }); + }); + + it('allows a session to be reused for another read', async () => { + await withController(async ({ controller }) => { + await controller.register(SECRET, IDENTIFIERS); + const session = await controller.authenticateIdentifier(PASSKEY); + + const first = await controller.getRecoverySecret(session); + expect(first).toStrictEqual( + expect.objectContaining({ epoch: REGISTERED_EPOCH }), + ); + const second = await controller.getRecoverySecret(session); + expect(second).toStrictEqual( + expect.objectContaining({ epoch: REGISTERED_EPOCH }), + ); + }); + }); + }); + describe('getRecoverySecret', () => { it('reads the highest version while a mutation is writing', async () => { await withController(async ({ controller, escrowA }) => { @@ -261,7 +355,7 @@ describe('MfaRecoveryController', () => { controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toBeInstanceOf(IncompleteMutationError); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); expect(recovered.epoch).toBe(REGISTERED_EPOCH + 1); @@ -272,7 +366,7 @@ describe('MfaRecoveryController', () => { it('returns the selected epoch so a later update can be formed', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, IDENTIFIERS); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); expect(recovered.epoch).toBe(REGISTERED_EPOCH); @@ -282,7 +376,7 @@ describe('MfaRecoveryController', () => { SECRET_2, recovered.epoch, ); - const afterUpdate = await controller.getRecoverySecret(PASSKEY); + const afterUpdate = await recover(controller, PASSKEY); expect(bytesToHex(afterUpdate.recoverySecret)).toBe( bytesToHex(SECRET_2), ); @@ -294,7 +388,7 @@ describe('MfaRecoveryController', () => { await withController(async ({ controller, escrowB }) => { await controller.register(SECRET, IDENTIFIERS); escrowB.available = false; - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -306,7 +400,7 @@ describe('MfaRecoveryController', () => { .spyOn(escrowA, 'isAvailable') .mockRejectedValue(new Error('health check failed')); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); @@ -319,7 +413,7 @@ describe('MfaRecoveryController', () => { .spyOn(escrowA, 'generateChallenge') .mockRejectedValue(new Error('challenge failed')); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); @@ -334,7 +428,7 @@ describe('MfaRecoveryController', () => { lastMutationId: '0x', wrapKeyId: '0xdead', }); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -345,7 +439,7 @@ describe('MfaRecoveryController', () => { escrowB.patchRecord('profile-1', { lastMutationId: '0xdifferent', }); - await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( + await expect(recover(controller, PASSKEY)).rejects.toThrow( 'Replica corruption', ); }); @@ -355,7 +449,7 @@ describe('MfaRecoveryController', () => { await withController(async ({ controller, escrowA, escrowB }) => { escrowA.available = false; escrowB.available = false; - await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( + await expect(recover(controller, PASSKEY)).rejects.toThrow( 'No escrow is available', ); }); @@ -366,7 +460,7 @@ describe('MfaRecoveryController', () => { await controller.register(SECRET, IDENTIFIERS); escrowA.failGetSecret = true; escrowB.failGetSecret = true; - await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( + await expect(recover(controller, PASSKEY)).rejects.toThrow( 'No escrow returned a recovery secret', ); }); @@ -405,7 +499,7 @@ describe('MfaRecoveryController', () => { REGISTERED_EPOCH, ); expect(generateChallenge).toHaveBeenCalledWith(); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); }); }); @@ -569,7 +663,7 @@ describe('MfaRecoveryController', () => { [OIDC, PASSKEY], REGISTERED_EPOCH, ); - const recovered = await controller.getRecoverySecret(OIDC); + const recovered = await recover(controller, OIDC); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -596,7 +690,7 @@ describe('MfaRecoveryController', () => { await controller.resume(); expect(await controller.getPhase()).toBe('idle'); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); }); }); @@ -616,7 +710,7 @@ describe('MfaRecoveryController', () => { expect(await controller.getPhase()).toBe('idle'); expect( bytesToHex( - (await controller.getRecoverySecret(PASSKEY)).recoverySecret, + (await recover(controller, PASSKEY)).recoverySecret, ), ).toBe(bytesToHex(SECRET_2)); }); @@ -733,7 +827,7 @@ describe('MfaRecoveryController', () => { expect(await controller.getPhase()).toBe('idle'); expect( bytesToHex( - (await controller.getRecoverySecret(PASSKEY)).recoverySecret, + (await recover(controller, PASSKEY)).recoverySecret, ), ).toBe(bytesToHex(SECRET)); }); @@ -775,7 +869,7 @@ describe('MfaRecoveryController', () => { expect(await resumed.getPhase()).toBe('idle'); expect( - bytesToHex((await resumed.getRecoverySecret(PASSKEY)).recoverySecret), + bytesToHex((await recover(resumed, PASSKEY)).recoverySecret), ).toBe(bytesToHex(SECRET)); }); }); @@ -1439,6 +1533,15 @@ function getOptions( }; } +async function recover( + controller: MfaRecoveryController, + identifier: Identifier, +): Promise { + return await controller.getRecoverySecret( + await controller.authenticateIdentifier(identifier), + ); +} + async function getValidWritingPending( escrows: RecoveryEscrowProvider[], ): Promise { diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index ae57db4174a..9e8455ee2ea 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -26,9 +26,11 @@ import { } from './escrow-utils.js'; import { authorizeKeyBoundIdentifier, + authorizeWithKeyBoundToken, countDistinctIdentifiers, getIdentifierAuthMode, MIN_IDENTIFIERS, + requestKeyBoundIdentifierToken, } from './identifier-auth.js'; import type { AuthorizedEscrow } from './identifier-auth.js'; import type { MfaRecoveryControllerMethodActions } from './MfaRecoveryController-method-action-types.js'; @@ -42,6 +44,7 @@ import type { AuthControllerToken, EcPublicJwk, Identifier, + IdentifierSession, Mutation, MutationPayload, MutationReceipt, @@ -66,6 +69,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'register', 'updateRecoverySecret', 'updateIdentifiers', + 'authenticateIdentifier', 'getRecoverySecret', 'resume', 'abort', @@ -315,25 +319,65 @@ export class MfaRecoveryController extends BaseController< }); } + /** + * Authenticates an identifier for a subsequent recovery-secret read. + * + * @param identifier - Identifier to authenticate. + * @returns Ephemeral session data required by {@link getRecoverySecret}. + */ + async authenticateIdentifier( + identifier: Identifier, + ): Promise { + this.#assertKnownIdentifierTypes([identifier]); + const requestId = randomId(); + const ephemeral = generateSigningKey(); + const pkE = JSON.parse(ephemeral.publicKey) as EcPublicJwk; + const requestHash = hash({ + operation: 'getRecoverySecret', + requestId, + pkE, + }); + const { token, proofPrivateKey } = + await requestKeyBoundIdentifierToken({ + identifier, + requestHash, + identifierAuthProvider: this.#identifierAuthProvider, + }); + return { + token, + proofPrivateKey, + requestId, + ephemeralPrivateKey: ephemeral.privateKey, + pkE, + }; + } + /** * Reads the recovery secret from available escrows and returns the highest * consistent version. `epoch` is the current recovery version and is required * by later mutations. Does not wait for a pending mutation to be repaired. * - * @param identifier - Identifier used to authorize the read. + * @param session - Identifier authentication session used to authorize the + * read. * @returns Recovered secret bytes and the selected epoch. */ - async getRecoverySecret(identifier: Identifier): Promise { + async getRecoverySecret( + session: IdentifierSession, + ): Promise { return await this.#withLock(async () => { - this.#assertKnownIdentifierTypes([identifier]); - const requestId = randomId(); - const ephemeral = generateSigningKey(); - const pkE = JSON.parse(ephemeral.publicKey) as EcPublicJwk; + const { token, requestId, pkE } = session; + this.#assertKnownIdentifierTypes([token.identifier]); const requestHash = hash({ operation: 'getRecoverySecret', requestId, pkE, }); + if (token.requestHash !== requestHash) { + throw new MfaRecoveryError( + 'Invalid identifier session', + 'invalid_identifier_session', + ); + } const available = await this.#getAvailableEscrows(); if (available.length === 0) { throw new MfaRecoveryError( @@ -341,10 +385,10 @@ export class MfaRecoveryController extends BaseController< 'no_available_escrow', ); } - const authorizedEscrows = await this.#authorizeIdentifier({ + const authorizedEscrows = await authorizeWithKeyBoundToken({ escrows: available, - identifier, - requestHash, + token, + proofPrivateKey: session.proofPrivateKey, }); const results = await Promise.allSettled( authorizedEscrows.map(async ({ escrow, authorization }) => { @@ -362,7 +406,7 @@ export class MfaRecoveryController extends BaseController< return { escrowId: escrow.id, recoverySecret: decryptFromPublic( - ephemeral.privateKey, + session.ephemeralPrivateKey, escrow.wrapPublicKey, response.recoverySecret.ciphertext, ), diff --git a/packages/mfa-recovery-controller/src/identifier-auth.ts b/packages/mfa-recovery-controller/src/identifier-auth.ts index e89115b7c44..c7566688de0 100644 --- a/packages/mfa-recovery-controller/src/identifier-auth.ts +++ b/packages/mfa-recovery-controller/src/identifier-auth.ts @@ -5,6 +5,7 @@ import type { Identifier, IdentifierAuthMode, IdentifierAuthorization, + KeyBoundIdentifierToken, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, } from './types.js'; @@ -47,33 +48,52 @@ export type AuthorizedEscrow = { }; /** - * Authorizes an identifier with each escrow that successfully provides a - * challenge. + * Requests a key-bound identifier token and returns the private key used to + * prove possession of it. * * @param params - Identifier authorization parameters. - * @param params.escrows - Escrows to authorize. - * @param params.identifier - Identifier to authorize. + * @param params.identifier - Identifier to authenticate. * @param params.requestHash - Request hash bound to the authorization. * @param params.identifierAuthProvider - Provider for key-bound assertions. - * @returns Successful escrow authorizations. + * @returns Key-bound identifier token and its proof private key. */ -export async function authorizeKeyBoundIdentifier({ - escrows, +export async function requestKeyBoundIdentifierToken({ identifier, requestHash, identifierAuthProvider, }: { - escrows: RecoveryEscrowProvider[]; identifier: Identifier; requestHash: string; identifierAuthProvider: RecoveryIdentifierAuthProvider; -}): Promise { +}): Promise<{ token: KeyBoundIdentifierToken; proofPrivateKey: string }> { const proofKey = generateSigningKey(); const token = await identifierAuthProvider.getKeyBoundIdentifierToken({ identifier, proofPublicKey: proofKey.publicKey, requestHash, }); + return { token, proofPrivateKey: proofKey.privateKey }; +} + +/** + * Authorizes a key-bound identifier with each escrow that successfully + * provides a challenge. + * + * @param params - Key-bound authorization parameters. + * @param params.escrows - Escrows to authorize. + * @param params.token - Key-bound identifier token. + * @param params.proofPrivateKey - Private key for proving token possession. + * @returns Successful escrow authorizations. + */ +export async function authorizeWithKeyBoundToken({ + escrows, + token, + proofPrivateKey, +}: { + escrows: RecoveryEscrowProvider[]; + token: KeyBoundIdentifierToken; + proofPrivateKey: string; +}): Promise { const challengeResults = await Promise.allSettled( escrows.map((escrow) => escrow.generateChallenge()), ); @@ -84,7 +104,7 @@ export async function authorizeKeyBoundIdentifier({ ); const authorizationResults = await Promise.allSettled( challenges.map(async ({ escrow, challenge }) => { - const message = hash([token, challenge.id, requestHash]); + const message = hash([token, challenge.id, token.requestHash]); return { escrow, authorization: { @@ -92,8 +112,8 @@ export async function authorizeKeyBoundIdentifier({ token, proof: { challengeId: challenge.id, - requestHash, - signature: sign(proofKey.privateKey, message), + requestHash: token.requestHash, + signature: sign(proofPrivateKey, message), }, }, }; @@ -104,6 +124,40 @@ export async function authorizeKeyBoundIdentifier({ .map((result) => result.value); } +/** + * Requests and authorizes a key-bound identifier with each escrow that + * successfully provides a challenge. + * + * @param params - Identifier authorization parameters. + * @param params.escrows - Escrows to authorize. + * @param params.identifier - Identifier to authorize. + * @param params.requestHash - Request hash bound to the authorization. + * @param params.identifierAuthProvider - Provider for key-bound assertions. + * @returns Successful escrow authorizations. + */ +export async function authorizeKeyBoundIdentifier({ + escrows, + identifier, + requestHash, + identifierAuthProvider, +}: { + escrows: RecoveryEscrowProvider[]; + identifier: Identifier; + requestHash: string; + identifierAuthProvider: RecoveryIdentifierAuthProvider; +}): Promise { + const { token, proofPrivateKey } = await requestKeyBoundIdentifierToken({ + identifier, + requestHash, + identifierAuthProvider, + }); + return await authorizeWithKeyBoundToken({ + escrows, + token, + proofPrivateKey, + }); +} + /** * Returns the auth mode for an identifier type. * diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts index 1733842c36e..4c56a2bc474 100644 --- a/packages/mfa-recovery-controller/src/index.ts +++ b/packages/mfa-recovery-controller/src/index.ts @@ -13,6 +13,7 @@ export type { } from './MfaRecoveryController.js'; export type { MfaRecoveryControllerAbortAction, + MfaRecoveryControllerAuthenticateIdentifierAction, MfaRecoveryControllerGetPhaseAction, MfaRecoveryControllerGetRecoverySecretAction, MfaRecoveryControllerRegisterAction, @@ -34,6 +35,7 @@ export type { EncryptedPendingOperation, Identifier, IdentifierAuthorization, + IdentifierSession, Mutation, MutationPayload, MutationReceipt, diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index 7d74e7159b2..f29c9513f18 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -95,6 +95,20 @@ export type EcPublicJwk = { y: string; }; +/** + * Result of first-factor identifier authentication, consumed by + * `getRecoverySecret`. + * + * The private keys are ephemeral and must only be kept in memory. + */ +export type IdentifierSession = { + token: KeyBoundIdentifierToken; + proofPrivateKey: string; + requestId: string; + ephemeralPrivateKey: string; + pkE: EcPublicJwk; +}; + /** * Recovery secret wrapped to one escrow wrap key for transit. * `ciphertext` is unprefixed hex of `nonce || ChaCha20-Poly1305(ciphertext+tag)`. From 21968aee38fbccc4ac17915c62efcd162ba1963e Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:27:06 +0800 Subject: [PATCH 19/55] refactor(mfa-recovery-controller): update architecture and changelog for identifier authentication - Renamed `getRecoverySecret` to accept an `IdentifierSession` instead of an identifier. - Added `authenticateIdentifier` to the public methods list in the architecture documentation. - Updated sequence diagrams to reflect the new authentication flow and session management. - Enhanced changelog to document the changes in public methods and breaking changes related to session handling. --- packages/mfa-recovery-controller/ARCHITECTURE.md | 16 +++++++++++++--- packages/mfa-recovery-controller/CHANGELOG.md | 7 ++++++- .../scripts/register-with-controller.ts | 3 ++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index f16737499ae..a7514a5bd19 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -51,7 +51,7 @@ is the hash of that pending payload. At apply, each escrow gets ephemeral `pkE` into the `getSecret` request hash. Public surface: `register`, `updateRecoverySecret`, `updateIdentifiers`, -`getRecoverySecret`, `resume`, `abort`, `getPhase`. +`authenticateIdentifier`, `getRecoverySecret`, `resume`, `abort`, `getPhase`. ## State machine @@ -139,11 +139,17 @@ stored version. sequenceDiagram participant C as Client participant M as MfaRecoveryController + participant I as IdentifierAuthProvider participant E as Available escrows - C->>M: getRecoverySecret(identifier) + C->>M: authenticateIdentifier(identifier) + M->>M: generate requestId, proof key, and ephemeral pkE + M->>I: getKeyBoundIdentifierToken + M-->>C: IdentifierSession + C->>M: getRecoverySecret(session) + M->>M: recompute and validate requestHash M->>E: isAvailable (failures skipped) - M->>E: authorize identifier (partial OK) + M->>E: authorize token (partial OK) par read M->>E: getSecret end @@ -157,6 +163,10 @@ sequenceDiagram end ``` +`IdentifierSession` contains the key-bound token, request identifiers, and +ephemeral private keys needed for the read. The controller does not persist or +store the session; callers must keep it in memory and must not log it. + `getRecoverySecret` does not block on a pending mutation. During `writing`, replicas may hold different versions; the read takes the highest one. The next mutation still requires `resume()` first. diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index c9f0b6f6823..4f3801c2068 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `MfaRecoveryController` for replicating an MFA recovery secret across injected escrow replicas ([#10022](https://github.com/MetaMask/core/pull/10022)) - - Public methods: `register`, `updateRecoverySecret`, `updateIdentifiers`, `getRecoverySecret`, `resume`, `abort`, and `getPhase` + - Public methods: `register`, `updateRecoverySecret`, `updateIdentifiers`, `authenticateIdentifier`, `getRecoverySecret`, `resume`, `abort`, and `getPhase` - Inject `RecoveryAuthProvider`, `RecoveryIdentifierAuthProvider`, `RecoveryEscrowProvider[]`, and `PendingOperationEncryptor` - Persist only an encrypted `authorizing` / `writing` pending mutation (`idle` when `pendingOperation` is `null`) - `register` and `updateIdentifiers` require at least two distinct identifiers (`MIN_IDENTIFIERS`) @@ -23,5 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AuthControllerToken.expiresAt` and `PoPChallenge.expiresAt` are Unix seconds - Mutations require idle pending state (`resume()` / `abort()` first). `abort()` drops `authorizing` only; `writing` must be finished with `resume()` - `CubistEscrowProvider` invokes Cubist escrow commands through `@cubist-labs/cubesigner-sdk` (`generateChallenge`, `applyMutation`, `getSecret` on `cubist_secret_escrow`) and verifies receipts with a pinned receipt public key + - `authenticateIdentifier` returns an in-memory `IdentifierSession` for key-bound recovery-secret reads + +### Changed + +- **BREAKING:** `getRecoverySecret` now accepts an `IdentifierSession` returned by `authenticateIdentifier` instead of an `Identifier` [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/mfa-recovery-controller/scripts/register-with-controller.ts b/packages/mfa-recovery-controller/scripts/register-with-controller.ts index 8ccd3809c7d..fed4e364470 100644 --- a/packages/mfa-recovery-controller/scripts/register-with-controller.ts +++ b/packages/mfa-recovery-controller/scripts/register-with-controller.ts @@ -135,7 +135,8 @@ async function main(flags: Record): Promise { const recoverySecret = new Uint8Array(randomBytes(32)); await controller.register(recoverySecret, [PASSKEY, OIDC]); - const recovered = await controller.getRecoverySecret(PASSKEY); + const identifierSession = await controller.authenticateIdentifier(PASSKEY); + const recovered = await controller.getRecoverySecret(identifierSession); const registeredSecret = bytesToHex(recoverySecret); const recoveredSecret = bytesToHex(recovered.recoverySecret); From a2f6118d2baf78241ebf8f951927cfc69c373783 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:26:38 +0800 Subject: [PATCH 20/55] feat(mfa-recovery-controller): implement identifier authentication and session management - Added `authenticateIdentifier` method to handle identifier authentication and return an ephemeral session. - Introduced `IdentifierSession` type to encapsulate session data required for recovery secret retrieval. - Updated `getRecoverySecret` to accept an `IdentifierSession` instead of an identifier, ensuring session validation. - Enhanced tests to cover identifier authentication scenarios, including tampering checks and session reuse. - Refactored authorization logic to utilize key-bound tokens for improved security and session management. --- ...aRecoveryController-method-action-types.ts | 15 +- .../src/MfaRecoveryController.test.ts | 137 +++++++++++++++--- .../src/MfaRecoveryController.ts | 64 ++++++-- .../src/identifier-auth.ts | 78 ++++++++-- packages/mfa-recovery-controller/src/index.ts | 2 + packages/mfa-recovery-controller/src/types.ts | 14 ++ 6 files changed, 270 insertions(+), 40 deletions(-) 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 204688a79f1..59e046054a5 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController-method-action-types.ts @@ -42,12 +42,24 @@ export type MfaRecoveryControllerUpdateIdentifiersAction = { handler: MfaRecoveryController['updateIdentifiers']; }; +/** + * Authenticates an identifier for a subsequent recovery-secret read. + * + * @param identifier - Identifier to authenticate. + * @returns Ephemeral session data required by {@link getRecoverySecret}. + */ +export type MfaRecoveryControllerAuthenticateIdentifierAction = { + type: `MfaRecoveryController:authenticateIdentifier`; + handler: MfaRecoveryController['authenticateIdentifier']; +}; + /** * Reads the recovery secret from available escrows and returns the highest * consistent version. `epoch` is the current recovery version and is required * by later mutations. Does not wait for a pending mutation to be repaired. * - * @param identifier - Identifier used to authorize the read. + * @param session - Identifier authentication session used to authorize the + * read. * @returns Recovered secret bytes and the selected epoch. */ export type MfaRecoveryControllerGetRecoverySecretAction = { @@ -86,6 +98,7 @@ export type MfaRecoveryControllerMethodActions = | MfaRecoveryControllerRegisterAction | MfaRecoveryControllerUpdateRecoverySecretAction | MfaRecoveryControllerUpdateIdentifiersAction + | MfaRecoveryControllerAuthenticateIdentifierAction | MfaRecoveryControllerGetRecoverySecretAction | MfaRecoveryControllerResumeAction | MfaRecoveryControllerAbortAction diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 480762d5b32..564166a91ef 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -27,6 +27,8 @@ import type { import { MfaRecoveryController } from './MfaRecoveryController.js'; import type { Identifier, + IdentifierSession, + RecoveredSecret, RecoveryEscrowProvider, RegisterPayload, WritingPendingOperation, @@ -175,7 +177,7 @@ describe('MfaRecoveryController', () => { ]); expect(wrappedA.ciphertext).not.toBe(wrappedB.ciphertext); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -252,6 +254,98 @@ describe('MfaRecoveryController', () => { }); }); + describe('authenticateIdentifier', () => { + it('rejects unknown identifier types', async () => { + await withController(async ({ controller }) => { + await expect( + controller.authenticateIdentifier({ + type: 'unknown', + namespace: 'example.com', + value: 'unknown', + verifier: null, + }), + ).rejects.toThrow('Unknown identifier type'); + }); + }); + + it('binds the returned token to the read request', async () => { + await withController(async ({ controller }) => { + const session = await controller.authenticateIdentifier(PASSKEY); + + expect(session.token.requestHash).toBe( + hash({ + operation: 'getRecoverySecret', + requestId: session.requestId, + pkE: session.pkE, + }), + ); + }); + }); + + it.each([ + { + name: 'request id', + update: (session: IdentifierSession): IdentifierSession => ({ + ...session, + requestId: `${session.requestId}-tampered`, + }), + }, + { + name: 'ephemeral public key', + update: ( + session: Awaited< + ReturnType + >, + ): IdentifierSession => ({ + ...session, + pkE: { ...session.pkE, x: `${session.pkE.x}-tampered` }, + }), + }, + ])('rejects a tampered $name', async ({ update }) => { + await withController(async ({ controller }) => { + const session = await controller.authenticateIdentifier(PASSKEY); + + await expect( + controller.getRecoverySecret(update(session)), + ).rejects.toMatchObject({ + code: 'invalid_identifier_session', + }); + }); + }); + + it('does not request another token when reading with a session', async () => { + await withController(async ({ controller, options }) => { + await controller.register(SECRET, IDENTIFIERS); + const getToken = jest.spyOn( + options.identifierAuthProvider, + 'getKeyBoundIdentifierToken', + ); + + const session = await controller.authenticateIdentifier(PASSKEY); + expect(getToken).toHaveBeenCalledTimes(1); + + await controller.getRecoverySecret(session); + expect(getToken).toHaveBeenCalledTimes(1); + }); + }); + + it('allows a session to be reused for another read', async () => { + await withController(async ({ controller }) => { + await controller.register(SECRET, IDENTIFIERS); + const session = await controller.authenticateIdentifier(PASSKEY); + + const first = await controller.getRecoverySecret(session); + expect(first).toStrictEqual( + expect.objectContaining({ epoch: REGISTERED_EPOCH }), + ); + const second = await controller.getRecoverySecret(session); + expect(second).toStrictEqual( + expect.objectContaining({ epoch: REGISTERED_EPOCH }), + ); + }); + }); + }); + describe('getRecoverySecret', () => { it('reads the highest version while a mutation is writing', async () => { await withController(async ({ controller, escrowA }) => { @@ -261,7 +355,7 @@ describe('MfaRecoveryController', () => { controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toBeInstanceOf(IncompleteMutationError); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); expect(recovered.epoch).toBe(REGISTERED_EPOCH + 1); @@ -272,7 +366,7 @@ describe('MfaRecoveryController', () => { it('returns the selected epoch so a later update can be formed', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, IDENTIFIERS); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); expect(recovered.epoch).toBe(REGISTERED_EPOCH); @@ -282,7 +376,7 @@ describe('MfaRecoveryController', () => { SECRET_2, recovered.epoch, ); - const afterUpdate = await controller.getRecoverySecret(PASSKEY); + const afterUpdate = await recover(controller, PASSKEY); expect(bytesToHex(afterUpdate.recoverySecret)).toBe( bytesToHex(SECRET_2), ); @@ -294,7 +388,7 @@ describe('MfaRecoveryController', () => { await withController(async ({ controller, escrowB }) => { await controller.register(SECRET, IDENTIFIERS); escrowB.available = false; - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -306,7 +400,7 @@ describe('MfaRecoveryController', () => { .spyOn(escrowA, 'isAvailable') .mockRejectedValue(new Error('health check failed')); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); @@ -319,7 +413,7 @@ describe('MfaRecoveryController', () => { .spyOn(escrowA, 'generateChallenge') .mockRejectedValue(new Error('challenge failed')); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); @@ -334,7 +428,7 @@ describe('MfaRecoveryController', () => { lastMutationId: '0x', wrapKeyId: '0xdead', }); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -345,7 +439,7 @@ describe('MfaRecoveryController', () => { escrowB.patchRecord('profile-1', { lastMutationId: '0xdifferent', }); - await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( + await expect(recover(controller, PASSKEY)).rejects.toThrow( 'Replica corruption', ); }); @@ -355,7 +449,7 @@ describe('MfaRecoveryController', () => { await withController(async ({ controller, escrowA, escrowB }) => { escrowA.available = false; escrowB.available = false; - await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( + await expect(recover(controller, PASSKEY)).rejects.toThrow( 'No escrow is available', ); }); @@ -366,7 +460,7 @@ describe('MfaRecoveryController', () => { await controller.register(SECRET, IDENTIFIERS); escrowA.failGetSecret = true; escrowB.failGetSecret = true; - await expect(controller.getRecoverySecret(PASSKEY)).rejects.toThrow( + await expect(recover(controller, PASSKEY)).rejects.toThrow( 'No escrow returned a recovery secret', ); }); @@ -405,7 +499,7 @@ describe('MfaRecoveryController', () => { REGISTERED_EPOCH, ); expect(generateChallenge).toHaveBeenCalledWith(); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); }); }); @@ -569,7 +663,7 @@ describe('MfaRecoveryController', () => { [OIDC, PASSKEY], REGISTERED_EPOCH, ); - const recovered = await controller.getRecoverySecret(OIDC); + const recovered = await recover(controller, OIDC); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -596,7 +690,7 @@ describe('MfaRecoveryController', () => { await controller.resume(); expect(await controller.getPhase()).toBe('idle'); - const recovered = await controller.getRecoverySecret(PASSKEY); + const recovered = await recover(controller, PASSKEY); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET_2)); }); }); @@ -616,7 +710,7 @@ describe('MfaRecoveryController', () => { expect(await controller.getPhase()).toBe('idle'); expect( bytesToHex( - (await controller.getRecoverySecret(PASSKEY)).recoverySecret, + (await recover(controller, PASSKEY)).recoverySecret, ), ).toBe(bytesToHex(SECRET_2)); }); @@ -733,7 +827,7 @@ describe('MfaRecoveryController', () => { expect(await controller.getPhase()).toBe('idle'); expect( bytesToHex( - (await controller.getRecoverySecret(PASSKEY)).recoverySecret, + (await recover(controller, PASSKEY)).recoverySecret, ), ).toBe(bytesToHex(SECRET)); }); @@ -775,7 +869,7 @@ describe('MfaRecoveryController', () => { expect(await resumed.getPhase()).toBe('idle'); expect( - bytesToHex((await resumed.getRecoverySecret(PASSKEY)).recoverySecret), + bytesToHex((await recover(resumed, PASSKEY)).recoverySecret), ).toBe(bytesToHex(SECRET)); }); }); @@ -1439,6 +1533,15 @@ function getOptions( }; } +async function recover( + controller: MfaRecoveryController, + identifier: Identifier, +): Promise { + return await controller.getRecoverySecret( + await controller.authenticateIdentifier(identifier), + ); +} + async function getValidWritingPending( escrows: RecoveryEscrowProvider[], ): Promise { diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index ae57db4174a..9e8455ee2ea 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -26,9 +26,11 @@ import { } from './escrow-utils.js'; import { authorizeKeyBoundIdentifier, + authorizeWithKeyBoundToken, countDistinctIdentifiers, getIdentifierAuthMode, MIN_IDENTIFIERS, + requestKeyBoundIdentifierToken, } from './identifier-auth.js'; import type { AuthorizedEscrow } from './identifier-auth.js'; import type { MfaRecoveryControllerMethodActions } from './MfaRecoveryController-method-action-types.js'; @@ -42,6 +44,7 @@ import type { AuthControllerToken, EcPublicJwk, Identifier, + IdentifierSession, Mutation, MutationPayload, MutationReceipt, @@ -66,6 +69,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'register', 'updateRecoverySecret', 'updateIdentifiers', + 'authenticateIdentifier', 'getRecoverySecret', 'resume', 'abort', @@ -315,25 +319,65 @@ export class MfaRecoveryController extends BaseController< }); } + /** + * Authenticates an identifier for a subsequent recovery-secret read. + * + * @param identifier - Identifier to authenticate. + * @returns Ephemeral session data required by {@link getRecoverySecret}. + */ + async authenticateIdentifier( + identifier: Identifier, + ): Promise { + this.#assertKnownIdentifierTypes([identifier]); + const requestId = randomId(); + const ephemeral = generateSigningKey(); + const pkE = JSON.parse(ephemeral.publicKey) as EcPublicJwk; + const requestHash = hash({ + operation: 'getRecoverySecret', + requestId, + pkE, + }); + const { token, proofPrivateKey } = + await requestKeyBoundIdentifierToken({ + identifier, + requestHash, + identifierAuthProvider: this.#identifierAuthProvider, + }); + return { + token, + proofPrivateKey, + requestId, + ephemeralPrivateKey: ephemeral.privateKey, + pkE, + }; + } + /** * Reads the recovery secret from available escrows and returns the highest * consistent version. `epoch` is the current recovery version and is required * by later mutations. Does not wait for a pending mutation to be repaired. * - * @param identifier - Identifier used to authorize the read. + * @param session - Identifier authentication session used to authorize the + * read. * @returns Recovered secret bytes and the selected epoch. */ - async getRecoverySecret(identifier: Identifier): Promise { + async getRecoverySecret( + session: IdentifierSession, + ): Promise { return await this.#withLock(async () => { - this.#assertKnownIdentifierTypes([identifier]); - const requestId = randomId(); - const ephemeral = generateSigningKey(); - const pkE = JSON.parse(ephemeral.publicKey) as EcPublicJwk; + const { token, requestId, pkE } = session; + this.#assertKnownIdentifierTypes([token.identifier]); const requestHash = hash({ operation: 'getRecoverySecret', requestId, pkE, }); + if (token.requestHash !== requestHash) { + throw new MfaRecoveryError( + 'Invalid identifier session', + 'invalid_identifier_session', + ); + } const available = await this.#getAvailableEscrows(); if (available.length === 0) { throw new MfaRecoveryError( @@ -341,10 +385,10 @@ export class MfaRecoveryController extends BaseController< 'no_available_escrow', ); } - const authorizedEscrows = await this.#authorizeIdentifier({ + const authorizedEscrows = await authorizeWithKeyBoundToken({ escrows: available, - identifier, - requestHash, + token, + proofPrivateKey: session.proofPrivateKey, }); const results = await Promise.allSettled( authorizedEscrows.map(async ({ escrow, authorization }) => { @@ -362,7 +406,7 @@ export class MfaRecoveryController extends BaseController< return { escrowId: escrow.id, recoverySecret: decryptFromPublic( - ephemeral.privateKey, + session.ephemeralPrivateKey, escrow.wrapPublicKey, response.recoverySecret.ciphertext, ), diff --git a/packages/mfa-recovery-controller/src/identifier-auth.ts b/packages/mfa-recovery-controller/src/identifier-auth.ts index e89115b7c44..c7566688de0 100644 --- a/packages/mfa-recovery-controller/src/identifier-auth.ts +++ b/packages/mfa-recovery-controller/src/identifier-auth.ts @@ -5,6 +5,7 @@ import type { Identifier, IdentifierAuthMode, IdentifierAuthorization, + KeyBoundIdentifierToken, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, } from './types.js'; @@ -47,33 +48,52 @@ export type AuthorizedEscrow = { }; /** - * Authorizes an identifier with each escrow that successfully provides a - * challenge. + * Requests a key-bound identifier token and returns the private key used to + * prove possession of it. * * @param params - Identifier authorization parameters. - * @param params.escrows - Escrows to authorize. - * @param params.identifier - Identifier to authorize. + * @param params.identifier - Identifier to authenticate. * @param params.requestHash - Request hash bound to the authorization. * @param params.identifierAuthProvider - Provider for key-bound assertions. - * @returns Successful escrow authorizations. + * @returns Key-bound identifier token and its proof private key. */ -export async function authorizeKeyBoundIdentifier({ - escrows, +export async function requestKeyBoundIdentifierToken({ identifier, requestHash, identifierAuthProvider, }: { - escrows: RecoveryEscrowProvider[]; identifier: Identifier; requestHash: string; identifierAuthProvider: RecoveryIdentifierAuthProvider; -}): Promise { +}): Promise<{ token: KeyBoundIdentifierToken; proofPrivateKey: string }> { const proofKey = generateSigningKey(); const token = await identifierAuthProvider.getKeyBoundIdentifierToken({ identifier, proofPublicKey: proofKey.publicKey, requestHash, }); + return { token, proofPrivateKey: proofKey.privateKey }; +} + +/** + * Authorizes a key-bound identifier with each escrow that successfully + * provides a challenge. + * + * @param params - Key-bound authorization parameters. + * @param params.escrows - Escrows to authorize. + * @param params.token - Key-bound identifier token. + * @param params.proofPrivateKey - Private key for proving token possession. + * @returns Successful escrow authorizations. + */ +export async function authorizeWithKeyBoundToken({ + escrows, + token, + proofPrivateKey, +}: { + escrows: RecoveryEscrowProvider[]; + token: KeyBoundIdentifierToken; + proofPrivateKey: string; +}): Promise { const challengeResults = await Promise.allSettled( escrows.map((escrow) => escrow.generateChallenge()), ); @@ -84,7 +104,7 @@ export async function authorizeKeyBoundIdentifier({ ); const authorizationResults = await Promise.allSettled( challenges.map(async ({ escrow, challenge }) => { - const message = hash([token, challenge.id, requestHash]); + const message = hash([token, challenge.id, token.requestHash]); return { escrow, authorization: { @@ -92,8 +112,8 @@ export async function authorizeKeyBoundIdentifier({ token, proof: { challengeId: challenge.id, - requestHash, - signature: sign(proofKey.privateKey, message), + requestHash: token.requestHash, + signature: sign(proofPrivateKey, message), }, }, }; @@ -104,6 +124,40 @@ export async function authorizeKeyBoundIdentifier({ .map((result) => result.value); } +/** + * Requests and authorizes a key-bound identifier with each escrow that + * successfully provides a challenge. + * + * @param params - Identifier authorization parameters. + * @param params.escrows - Escrows to authorize. + * @param params.identifier - Identifier to authorize. + * @param params.requestHash - Request hash bound to the authorization. + * @param params.identifierAuthProvider - Provider for key-bound assertions. + * @returns Successful escrow authorizations. + */ +export async function authorizeKeyBoundIdentifier({ + escrows, + identifier, + requestHash, + identifierAuthProvider, +}: { + escrows: RecoveryEscrowProvider[]; + identifier: Identifier; + requestHash: string; + identifierAuthProvider: RecoveryIdentifierAuthProvider; +}): Promise { + const { token, proofPrivateKey } = await requestKeyBoundIdentifierToken({ + identifier, + requestHash, + identifierAuthProvider, + }); + return await authorizeWithKeyBoundToken({ + escrows, + token, + proofPrivateKey, + }); +} + /** * Returns the auth mode for an identifier type. * diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts index 36fb4526842..bf03a9da7aa 100644 --- a/packages/mfa-recovery-controller/src/index.ts +++ b/packages/mfa-recovery-controller/src/index.ts @@ -13,6 +13,7 @@ export type { } from './MfaRecoveryController.js'; export type { MfaRecoveryControllerAbortAction, + MfaRecoveryControllerAuthenticateIdentifierAction, MfaRecoveryControllerGetPhaseAction, MfaRecoveryControllerGetRecoverySecretAction, MfaRecoveryControllerRegisterAction, @@ -32,6 +33,7 @@ export type { EncryptedPendingOperation, Identifier, IdentifierAuthorization, + IdentifierSession, Mutation, MutationPayload, MutationReceipt, diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index 7d74e7159b2..f29c9513f18 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -95,6 +95,20 @@ export type EcPublicJwk = { y: string; }; +/** + * Result of first-factor identifier authentication, consumed by + * `getRecoverySecret`. + * + * The private keys are ephemeral and must only be kept in memory. + */ +export type IdentifierSession = { + token: KeyBoundIdentifierToken; + proofPrivateKey: string; + requestId: string; + ephemeralPrivateKey: string; + pkE: EcPublicJwk; +}; + /** * Recovery secret wrapped to one escrow wrap key for transit. * `ciphertext` is unprefixed hex of `nonce || ChaCha20-Poly1305(ciphertext+tag)`. From eeefb2c13567de2d5a9a29f9b7fbf0d78066a780 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:07:05 +0800 Subject: [PATCH 21/55] refactor(mfa-recovery-controller): update key-bound identifier handling and authorization flow --- .../src/MfaRecoveryController.ts | 30 +++++----- .../src/identifier-auth.ts | 55 +++---------------- packages/mfa-recovery-controller/src/types.ts | 20 +++++-- 3 files changed, 37 insertions(+), 68 deletions(-) diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 9e8455ee2ea..db4b536c3f3 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -25,8 +25,7 @@ import { verifyMutationReceipt, } from './escrow-utils.js'; import { - authorizeKeyBoundIdentifier, - authorizeWithKeyBoundToken, + authorizeEscrowsWithToken, countDistinctIdentifiers, getIdentifierAuthMode, MIN_IDENTIFIERS, @@ -337,15 +336,13 @@ export class MfaRecoveryController extends BaseController< requestId, pkE, }); - const { token, proofPrivateKey } = - await requestKeyBoundIdentifierToken({ - identifier, - requestHash, - identifierAuthProvider: this.#identifierAuthProvider, - }); + const credential = await requestKeyBoundIdentifierToken({ + identifier, + requestHash, + identifierAuthProvider: this.#identifierAuthProvider, + }); return { - token, - proofPrivateKey, + ...credential, requestId, ephemeralPrivateKey: ephemeral.privateKey, pkE, @@ -385,10 +382,9 @@ export class MfaRecoveryController extends BaseController< 'no_available_escrow', ); } - const authorizedEscrows = await authorizeWithKeyBoundToken({ + const authorizedEscrows = await authorizeEscrowsWithToken({ escrows: available, - token, - proofPrivateKey: session.proofPrivateKey, + credential: session, }); const results = await Promise.allSettled( authorizedEscrows.map(async ({ escrow, authorization }) => { @@ -564,7 +560,7 @@ export class MfaRecoveryController extends BaseController< const authorizedEscrows = mutation.operation === 'register' ? null - : await this.#authorizeIdentifier({ + : await this.#authorizeEscrows({ escrows: remainingEscrows, identifier: identifier as Identifier, requestHash: mutation.requestHash, @@ -672,7 +668,7 @@ export class MfaRecoveryController extends BaseController< return { receipts, hasInvalidReceipt }; } - async #authorizeIdentifier({ + async #authorizeEscrows({ escrows, identifier, requestHash, @@ -682,12 +678,12 @@ export class MfaRecoveryController extends BaseController< requestHash: string; }): Promise { getIdentifierAuthMode(identifier.type); - return await authorizeKeyBoundIdentifier({ - escrows, + const credential = await requestKeyBoundIdentifierToken({ identifier, requestHash, identifierAuthProvider: this.#identifierAuthProvider, }); + return await authorizeEscrowsWithToken({ escrows, credential }); } #payloadForEscrow( diff --git a/packages/mfa-recovery-controller/src/identifier-auth.ts b/packages/mfa-recovery-controller/src/identifier-auth.ts index c7566688de0..0df9298bed2 100644 --- a/packages/mfa-recovery-controller/src/identifier-auth.ts +++ b/packages/mfa-recovery-controller/src/identifier-auth.ts @@ -5,7 +5,7 @@ import type { Identifier, IdentifierAuthMode, IdentifierAuthorization, - KeyBoundIdentifierToken, + KeyBoundCredential, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, } from './types.js'; @@ -65,7 +65,7 @@ export async function requestKeyBoundIdentifierToken({ identifier: Identifier; requestHash: string; identifierAuthProvider: RecoveryIdentifierAuthProvider; -}): Promise<{ token: KeyBoundIdentifierToken; proofPrivateKey: string }> { +}): Promise { const proofKey = generateSigningKey(); const token = await identifierAuthProvider.getKeyBoundIdentifierToken({ identifier, @@ -76,24 +76,21 @@ export async function requestKeyBoundIdentifierToken({ } /** - * Authorizes a key-bound identifier with each escrow that successfully - * provides a challenge. + * Presents a key-bound credential to each escrow that provides a challenge. * - * @param params - Key-bound authorization parameters. + * @param params - Escrow authorization parameters. * @param params.escrows - Escrows to authorize. - * @param params.token - Key-bound identifier token. - * @param params.proofPrivateKey - Private key for proving token possession. + * @param params.credential - Key-bound identifier credential. * @returns Successful escrow authorizations. */ -export async function authorizeWithKeyBoundToken({ +export async function authorizeEscrowsWithToken({ escrows, - token, - proofPrivateKey, + credential, }: { escrows: RecoveryEscrowProvider[]; - token: KeyBoundIdentifierToken; - proofPrivateKey: string; + credential: KeyBoundCredential; }): Promise { + const { token, proofPrivateKey } = credential; const challengeResults = await Promise.allSettled( escrows.map((escrow) => escrow.generateChallenge()), ); @@ -124,40 +121,6 @@ export async function authorizeWithKeyBoundToken({ .map((result) => result.value); } -/** - * Requests and authorizes a key-bound identifier with each escrow that - * successfully provides a challenge. - * - * @param params - Identifier authorization parameters. - * @param params.escrows - Escrows to authorize. - * @param params.identifier - Identifier to authorize. - * @param params.requestHash - Request hash bound to the authorization. - * @param params.identifierAuthProvider - Provider for key-bound assertions. - * @returns Successful escrow authorizations. - */ -export async function authorizeKeyBoundIdentifier({ - escrows, - identifier, - requestHash, - identifierAuthProvider, -}: { - escrows: RecoveryEscrowProvider[]; - identifier: Identifier; - requestHash: string; - identifierAuthProvider: RecoveryIdentifierAuthProvider; -}): Promise { - const { token, proofPrivateKey } = await requestKeyBoundIdentifierToken({ - identifier, - requestHash, - identifierAuthProvider, - }); - return await authorizeWithKeyBoundToken({ - escrows, - token, - proofPrivateKey, - }); -} - /** * Returns the auth mode for an identifier type. * diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index f29c9513f18..5f2a00b9fe2 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -86,7 +86,8 @@ export type MutationReceipt = { }; /** - * P-256 public JWK used as an ephemeral wrap key (`pkE`). + * P-256 public JWK for the client's ephemeral key (`pkE`). The escrow wrap + * key is a different key. */ export type EcPublicJwk = { kty: 'EC'; @@ -95,15 +96,24 @@ export type EcPublicJwk = { y: string; }; +/** + * Key-bound identifier credential: the IdP token and the private key that + * proves possession of it. Ephemeral; keep only in memory. + */ +export type KeyBoundCredential = { + token: KeyBoundIdentifierToken; + proofPrivateKey: string; +}; + /** * Result of first-factor identifier authentication, consumed by * `getRecoverySecret`. * - * The private keys are ephemeral and must only be kept in memory. + * `proofPrivateKey` signs escrow challenges. `ephemeralPrivateKey` is the + * private half of `pkE` and decrypts the recovery secret. Keep both in + * memory only. */ -export type IdentifierSession = { - token: KeyBoundIdentifierToken; - proofPrivateKey: string; +export type IdentifierSession = KeyBoundCredential & { requestId: string; ephemeralPrivateKey: string; pkE: EcPublicJwk; From 73b7b8ded471af833a2fd558125d275aac44a5c1 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:47:45 +0800 Subject: [PATCH 22/55] docs(mfa-recovery-controller): add detailed Cubist store sequence and integration test documentation - Introduced a new section in ARCHITECTURE.md outlining the Cubist store sequence, detailing the interaction between the MfaRecoveryController and Cubist escrow. - Added CUBIST_TEST_SEQUENCE.md to document the integration test flow for the Cubist MFA recovery process, including sequence diagrams for clarity. --- .../mfa-recovery-controller/ARCHITECTURE.md | 55 +++++++++++++++++++ .../CUBIST_TEST_SEQUENCE.md | 48 ++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index f16737499ae..ef663835df4 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -128,6 +128,61 @@ Versioning: pending gets a fresh token then replicates; writing pending retries only escrows that do not already have a stored receipt. +## Cubist store sequence + +The controller does not call CubeSigner, the registration API, or +`KeyringController`. A Cubist deploy injects one `RecoveryEscrowProvider` +(`id: "cubist"`). The caller proves OIDC, calls `ensure-user`, opens the +session, and constructs `CubistEscrowProvider` before `register`. + +`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 provider persists that payload. `AuthControllerToken` +is opaque; a stub may put an SRP token in `signature`. + +`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->>Auth: getAuthenticatedProfileId + Ctrl->>Escrow: isAvailable + Ctrl->>Ctrl: persist authorizing + Ctrl->>Auth: authorizeRecoveryRequest + Note over Auth: stub may set signature to the SRP token + Ctrl->>Ctrl: wrap secret, persist writing + Ctrl->>Escrow: applyMutation + Escrow-->>Ctrl: receipt + Ctrl->>Ctrl: 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: generateChallenge + Ctrl->>Ctrl: sign proof of possession + Ctrl->>Escrow: getSecret + Escrow-->>Ctrl: wrapped secret + version + Ctrl->>Ctrl: unwrap + Ctrl-->>Caller: secret bytes + epoch +``` + ## Read sequence (`getRecoverySecret`) Reads tolerate down replicas. They do **not** repair pending writes. The diff --git a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md new file mode 100644 index 00000000000..2ff6881f7fb --- /dev/null +++ b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md @@ -0,0 +1,48 @@ +# Cubist MFA recovery integration test sequence + +Harness flow for `runMfaRecoveryCubistTest` (Developer Options → Cubist +gamma/beta/prod). Setup (OIDC proof, ensure-user, session, provider +construction) happens outside the controller; the controller only runs +`register` → `authenticateIdentifier` → `getRecoverySecret`. + +```mermaid +sequenceDiagram + participant UI as Developer Options + participant Test as runMfaRecoveryCubistTest + participant Auth as AuthenticationController + participant Cubist as CubeSigner (gamma/beta/prod) + participant Reg as Recovery Registration API + participant KC as KeyringController + participant Ctrl as MfaRecoveryController + participant Escrow as CubistEscrowProvider + + UI->>Test: run test + Test->>Auth: getBearerToken + getSessionProfile + Auth-->>Test: accessToken, profileId + + Test->>Cubist: proveOidcIdentity(orgId, accessToken) + Cubist-->>Test: providerRegistrationPayload + Test->>Reg: ensure-user (Bearer accessToken) + Reg-->>Test: created | exists + + Test->>Cubist: createOidcSession(scopes) + Cubist-->>Test: session + Test->>Test: new CubistEscrowProvider(client, wrap/receipt keys) + Test->>Test: new MfaRecoveryController(stub auth, SIWE auth, escrow) + + Test->>Ctrl: register(recoverySecret, siweIdentifier) + Ctrl->>Escrow: escrow C2F (wrap/store secret) + Note over Ctrl,Auth: StubAuthProvider puts SRP token in AuthControllerToken.signature + + Test->>Ctrl: authenticateIdentifier(siweIdentifier) + Ctrl->>KC: signPersonalMessage (identifier proof) + KC-->>Ctrl: signature + Ctrl-->>Test: identifierSession + + Test->>Ctrl: getRecoverySecret(identifierSession) + Ctrl->>Escrow: getSecret (C2F unwrap) + Escrow-->>Ctrl: wrapped/recovered secret + epoch + Ctrl-->>Test: recovered + Test->>Test: matches = bytes equal? + Test-->>UI: { ensureUserStatus, epoch, matches } +``` From de6199ea74129a0e034cc3ca151770a56f17a01b Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:19:12 +0800 Subject: [PATCH 23/55] refactor(mfa-recovery-controller): treat AuthController tokens as Hydra JWTs Decode compact access-token claims (exp, sub, aal, request_hash, identifiers_hash) instead of a local token object. --- .../src/MfaRecoveryController.ts | 8 ++- .../src/auth-controller-token.ts | 70 +++++++++++++++++++ .../mfa-recovery-controller/src/crypto.ts | 4 +- .../src/pending-operation-validation.ts | 34 ++------- packages/mfa-recovery-controller/src/types.ts | 18 ++--- 5 files changed, 88 insertions(+), 46 deletions(-) create mode 100644 packages/mfa-recovery-controller/src/auth-controller-token.ts diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index db4b536c3f3..3bd15751645 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -7,6 +7,7 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import { bytesToHex } from '@metamask/utils'; +import { decodeAuthControllerToken } from './auth-controller-token.js'; import { decodeHex, decryptFromPublic, @@ -552,10 +553,11 @@ export class MfaRecoveryController extends BaseController< ); } + const claims = decodeAuthControllerToken(pending.authControllerToken); const authControllerToken = - pending.authControllerToken.expiresAt <= this.#now() - ? await this.#authorizeMutation(mutation, payload) - : pending.authControllerToken; + claims && claims.exp > this.#now() + ? pending.authControllerToken + : await this.#authorizeMutation(mutation, payload); const writing = { ...pending, authControllerToken }; const authorizedEscrows = mutation.operation === 'register' diff --git a/packages/mfa-recovery-controller/src/auth-controller-token.ts b/packages/mfa-recovery-controller/src/auth-controller-token.ts new file mode 100644 index 00000000000..136f42e08e3 --- /dev/null +++ b/packages/mfa-recovery-controller/src/auth-controller-token.ts @@ -0,0 +1,70 @@ +import { bytesToString, isObject } from '@metamask/utils'; + +import { fromBase64Url } from './crypto.js'; + +/** + * The claims the controller reads from an AuthController (Hydra) access token, + * in the token's shape with camelCase names. The Auth API token hook adds the + * step-up bindings under `ext`; a token that did not step up has none of them. + */ +export type AuthControllerTokenClaims = { + /** + * The profile id. + */ + sub: string; + /** + * Unix time in seconds. + */ + exp: number; + ext: { + requestHash: string; + /** + * Authentication assurance level. + */ + aal?: number; + /** + * Only present when the step-up was bound to a payload's identifiers. + */ + identifiersHash?: string; + }; +}; + +/** + * Decodes the claims of an AuthController token without verifying its + * signature; the escrow verifies the token. Required claims must be present; + * optional ones may be absent but, when present, must have the right type. + * + * @param token - Compact JWT. + * @returns The claims, or `undefined` if the token is malformed. + */ +export function decodeAuthControllerToken( + token: unknown, +): AuthControllerTokenClaims | undefined { + if (typeof token !== 'string') { + return undefined; + } + let claims: unknown; + try { + claims = JSON.parse( + bytesToString(fromBase64Url(token.split('.')[1] ?? '')), + ); + } catch { + return undefined; + } + if (!isObject(claims) || !isObject(claims.ext)) { + return undefined; + } + const { sub, exp, ext } = claims; + const { aal, request_hash: requestHash, identifiers_hash: identifiersHash } = + ext; + if ( + typeof sub !== 'string' || + typeof exp !== 'number' || + typeof requestHash !== 'string' || + (aal !== undefined && typeof aal !== 'number') || + (identifiersHash !== undefined && typeof identifiersHash !== 'string') + ) { + return undefined; + } + return { sub, exp, ext: { requestHash, aal, identifiersHash } }; +} diff --git a/packages/mfa-recovery-controller/src/crypto.ts b/packages/mfa-recovery-controller/src/crypto.ts index 58fa25d51be..a8c190e06cb 100644 --- a/packages/mfa-recovery-controller/src/crypto.ts +++ b/packages/mfa-recovery-controller/src/crypto.ts @@ -298,12 +298,12 @@ function randomBytes(length: number): Uint8Array { } /** - * Decodes unpadded base64url JWK coordinates. + * Decodes unpadded base64url, as used by JWK coordinates and JWT segments. * * @param value - Base64url string. * @returns Raw bytes. */ -function fromBase64Url(value: string): Uint8Array { +export function fromBase64Url(value: string): Uint8Array { return base64ToBytes( value .replace(/-/gu, '+') diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts index 203cecc7387..3bf11dc83ca 100644 --- a/packages/mfa-recovery-controller/src/pending-operation-validation.ts +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -1,3 +1,4 @@ +import { decodeAuthControllerToken } from './auth-controller-token.js'; import { hash } from './crypto.js'; import { MfaRecoveryError } from './errors.js'; import { isMutationReceipt } from './escrow-utils.js'; @@ -8,7 +9,6 @@ import { } from './identifier-auth.js'; import { assertSameAudienceIds } from './state-machine.js'; import type { - AuthControllerToken, Identifier, Mutation, MutationReceipt, @@ -110,16 +110,14 @@ export async function assertValidPendingOperation( if (pending.phase === 'authorizing') { return; } + const token = decodeAuthControllerToken(pending.authControllerToken); if ( - !isAuthControllerToken(pending.authControllerToken) || - pending.authControllerToken.profileId !== mutation.profileId || - pending.authControllerToken.requestHash !== mutation.requestHash || - (mutation.operation !== 'register' && - pending.authControllerToken.twoFactor !== true) || + token?.sub !== mutation.profileId || + token.ext.requestHash !== mutation.requestHash || + (mutation.operation !== 'register' && (token.ext.aal ?? 0) < 2) || ((mutation.operation === 'register' || mutation.operation === 'updateIdentifiers') && - (pending.authControllerToken.identifierOwnershipApproved !== true || - typeof pending.authControllerToken.identifiersHash !== 'string')) || + token.ext.identifiersHash === undefined) || !isMutationReceiptArray(pending.receipts) ) { throwInvalidPendingOperation(); @@ -202,26 +200,6 @@ function isIdentifier(value: unknown): value is Identifier { ); } -function isAuthControllerToken(value: unknown): value is AuthControllerToken { - if (!isRecord(value)) { - return false; - } - return ( - typeof value.profileId === 'string' && - typeof value.requestHash === 'string' && - (value.twoFactor === undefined || value.twoFactor === true) && - (value.identifiersHash === undefined || - typeof value.identifiersHash === 'string') && - (value.identifierOwnershipApproved === undefined || - value.identifierOwnershipApproved === true) && - typeof value.issuer === 'string' && - typeof value.expiresAt === 'number' && - Number.isInteger(value.expiresAt) && - value.expiresAt >= 0 && - typeof value.signature === 'string' - ); -} - function isMutationReceiptArray(value: unknown): value is MutationReceipt[] { if (!Array.isArray(value)) { return false; diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index 5f2a00b9fe2..ecda69de39a 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -59,19 +59,11 @@ export type Mutation = { requestHash: string; }; -export type AuthControllerToken = { - profileId: string; - requestHash: string; - twoFactor?: true; - identifiersHash?: string; - identifierOwnershipApproved?: true; - issuer: string; - /** - * Unix time in seconds. - */ - expiresAt: number; - signature: string; -}; +/** + * Hydra access token (compact JWT). Its `ext` claims bind it to one mutation: + * `aal`, `request_hash`, and `identifiers_hash`. + */ +export type AuthControllerToken = string; export type MutationReceipt = { mutationId: string; From f9a12eaf7e3ea9f236b2270cfdab55afadeecdca Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:54:09 +0800 Subject: [PATCH 24/55] docs(mfa-recovery-controller): expand CUBIST_TEST_SEQUENCE.md with detailed integration test flow - Updated the documentation for the Cubist MFA recovery integration test sequence in CUBIST_TEST_SEQUENCE.md. - Enhanced the overview section with a sequence diagram illustrating the interaction between Mobile, MfaRecoveryController, and Cubist escrow. - Added detailed descriptions of the setup process, write path mutations, and read path operations. - Included notes on the use of development-only token routes and stub providers for testing. --- .../CUBIST_TEST_SEQUENCE.md | 291 +++++++++++++++--- 1 file changed, 251 insertions(+), 40 deletions(-) diff --git a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md index 2ff6881f7fb..89efe69dad6 100644 --- a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md +++ b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md @@ -1,48 +1,259 @@ # Cubist MFA recovery integration test sequence -Harness flow for `runMfaRecoveryCubistTest` (Developer Options → Cubist -gamma/beta/prod). Setup (OIDC proof, ensure-user, session, provider -construction) happens outside the controller; the controller only runs -`register` → `authenticateIdentifier` → `getRecoverySecret`. +Harness flow for `runMfaRecoveryCubistTest` in MetaMask Mobile (Developer +Options, Cubist gamma/beta/prod). Mobile drives one `MfaRecoveryController` +against a single Cubist escrow (`id: cubist`) through three mutations, reading +the secret back after each one. The general controller design is in +[ARCHITECTURE.md](./ARCHITECTURE.md); treat the TypeScript in `src/` as the +source of truth if a diagram drifts. + +- Versions: `0 → 1` (register), `1 → 2` (update secret), `2 → 3` (update + identifiers). +- Pending state: `idle → authorizing → writing → idle` for every mutation. + +Sections: + +- [Setup — outside the controller](#setup-outside-the-controller) +- [Write path — `register`, `updateRecoverySecret`, `updateIdentifiers`](#write-path-mutate) +- [Read path — `authenticateIdentifier`, `getRecoverySecret`](#read-path) + +## Overview + +```mermaid +sequenceDiagram + autonumber + participant Mobile as Mobile (runMfaRecoveryCubistTest) + participant C as MfaRecoveryController + participant Escrow as Cubist escrow + + Note over Mobile: createRecoveryContext
SIWE login, OIDC token, CubeSigner session + + Mobile->>C: register(secret, [siwe, passkey]) + C->>Escrow: applyMutation register, expectedVersion 0, newVersion 1 + Escrow-->>C: receipt version 1 + Mobile->>C: authenticateIdentifier(siwe) + Mobile->>C: getRecoverySecret(session) + C->>Escrow: getSecret + Escrow-->>Mobile: secret, epoch 1 + + Mobile->>C: updateRecoverySecret(passkey, newSecret, epoch 1) + C->>Escrow: applyMutation, newVersion 2 + Mobile->>C: authenticateIdentifier(passkey) + Mobile->>C: getRecoverySecret(session) + Escrow-->>Mobile: newSecret, epoch 2 + + Mobile->>C: updateIdentifiers(siwe, [siwe, replacementPasskey], epoch 2) + C->>Escrow: applyMutation, newVersion 3 + Mobile->>C: authenticateIdentifier(replacementPasskey) + Mobile->>C: getRecoverySecret(session) + Escrow-->>Mobile: same secret, epoch 3 +``` + +Each step only runs if the previous read returned the expected secret. + +## Setup (outside the controller) + +The controller does not call the Authentication API, the MPC token service, +the Recovery Registration API, or CubeSigner. Mobile does all of this before +constructing the controller (see +[Cubist store sequence](./ARCHITECTURE.md#cubist-store-sequence)). ```mermaid sequenceDiagram + autonumber participant UI as Developer Options - participant Test as runMfaRecoveryCubistTest - participant Auth as AuthenticationController - participant Cubist as CubeSigner (gamma/beta/prod) + participant Mobile as Mobile (runMfaRecoveryCubistTest) + participant AuthAPI as Authentication API + participant Token as MPC token service participant Reg as Recovery Registration API - participant KC as KeyringController - participant Ctrl as MfaRecoveryController - participant Escrow as CubistEscrowProvider - - UI->>Test: run test - Test->>Auth: getBearerToken + getSessionProfile - Auth-->>Test: accessToken, profileId - - Test->>Cubist: proveOidcIdentity(orgId, accessToken) - Cubist-->>Test: providerRegistrationPayload - Test->>Reg: ensure-user (Bearer accessToken) - Reg-->>Test: created | exists - - Test->>Cubist: createOidcSession(scopes) - Cubist-->>Test: session - Test->>Test: new CubistEscrowProvider(client, wrap/receipt keys) - Test->>Test: new MfaRecoveryController(stub auth, SIWE auth, escrow) - - Test->>Ctrl: register(recoverySecret, siweIdentifier) - Ctrl->>Escrow: escrow C2F (wrap/store secret) - Note over Ctrl,Auth: StubAuthProvider puts SRP token in AuthControllerToken.signature - - Test->>Ctrl: authenticateIdentifier(siweIdentifier) - Ctrl->>KC: signPersonalMessage (identifier proof) - KC-->>Ctrl: signature - Ctrl-->>Test: identifierSession - - Test->>Ctrl: getRecoverySecret(identifierSession) - Ctrl->>Escrow: getSecret (C2F unwrap) - Escrow-->>Ctrl: wrapped/recovered secret + epoch - Ctrl-->>Test: recovered - Test->>Test: matches = bytes equal? - Test-->>UI: { ensureUserStatus, epoch, matches } + participant Cubist as CubeSigner + + UI->>Mobile: run test + Mobile->>AuthAPI: SIWE login (JwtBearerAuth, primary account signs) + AuthAPI-->>Mobile: accessToken + Note over Mobile: StubAuthProvider: profileId = accessToken sub + Mobile->>Token: POST /token { user: profileId } (x-api-key) + Token-->>Mobile: minted JWT + Mobile->>Reg: POST /v1/recovery/registration/oidc-token (Bearer minted JWT) + Reg-->>Mobile: idToken, expiresAt + Mobile->>Cubist: createOidcSession(orgId, idToken, scopes) + Cubist-->>Mobile: session + Note over Mobile: new CubistEscrowProvider(client, wrap/receipt keys)
new MfaRecoveryController(StubAuthProvider,
SIWE + software passkey identifier auth,
[escrow], passthrough encryptor) ``` + +**Notes** + +- `POST /token` is the MPC service's development-only token route. It is not + mounted in production builds. +- `StubAuthProvider` and `passthroughEncryptor` come from `tests/stubs.ts`. + +## Write path: `#mutate` + +`register`, `updateRecoverySecret`, and `updateIdentifiers` share this path +(`#mutate` and `#replicateMutation` in `src/MfaRecoveryController.ts`). The +controller holds a lock for the whole mutation. Repair of an existing pending +mutation is `resume()` only (see +[Crash / repair](./ARCHITECTURE.md#crash--repair)). + +`register` and `updateIdentifiers` require at least two distinct identifiers +(`type + namespace + value`). `siwe` and `passkey` are both `key-bound`. + +```mermaid +sequenceDiagram + autonumber + participant Mobile as Mobile + participant C as MfaRecoveryController + participant Auth as authProvider (StubAuthProvider) + participant Id as identifierAuthProvider + participant Enc as pendingOperationEncryptor + participant Escrow as Cubist escrow + + Mobile->>C: register / updateRecoverySecret / updateIdentifiers + C->>C: withLock + C->>Enc: decrypt pendingOperation + alt pending exists + C-->>Mobile: throw pending_mutation + else no pending + Enc-->>C: null + end + C->>Auth: getAuthenticatedProfileId() + Auth-->>C: profileId + + Note over C: mutation id = randomId()
expectedVersion = epoch
newVersion = epoch + 1
audiences = ["cubist"]
payloadHash = hash(logical payload)
requestHash = hash(id, profileId, operation,
expectedVersion, newVersion, payloadHash, audiences) + + C->>Enc: encrypt phase authorizing + Note over Enc: payload is plaintext hex secret
plus identifiers, not wrapped yet + + alt register + C->>Auth: authorizeRecoveryRequest(requestHash, identifiers) + else updateRecoverySecret + C->>Auth: authorizeRecoveryRequest(requestHash, requireTwoFactor) + else updateIdentifiers + C->>Auth: authorizeRecoveryRequest(requestHash, requireTwoFactor, identifiers) + end + Note over Auth: mints JWT with ext claims:
requestHash, aal2 when requireTwoFactor,
identifiersHash when identifiers are present + Auth-->>C: AuthController JWT + + C->>Escrow: isAvailable() + alt userGet() fails + C-->>Mobile: throw escrow_unavailable (pending stays authorizing) + else available + Escrow-->>C: userGet() succeeds + end + + C->>C: decode JWT, re-authorize if exp <= now + + alt not register + C->>C: generateSigningKey() proof key + C->>Id: getKeyBoundIdentifierToken(identifier, proofPublicKey, requestHash) + Note over Id: SIWE: personal_sign of SIWE message
nonce = hash([proofPublicKey, requestHash])
Passkey: assertion, challenge = that same digest + Id-->>C: token + providerAssertion + C->>Escrow: generateChallenge() + Escrow-->>C: challenge id, expiresAt + C->>C: sign(proofPrivateKey, hash([token, challengeId, requestHash])) + opt challenge or signing fails + C-->>Mobile: throw identifier_auth_failed + end + end + + alt payload contains recoverySecret + C->>C: fresh ephemeral key, encrypt secret to escrow wrap public key + Note over C: apply body is { pkE, ciphertext }
plus identifiers on register + else updateIdentifiers + Note over C: apply body is { identifiers } only + end + + C->>Enc: encrypt phase writing, receipts [] + C->>Escrow: applyMutation(mutation, JWT, authorization or null, wrapped payload) + Note over Escrow: C2F cubist_secret_escrow
register sends identifierAuthorization null + alt applyMutation rejects + C->>Enc: encrypt writing, receipts [] + C-->>Mobile: throw incomplete_mutation (call resume) + else receipt returned + Escrow-->>C: receipt { mutationId, requestHash, escrowId, version, receiptKeyId, signature } + C->>C: verifyReceipt with pinned receipt public key + alt receipt invalid + C-->>Mobile: throw invalid_receipt (pending stays writing) + else valid + C->>Enc: encrypt writing with receipt + C->>C: clear pendingOperation (every escrow has a valid receipt) + end + end +``` + +| Operation | Epoch in | Payload hashed | Identifier proof | AuthController token | +| ---------------------- | ------------------------ | ------------------------ | ----------------------- | -------------------------------------------------- | +| `register` | `0` | identifiers + hex secret | none | `requestHash` + identifiers, no `requireTwoFactor` | +| `updateRecoverySecret` | epoch from the last read | hex secret only | the identifier argument | `requireTwoFactor` | +| `updateIdentifiers` | epoch from the last read | identifiers only | the identifier argument | `requireTwoFactor` + identifiers | + +**Notes** + +- `newVersion` must be `expectedVersion + 1`; the escrow rejects a mismatch. +- The secret is wrapped per escrow only at `applyMutation` + (`#payloadForEscrow`); pending state keeps the hex secret. In this harness + the encryptor is a JSON passthrough. +- Receipts are checked by `verifyMutationReceipt` (`src/escrow-utils.ts`), + which requires a matching `escrowId` and then calls the provider's + `verifyReceipt` (`src/escrow-providers/cubist-escrow-provider.ts`). +- Key-bound identifier proofs are built by `requestKeyBoundIdentifierToken` + and `authorizeEscrowsWithToken` (`src/identifier-auth.ts`). + +## Read path + +`getRecoverySecret` (`src/MfaRecoveryController.ts`) does not wait for or +repair a pending mutation. It takes the session from `authenticateIdentifier` +and checks that the session token is still bound to the same read hash. See +also [Read sequence](./ARCHITECTURE.md#read-sequence-getrecoverysecret). + +```mermaid +sequenceDiagram + autonumber + participant Mobile as Mobile + participant C as MfaRecoveryController + participant Id as identifierAuthProvider + participant Escrow as Cubist escrow + + Mobile->>C: authenticateIdentifier(identifier) + C->>C: requestId = randomId(), ephemeral key pkE + Note over C: requestHash = hash({ operation: getRecoverySecret, requestId, pkE }) + C->>C: generateSigningKey() proof key + C->>Id: getKeyBoundIdentifierToken(identifier, proofPublicKey, requestHash) + Id-->>C: key-bound token + C-->>Mobile: session { token, proofPrivateKey, requestId, ephemeralPrivateKey, pkE } + + Mobile->>C: getRecoverySecret(session) + C->>C: withLock, recompute requestHash + alt token.requestHash differs + C-->>Mobile: throw invalid_identifier_session + end + C->>Escrow: isAvailable() + alt unavailable + C-->>Mobile: throw no_available_escrow + end + C->>Escrow: generateChallenge() + Escrow-->>C: challenge + C->>C: sign(proofPrivateKey, hash([token, challengeId, requestHash])) + C->>Escrow: getSecret(key-bound authorization, requestId, pkE) + Escrow-->>C: ciphertext wrapped to pkE, version, lastMutationId, wrapKeyId + C->>C: wrapKeyId must match escrow wrap public key + C->>C: decryptFromPublic(ephemeralPrivateKey, wrapPublicKey, ciphertext) + C->>C: selectHighestConsistentVersion + alt no successful reply + C-->>Mobile: throw read_failed + else replies at highest version disagree + C-->>Mobile: throw replica_corruption + else ok + C-->>Mobile: { recoverySecret, epoch: version } + end +``` + +**Notes** + +- `selectHighestConsistentVersion` (`src/escrow-utils.ts`) keeps successful + escrow replies, takes the highest `version`, and requires every reply at + that version to share the same `lastMutationId` and secret bytes. +- Per-escrow failures (`generateChallenge` or `getSecret` rejecting, + `wrap_key_mismatch`, a failed decrypt) only drop that reply. With one escrow, any of them surfaces as + `read_failed`. +- Mobile compares the returned bytes to the secret it last wrote. From 3ca9dfb49eb3fb984287a5a7bbe88f427f3926f2 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:20:10 +0800 Subject: [PATCH 25/55] docs(mfa-recovery-controller): refine CUBIST_TEST_SEQUENCE.md for clarity and consistency - Updated the documentation to clarify the role of the client in the MFA recovery sequence, replacing references to "Mobile" with "Client." - Streamlined the sequence diagrams and descriptions to enhance readability and understanding of the integration test flow. - Adjusted notes regarding the setup process to reflect the client's responsibilities more accurately. --- .../CUBIST_TEST_SEQUENCE.md | 110 +++++++++--------- 1 file changed, 52 insertions(+), 58 deletions(-) diff --git a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md index 89efe69dad6..09c0f6c516d 100644 --- a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md +++ b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md @@ -1,11 +1,9 @@ -# Cubist MFA recovery integration test sequence +# Cubist MFA recovery sequence -Harness flow for `runMfaRecoveryCubistTest` in MetaMask Mobile (Developer -Options, Cubist gamma/beta/prod). Mobile drives one `MfaRecoveryController` -against a single Cubist escrow (`id: cubist`) through three mutations, reading -the secret back after each one. The general controller design is in -[ARCHITECTURE.md](./ARCHITECTURE.md); treat the TypeScript in `src/` as the -source of truth if a diagram drifts. +How a client drives one `MfaRecoveryController` against a single Cubist escrow +(`id: cubist`) through three mutations, reading the secret back after each one. +The general controller design is in [ARCHITECTURE.md](./ARCHITECTURE.md); treat +the TypeScript in `src/` as the source of truth if a diagram drifts. - Versions: `0 → 1` (register), `1 → 2` (update secret), `2 → 3` (update identifiers). @@ -22,70 +20,67 @@ Sections: ```mermaid sequenceDiagram autonumber - participant Mobile as Mobile (runMfaRecoveryCubistTest) + participant Client participant C as MfaRecoveryController participant Escrow as Cubist escrow - Note over Mobile: createRecoveryContext
SIWE login, OIDC token, CubeSigner session + Note over Client: SIWE login, OIDC token, CubeSigner session - Mobile->>C: register(secret, [siwe, passkey]) + Client->>C: register(secret, [siwe, passkey]) C->>Escrow: applyMutation register, expectedVersion 0, newVersion 1 Escrow-->>C: receipt version 1 - Mobile->>C: authenticateIdentifier(siwe) - Mobile->>C: getRecoverySecret(session) + Client->>C: authenticateIdentifier(siwe) + Client->>C: getRecoverySecret(session) C->>Escrow: getSecret - Escrow-->>Mobile: secret, epoch 1 + Escrow-->>Client: secret, epoch 1 - Mobile->>C: updateRecoverySecret(passkey, newSecret, epoch 1) + Client->>C: updateRecoverySecret(passkey, newSecret, epoch 1) C->>Escrow: applyMutation, newVersion 2 - Mobile->>C: authenticateIdentifier(passkey) - Mobile->>C: getRecoverySecret(session) - Escrow-->>Mobile: newSecret, epoch 2 + Client->>C: authenticateIdentifier(passkey) + Client->>C: getRecoverySecret(session) + Escrow-->>Client: newSecret, epoch 2 - Mobile->>C: updateIdentifiers(siwe, [siwe, replacementPasskey], epoch 2) + Client->>C: updateIdentifiers(siwe, [siwe, replacementPasskey], epoch 2) C->>Escrow: applyMutation, newVersion 3 - Mobile->>C: authenticateIdentifier(replacementPasskey) - Mobile->>C: getRecoverySecret(session) - Escrow-->>Mobile: same secret, epoch 3 + Client->>C: authenticateIdentifier(replacementPasskey) + Client->>C: getRecoverySecret(session) + Escrow-->>Client: same secret, epoch 3 ``` -Each step only runs if the previous read returned the expected secret. +Each mutation uses the epoch returned by the previous read. ## Setup (outside the controller) The controller does not call the Authentication API, the MPC token service, -the Recovery Registration API, or CubeSigner. Mobile does all of this before -constructing the controller (see +the Recovery Registration API, or CubeSigner. The client does all of this +before constructing the controller (see [Cubist store sequence](./ARCHITECTURE.md#cubist-store-sequence)). ```mermaid sequenceDiagram autonumber - participant UI as Developer Options - participant Mobile as Mobile (runMfaRecoveryCubistTest) + participant Client participant AuthAPI as Authentication API participant Token as MPC token service participant Reg as Recovery Registration API participant Cubist as CubeSigner - UI->>Mobile: run test - Mobile->>AuthAPI: SIWE login (JwtBearerAuth, primary account signs) - AuthAPI-->>Mobile: accessToken - Note over Mobile: StubAuthProvider: profileId = accessToken sub - Mobile->>Token: POST /token { user: profileId } (x-api-key) - Token-->>Mobile: minted JWT - Mobile->>Reg: POST /v1/recovery/registration/oidc-token (Bearer minted JWT) - Reg-->>Mobile: idToken, expiresAt - Mobile->>Cubist: createOidcSession(orgId, idToken, scopes) - Cubist-->>Mobile: session - Note over Mobile: new CubistEscrowProvider(client, wrap/receipt keys)
new MfaRecoveryController(StubAuthProvider,
SIWE + software passkey identifier auth,
[escrow], passthrough encryptor) + Client->>AuthAPI: SIWE login (JwtBearerAuth, primary account signs) + AuthAPI-->>Client: accessToken + Note over Client: profileId = accessToken sub + Client->>Token: POST /token { user: profileId } (x-api-key) + Token-->>Client: minted JWT + Client->>Reg: POST /v1/recovery/registration/oidc-token (Bearer minted JWT) + Reg-->>Client: idToken, expiresAt + Client->>Cubist: createOidcSession(orgId, idToken, scopes) + Cubist-->>Client: session + Note over Client: new CubistEscrowProvider(client, wrap/receipt keys)
new MfaRecoveryController(authProvider,
SIWE + passkey identifier auth,
[escrow], pendingOperationEncryptor) ``` **Notes** - `POST /token` is the MPC service's development-only token route. It is not mounted in production builds. -- `StubAuthProvider` and `passthroughEncryptor` come from `tests/stubs.ts`. ## Write path: `#mutate` @@ -101,18 +96,18 @@ mutation is `resume()` only (see ```mermaid sequenceDiagram autonumber - participant Mobile as Mobile + participant Client participant C as MfaRecoveryController - participant Auth as authProvider (StubAuthProvider) + participant Auth as authProvider participant Id as identifierAuthProvider participant Enc as pendingOperationEncryptor participant Escrow as Cubist escrow - Mobile->>C: register / updateRecoverySecret / updateIdentifiers + Client->>C: register / updateRecoverySecret / updateIdentifiers C->>C: withLock C->>Enc: decrypt pendingOperation alt pending exists - C-->>Mobile: throw pending_mutation + C-->>Client: throw pending_mutation else no pending Enc-->>C: null end @@ -136,7 +131,7 @@ sequenceDiagram C->>Escrow: isAvailable() alt userGet() fails - C-->>Mobile: throw escrow_unavailable (pending stays authorizing) + C-->>Client: throw escrow_unavailable (pending stays authorizing) else available Escrow-->>C: userGet() succeeds end @@ -152,7 +147,7 @@ sequenceDiagram Escrow-->>C: challenge id, expiresAt C->>C: sign(proofPrivateKey, hash([token, challengeId, requestHash])) opt challenge or signing fails - C-->>Mobile: throw identifier_auth_failed + C-->>Client: throw identifier_auth_failed end end @@ -168,12 +163,12 @@ sequenceDiagram Note over Escrow: C2F cubist_secret_escrow
register sends identifierAuthorization null alt applyMutation rejects C->>Enc: encrypt writing, receipts [] - C-->>Mobile: throw incomplete_mutation (call resume) + C-->>Client: throw incomplete_mutation (call resume) else receipt returned Escrow-->>C: receipt { mutationId, requestHash, escrowId, version, receiptKeyId, signature } C->>C: verifyReceipt with pinned receipt public key alt receipt invalid - C-->>Mobile: throw invalid_receipt (pending stays writing) + C-->>Client: throw invalid_receipt (pending stays writing) else valid C->>Enc: encrypt writing with receipt C->>C: clear pendingOperation (every escrow has a valid receipt) @@ -191,8 +186,8 @@ sequenceDiagram - `newVersion` must be `expectedVersion + 1`; the escrow rejects a mismatch. - The secret is wrapped per escrow only at `applyMutation` - (`#payloadForEscrow`); pending state keeps the hex secret. In this harness - the encryptor is a JSON passthrough. + (`#payloadForEscrow`); pending state keeps the hex secret, protected by + `pendingOperationEncryptor`. - Receipts are checked by `verifyMutationReceipt` (`src/escrow-utils.ts`), which requires a matching `escrowId` and then calls the provider's `verifyReceipt` (`src/escrow-providers/cubist-escrow-provider.ts`). @@ -209,27 +204,27 @@ also [Read sequence](./ARCHITECTURE.md#read-sequence-getrecoverysecret). ```mermaid sequenceDiagram autonumber - participant Mobile as Mobile + participant Client participant C as MfaRecoveryController participant Id as identifierAuthProvider participant Escrow as Cubist escrow - Mobile->>C: authenticateIdentifier(identifier) + Client->>C: authenticateIdentifier(identifier) C->>C: requestId = randomId(), ephemeral key pkE Note over C: requestHash = hash({ operation: getRecoverySecret, requestId, pkE }) C->>C: generateSigningKey() proof key C->>Id: getKeyBoundIdentifierToken(identifier, proofPublicKey, requestHash) Id-->>C: key-bound token - C-->>Mobile: session { token, proofPrivateKey, requestId, ephemeralPrivateKey, pkE } + C-->>Client: session { token, proofPrivateKey, requestId, ephemeralPrivateKey, pkE } - Mobile->>C: getRecoverySecret(session) + Client->>C: getRecoverySecret(session) C->>C: withLock, recompute requestHash alt token.requestHash differs - C-->>Mobile: throw invalid_identifier_session + C-->>Client: throw invalid_identifier_session end C->>Escrow: isAvailable() alt unavailable - C-->>Mobile: throw no_available_escrow + C-->>Client: throw no_available_escrow end C->>Escrow: generateChallenge() Escrow-->>C: challenge @@ -240,11 +235,11 @@ sequenceDiagram C->>C: decryptFromPublic(ephemeralPrivateKey, wrapPublicKey, ciphertext) C->>C: selectHighestConsistentVersion alt no successful reply - C-->>Mobile: throw read_failed + C-->>Client: throw read_failed else replies at highest version disagree - C-->>Mobile: throw replica_corruption + C-->>Client: throw replica_corruption else ok - C-->>Mobile: { recoverySecret, epoch: version } + C-->>Client: { recoverySecret, epoch: version } end ``` @@ -256,4 +251,3 @@ sequenceDiagram - Per-escrow failures (`generateChallenge` or `getSecret` rejecting, `wrap_key_mismatch`, a failed decrypt) only drop that reply. With one escrow, any of them surfaces as `read_failed`. -- Mobile compares the returned bytes to the secret it last wrote. From 8616570c874a54b35a47debb130e048ca93c2cbc Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:34:39 +0800 Subject: [PATCH 26/55] docs(mfa-recovery-controller): update CUBIST_TEST_SEQUENCE.md with new sequence diagrams and clarifications - Revised the documentation to clarify the client's role in the MFA recovery process, specifically updating references to the MPC token service. - Added new sequence diagrams illustrating the happy path and full path with errors for better understanding of the integration flow. - Enhanced notes regarding the use of development-only token routes for testing purposes. --- .../CUBIST_TEST_SEQUENCE.md | 57 ++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md index 09c0f6c516d..e4421e32331 100644 --- a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md +++ b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md @@ -51,8 +51,8 @@ Each mutation uses the epoch returned by the previous read. ## Setup (outside the controller) -The controller does not call the Authentication API, the MPC token service, -the Recovery Registration API, or CubeSigner. The client does all of this +The controller does not call the Authentication API, the Recovery +Registration API, or CubeSigner. The client does all of this before constructing the controller (see [Cubist store sequence](./ARCHITECTURE.md#cubist-store-sequence)). @@ -61,16 +61,13 @@ sequenceDiagram autonumber participant Client participant AuthAPI as Authentication API - participant Token as MPC token service participant Reg as Recovery Registration API participant Cubist as CubeSigner Client->>AuthAPI: SIWE login (JwtBearerAuth, primary account signs) AuthAPI-->>Client: accessToken Note over Client: profileId = accessToken sub - Client->>Token: POST /token { user: profileId } (x-api-key) - Token-->>Client: minted JWT - Client->>Reg: POST /v1/recovery/registration/oidc-token (Bearer minted JWT) + Client->>Reg: POST /v1/recovery/registration/oidc-token (Bearer accessToken) Reg-->>Client: idToken, expiresAt Client->>Cubist: createOidcSession(orgId, idToken, scopes) Cubist-->>Client: session @@ -79,8 +76,10 @@ sequenceDiagram **Notes** -- `POST /token` is the MPC service's development-only token route. It is not - mounted in production builds. +- For testing, the Bearer `accessToken` is replaced with a JWT minted by the + MPC service's development-only `POST /token` endpoint + (`{ user: profileId }`, `x-api-key`). That route is not mounted in + production builds. ## Write path: `#mutate` @@ -93,6 +92,48 @@ mutation is `resume()` only (see `register` and `updateIdentifiers` require at least two distinct identifiers (`type + namespace + value`). `siwe` and `passkey` are both `key-bound`. +### Happy path + +```mermaid +sequenceDiagram + autonumber + participant Client + participant C as MfaRecoveryController + participant Auth as authProvider + participant Id as identifierAuthProvider + participant Enc as pendingOperationEncryptor + participant Escrow as Cubist escrow + + Client->>C: register / updateRecoverySecret / updateIdentifiers + C->>C: withLock, no pendingOperation + C->>Auth: getAuthenticatedProfileId() + Auth-->>C: profileId + Note over C: build mutation and requestHash
(expectedVersion = epoch, newVersion = epoch + 1) + C->>Enc: encrypt phase authorizing + C->>Auth: authorizeRecoveryRequest(requestHash, ...) + Auth-->>C: AuthController JWT + C->>Escrow: isAvailable() + Escrow-->>C: available + + opt not register + C->>Id: getKeyBoundIdentifierToken(identifier, proofPublicKey, requestHash) + Id-->>C: key-bound token + C->>Escrow: generateChallenge() + Escrow-->>C: challenge + C->>C: sign proof over token, challengeId, requestHash + end + + C->>C: wrap secret to escrow wrap public key (if present) + C->>Enc: encrypt phase writing + C->>Escrow: applyMutation(mutation, JWT, authorization, payload) + Escrow-->>C: signed receipt + C->>C: verifyReceipt + C->>C: clear pendingOperation + C-->>Client: done +``` + +### Full path with errors + ```mermaid sequenceDiagram autonumber From de9ac36e8cf1b2691cf3b2e957109661f8e580bb Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:20:52 +0800 Subject: [PATCH 27/55] fix(mfa-recovery-controller): sort identifiers by UTF-8 bytes Match the escrow's Rust string order so identifiersHash is accepted for mixed-case credential ids. --- .../src/crypto.test.ts | 41 +++++++++++++++++++ .../mfa-recovery-controller/src/crypto.ts | 26 +++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/mfa-recovery-controller/src/crypto.test.ts b/packages/mfa-recovery-controller/src/crypto.test.ts index 408d186396a..e71997205bd 100644 --- a/packages/mfa-recovery-controller/src/crypto.test.ts +++ b/packages/mfa-recovery-controller/src/crypto.test.ts @@ -73,6 +73,47 @@ describe('crypto', () => { ); }); + describe('identifier sort order', () => { + // The escrow sorts by UTF-8 byte order (Rust `String` ordering). Any other + // order changes `identifiersHash` and the escrow rejects the mutation. + const identifier = (value: string): Identifier => ({ + type: 'passkey', + namespace: 'metamask.io', + value, + verifier: null, + }); + const sortedValues = (values: string[]): string[] => + ( + canonicalizeIdentifiers(values.map(identifier)) as { value: string }[] + ).map((item) => item.value); + + it('puts uppercase before lowercase, unlike locale order', () => { + // base64url credential ids are mixed case; localeCompare would put + // `a2V5LTE` first. + expect(sortedValues(['a2V5LTE', 'Zm9vLTI'])).toStrictEqual([ + 'Zm9vLTI', + 'a2V5LTE', + ]); + expect(sortedValues(['Zm9vLTI', 'a2V5LTE'])).toStrictEqual([ + 'Zm9vLTI', + 'a2V5LTE', + ]); + }); + + it('orders by UTF-8 bytes rather than UTF-16 code units', () => { + // U+FF21 is EF BC A1 in UTF-8 and U+1F600 is F0 9F 98 80, so U+FF21 + // sorts first. In UTF-16 U+1F600 (D83D DE00) would sort first. + expect(sortedValues(['\u{1F600}', '\uFF21'])).toStrictEqual([ + '\uFF21', + '\u{1F600}', + ]); + }); + + it('orders a shorter identifier before its extension', () => { + expect(sortedValues(['abc', 'ab'])).toStrictEqual(['ab', 'abc']); + }); + }); + it('round-trips wrap encryption to a wrap public key', () => { const wrapKey = generateSigningKey(); const ephemeral = generateSigningKey(); diff --git a/packages/mfa-recovery-controller/src/crypto.ts b/packages/mfa-recovery-controller/src/crypto.ts index a8c190e06cb..8fabac0ba49 100644 --- a/packages/mfa-recovery-controller/src/crypto.ts +++ b/packages/mfa-recovery-controller/src/crypto.ts @@ -38,11 +38,33 @@ export function hash(value: unknown): Hex { return bytesToHex(sha256Sync(stringToBytes(canonicalize(value)))); } +/** + * Orders two strings by their UTF-8 bytes. The escrow sorts identifiers the same + * way (Rust `String` ordering), so this must not be locale or UTF-16 aware. + * + * @param left - First string. + * @param right - Second string. + * @returns Negative, zero, or positive like `Array.prototype.sort` expects. + */ +function compareUtf8(left: string, right: string): number { + const leftBytes = stringToBytes(left); + const rightBytes = stringToBytes(right); + const shared = Math.min(leftBytes.length, rightBytes.length); + for (let index = 0; index < shared; index += 1) { + const difference = (leftBytes[index] ?? 0) - (rightBytes[index] ?? 0); + if (difference !== 0) { + return difference; + } + } + return leftBytes.length - rightBytes.length; +} + /** * Canonical identifier list bound into AuthController `identifiersHash`. * * @param identifiers - Identifier set. - * @returns Sorted identifier records including verifier material. + * @returns Identifier records including verifier material, sorted by the UTF-8 + * bytes of their canonical JSON. */ export function canonicalizeIdentifiers(identifiers: Identifier[]): unknown { return [...identifiers] @@ -53,7 +75,7 @@ export function canonicalizeIdentifiers(identifiers: Identifier[]): unknown { verifier: identifier.verifier, })) .sort((left, right) => - canonicalize(left).localeCompare(canonicalize(right)), + compareUtf8(canonicalize(left), canonicalize(right)), ); } From 8e94755ce7b72b745e1bfacb9a79ae0f0554350e Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:13:43 +0800 Subject: [PATCH 28/55] feat(mfa-recovery-controller): request AuthController tokens for escrow audiences --- .../src/MfaRecoveryController.test.ts | 84 +++++++++---------- .../src/MfaRecoveryController.ts | 1 + .../cubist-escrow-provider.test.ts | 8 +- .../cubist-escrow-provider.ts | 3 + packages/mfa-recovery-controller/src/types.ts | 6 ++ .../mfa-recovery-controller/tests/stubs.ts | 79 ++++++++++++----- 6 files changed, 110 insertions(+), 71 deletions(-) diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 564166a91ef..e2d121d87cd 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -11,6 +11,7 @@ import { StubAuthProvider, StubEscrowProvider, StubIdentifierAuthProvider, + createAuthControllerToken, passthroughEncryptor, } from '../tests/stubs.js'; import { @@ -915,14 +916,12 @@ describe('MfaRecoveryController', () => { const writing = await passthroughEncryptor.encrypt({ phase: 'writing', mutation, - authControllerToken: { - profileId: 'profile-1', + authControllerToken: createAuthControllerToken({ + sub: 'profile-1', + exp: unixNow() + 60, requestHash: mutation.requestHash, - twoFactor: true, - issuer: 'stub-auth', - expiresAt: unixNow() + 60, - signature: 'stub-auth-signature', - }, + aal: 2, + }), payload, identifier: PASSKEY, receipts, @@ -983,13 +982,11 @@ describe('MfaRecoveryController', () => { audiences: ['escrow-a', 'escrow-b'], requestHash: '0x', }, - authControllerToken: { - profileId: 'profile-1', + authControllerToken: createAuthControllerToken({ + sub: 'profile-1', + exp: unixNow() + 60, requestHash: '0x', - issuer: 'stub-auth', - expiresAt: unixNow() + 60, - signature: 'stub-auth-signature', - }, + }), payload: { recoverySecret: bytesToHex(SECRET), identifiers: IDENTIFIERS, @@ -1037,15 +1034,12 @@ describe('MfaRecoveryController', () => { const writing = await passthroughEncryptor.encrypt({ phase: 'writing', mutation, - authControllerToken: { - profileId: 'profile-1', + authControllerToken: createAuthControllerToken({ + sub: 'profile-1', + exp: unixNow() + 60, requestHash: mutation.requestHash, identifiersHash: hash(canonicalizeIdentifiers(payload.identifiers)), - identifierOwnershipApproved: true, - issuer: 'stub-auth', - expiresAt: unixNow() + 60, - signature: 'stub-auth-signature', - }, + }), payload, identifier: null, receipts: [ @@ -1100,15 +1094,12 @@ describe('MfaRecoveryController', () => { const writing = await passthroughEncryptor.encrypt({ phase: 'writing', mutation, - authControllerToken: { - profileId: 'profile-1', + authControllerToken: createAuthControllerToken({ + sub: 'profile-1', + exp: unixNow() + 60, requestHash: mutation.requestHash, identifiersHash: hash(canonicalizeIdentifiers(payload.identifiers)), - identifierOwnershipApproved: true, - issuer: 'stub-auth', - expiresAt: unixNow() + 60, - signature: 'stub-auth-signature', - }, + }), payload, identifier: null, receipts: [ @@ -1204,8 +1195,13 @@ describe('MfaRecoveryController', () => { ).rejects.toBeInstanceOf(IncompleteMutationError); const pending = JSON.parse( controller.state.pendingOperation as string, - ) as { authControllerToken: { expiresAt: number } }; - pending.authControllerToken.expiresAt = 0; + ) as { mutation: { requestHash: string }; authControllerToken: string }; + pending.authControllerToken = createAuthControllerToken({ + sub: 'profile-1', + exp: 0, + requestHash: pending.mutation.requestHash, + aal: 2, + }); const resumed = new MfaRecoveryController({ ...options, messenger: getMessenger(getRootMessenger()), @@ -1306,10 +1302,12 @@ describe('MfaRecoveryController', () => { }; pending.mutation = { ...fields, requestHash: hash(fields) }; pending.payload = payload; - const token = pending.authControllerToken as Record; - token.requestHash = hash(fields); - token.identifierOwnershipApproved = true; - token.identifiersHash = hash(payload.identifiers); + pending.authControllerToken = createAuthControllerToken({ + sub: 'profile-1', + exp: unixNow() + 60, + requestHash: hash(fields), + identifiersHash: hash(payload.identifiers), + }); }, }, { @@ -1387,8 +1385,12 @@ describe('MfaRecoveryController', () => { { name: 'an invalid AuthController token', mutate: (pending: Record): void => { - const token = pending.authControllerToken as Record; - token.requestHash = '0xwrong-token-request-hash'; + pending.authControllerToken = createAuthControllerToken({ + sub: 'profile-1', + exp: unixNow() + 60, + requestHash: '0xwrong-token-request-hash', + aal: 2, + }); }, }, { @@ -1564,14 +1566,12 @@ async function getValidWritingPending( ...fields, requestHash: hash(fields), }, - authControllerToken: { - profileId: 'profile-1', + authControllerToken: createAuthControllerToken({ + sub: 'profile-1', + exp: unixNow() + 60, requestHash: hash(fields), - twoFactor: true, - issuer: 'stub-auth', - expiresAt: unixNow() + 60, - signature: 'stub-auth-signature', - }, + aal: 2, + }), payload, identifier: PASSKEY, receipts: [], diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 3bd15751645..1ba38ff64ec 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -507,6 +507,7 @@ export class MfaRecoveryController extends BaseController< ): Promise { return await this.#authProvider.authorizeRecoveryRequest({ requestHash: mutation.requestHash, + audiences: this.#escrows.map((escrow) => escrow.authAudience), ...(mutation.operation === 'register' ? {} : { requireTwoFactor: true }), ...(pendingPayloadHasIdentifiers(payload) ? { identifiers: payload.identifiers } 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 f9002800eae..5c972cb6256 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 @@ -31,13 +31,7 @@ const MUTATION: Mutation = { requestHash: '0xdef', }; -const TOKEN: AuthControllerToken = { - profileId: 'profile-1', - requestHash: '0xdef', - issuer: 'auth', - expiresAt: 1_800_000_000, - signature: '00', -}; +const TOKEN: AuthControllerToken = 'header.payload.signature'; const PAYLOAD: MutationPayload = { identifiers: [], 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 c2aca895d8f..a2341622b74 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 @@ -25,6 +25,7 @@ import type { } from '../types.js'; const ESCROW_ID = 'cubist'; +const AUTH_AUDIENCE = 'cubist'; const DEFAULT_FUNCTION_ID = 'cubist_secret_escrow'; const DEFAULT_VERSION: Version = 'latest'; @@ -61,6 +62,8 @@ export type CubistEscrowProviderOptions = { export class CubistEscrowProvider implements RecoveryEscrowProvider { readonly id = ESCROW_ID; + readonly authAudience = AUTH_AUDIENCE; + readonly wrapPublicKey: string; readonly #receiptPublicKey: string; diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index ecda69de39a..d394dfc9bbb 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -239,6 +239,7 @@ export type RecoveryAuthProvider = { getAuthenticatedProfileId: () => Promise; authorizeRecoveryRequest: (params: { requestHash: string; + audiences: string[]; requireTwoFactor?: boolean; identifiers?: Identifier[]; }) => Promise; @@ -260,6 +261,11 @@ export type RecoveryIdentifierAuthProvider = { */ export type RecoveryEscrowProvider = { readonly id: string; + /** + * Audience the AuthController token must carry for this escrow to accept it. + * Independent of `id`. + */ + readonly authAudience: string; /** * P-256 wrap public JWK JSON. Mutation payloads are encrypted to this key; * `getSecret` responses are encrypted to the request's ephemeral key using diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index ba61b4f2634..e3600b33373 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -1,5 +1,6 @@ -import { bytesToHex } from '@metamask/utils'; +import { bytesToBase64, bytesToHex, stringToBytes } from '@metamask/utils'; +import { decodeAuthControllerToken } from '../src/auth-controller-token.js'; import { canonicalizeIdentifiers, decodeHex, @@ -53,6 +54,44 @@ type StoredPoPChallenge = PoPChallenge & { consumed: boolean }; const CHALLENGE_TTL_SECS = 5 * 60; +/** + * Builds an unsigned compact JWT carrying the claims the controller reads from + * an AuthController (Hydra) access token. + * + * @param claims - Token claims. + * @param claims.sub - The profile id. + * @param claims.exp - Unix time in seconds. + * @param claims.requestHash - Hash of the request the token is bound to. + * @param claims.aal - Authentication assurance level. + * @param claims.identifiersHash - Hash of the approved identifier set. + * @returns The compact JWT. + */ +export function createAuthControllerToken({ + sub, + exp, + requestHash, + aal, + identifiersHash, +}: { + sub: string; + exp: number; + requestHash: string; + aal?: number; + identifiersHash?: string; +}): AuthControllerToken { + const encode = (value: unknown): string => + bytesToBase64(stringToBytes(JSON.stringify(value))) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); + const payload = { + sub, + exp, + ext: { aal, request_hash: requestHash, identifiers_hash: identifiersHash }, + }; + return `${encode({ alg: 'none' })}.${encode(payload)}.stub-signature`; +} + /** * In-memory AuthController used in tests. */ @@ -70,24 +109,17 @@ export class StubAuthProvider implements RecoveryAuthProvider { requireTwoFactor?: boolean; identifiers?: Identifier[]; }): Promise { - const identifiersHash = - params.identifiers === undefined - ? undefined - : hash(canonicalizeIdentifiers(params.identifiers)); - return { - profileId: this.profileId, + return createAuthControllerToken({ + sub: this.profileId, + exp: this.now() + 60 * 60, requestHash: params.requestHash, - ...(params.requireTwoFactor ? { twoFactor: true as const } : {}), - ...(identifiersHash === undefined + aal: params.requireTwoFactor ? 2 : 1, + ...(params.identifiers === undefined ? {} : { - identifiersHash, - identifierOwnershipApproved: true as const, + identifiersHash: hash(canonicalizeIdentifiers(params.identifiers)), }), - issuer: 'stub-auth', - expiresAt: this.now() + 60 * 60, - signature: 'stub-auth-signature', - }; + }); } } @@ -136,6 +168,8 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { readonly id: string; + readonly authAudience = 'stub-audience'; + readonly wrapPublicKey: string; /** @@ -317,7 +351,8 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { return await this.#signReceipt(mutation); } - if (record === undefined || authControllerToken.twoFactor !== true) { + const claims = decodeAuthControllerToken(authControllerToken); + if (record === undefined || (claims?.ext.aal ?? 0) < 2) { throw new MfaRecoveryError( 'Update not authorized', 'update_unauthorized', @@ -462,12 +497,12 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { payloadHash: mutation.payloadHash, audiences: mutation.audiences, }); + const claims = decodeAuthControllerToken(token); if ( requestHash !== mutation.requestHash || - token.requestHash !== mutation.requestHash || - token.profileId !== mutation.profileId || - token.signature !== 'stub-auth-signature' || - token.expiresAt <= this.now() || + claims?.ext.requestHash !== mutation.requestHash || + claims.sub !== mutation.profileId || + claims.exp <= this.now() || !this.#hasExactAudiences(mutation.audiences) ) { throw new MfaRecoveryError( @@ -489,8 +524,8 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { identifiers: Identifier[], ): Promise { if ( - token.identifierOwnershipApproved !== true || - token.identifiersHash !== hash(canonicalizeIdentifiers(identifiers)) + decodeAuthControllerToken(token)?.ext.identifiersHash !== + hash(canonicalizeIdentifiers(identifiers)) ) { throw new MfaRecoveryError( 'Identifier ownership not approved', From 91712a148642f95d998f5b1105dba0cb40c6c736 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:51:36 +0800 Subject: [PATCH 29/55] docs(mfa-recovery-controller): refine CUBIST_TEST_SEQUENCE.md for mobile developer test clarity - Updated the documentation to clarify the mobile developer's role in the MFA recovery process, emphasizing the use of `runMfaRecoveryCubistTest`. - Enhanced sequence diagrams to reflect the interaction between the mobile client and the MfaRecoveryController, including detailed mutation steps. - Streamlined the setup and write path sections for better readability and understanding of the integration flow. --- .../CUBIST_TEST_SEQUENCE.md | 371 +++++++----------- 1 file changed, 136 insertions(+), 235 deletions(-) diff --git a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md index e4421e32331..ebd6bee5f45 100644 --- a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md +++ b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md @@ -1,294 +1,195 @@ # Cubist MFA recovery sequence -How a client drives one `MfaRecoveryController` against a single Cubist escrow -(`id: cubist`) through three mutations, reading the secret back after each one. -The general controller design is in [ARCHITECTURE.md](./ARCHITECTURE.md); treat -the TypeScript in `src/` as the source of truth if a diagram drifts. +How the mobile developer test (`runMfaRecoveryCubistTest` in +`metamask-mobile`) drives one `MfaRecoveryController` against a single Cubist +escrow (`id: cubist`). It runs three mutations and reads the secret back after +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. -- Versions: `0 → 1` (register), `1 → 2` (update secret), `2 → 3` (update - identifiers). -- Pending state: `idle → authorizing → writing → idle` for every mutation. +| 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` | -Sections: - -- [Setup — outside the controller](#setup-outside-the-controller) -- [Write path — `register`, `updateRecoverySecret`, `updateIdentifiers`](#write-path-mutate) -- [Read path — `authenticateIdentifier`, `getRecoverySecret`](#read-path) +Each update passes the `epoch` returned by the previous read. ## Overview ```mermaid sequenceDiagram autonumber - participant Client + participant Mobile as Mobile client participant C as MfaRecoveryController - participant Escrow as Cubist escrow - - Note over Client: SIWE login, OIDC token, CubeSigner session - - Client->>C: register(secret, [siwe, passkey]) - C->>Escrow: applyMutation register, expectedVersion 0, newVersion 1 - Escrow-->>C: receipt version 1 - Client->>C: authenticateIdentifier(siwe) - Client->>C: getRecoverySecret(session) - C->>Escrow: getSecret - Escrow-->>Client: secret, epoch 1 - - Client->>C: updateRecoverySecret(passkey, newSecret, epoch 1) - C->>Escrow: applyMutation, newVersion 2 - Client->>C: authenticateIdentifier(passkey) - Client->>C: getRecoverySecret(session) - Escrow-->>Client: newSecret, epoch 2 - - Client->>C: updateIdentifiers(siwe, [siwe, replacementPasskey], epoch 2) - C->>Escrow: applyMutation, newVersion 3 - Client->>C: authenticateIdentifier(replacementPasskey) - Client->>C: getRecoverySecret(session) - Escrow-->>Client: same secret, epoch 3 + 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 ``` -Each mutation uses the epoch returned by the previous read. - -## Setup (outside the controller) +## Setup -The controller does not call the Authentication API, the Recovery -Registration API, or CubeSigner. The client does all of this -before constructing the controller (see -[Cubist store sequence](./ARCHITECTURE.md#cubist-store-sequence)). +The mobile client does this before it creates the controller. The controller +never calls these services itself. ```mermaid sequenceDiagram autonumber - participant Client - participant AuthAPI as Authentication API - participant Reg as Recovery Registration API - participant Cubist as CubeSigner - - Client->>AuthAPI: SIWE login (JwtBearerAuth, primary account signs) - AuthAPI-->>Client: accessToken - Note over Client: profileId = accessToken sub - Client->>Reg: POST /v1/recovery/registration/oidc-token (Bearer accessToken) - Reg-->>Client: idToken, expiresAt - Client->>Cubist: createOidcSession(orgId, idToken, scopes) - Cubist-->>Client: session - Note over Client: new CubistEscrowProvider(client, wrap/receipt keys)
new MfaRecoveryController(authProvider,
SIWE + passkey identifier auth,
[escrow], pendingOperationEncryptor) + participant Mobile as Mobile client + participant Auth as authProvider + participant Id as identifierAuthProvider + participant C as MfaRecoveryController + participant Cubist as Cubist escrow + + Note over Mobile: SIWE sign-in with the primary account → accessToken + Mobile->>Auth: new StubAuthProvider(accessToken, apiKey) + Mobile->>Auth: getAccessToken() + Auth-->>Mobile: dev JWT (POST /token) + Note over Mobile: Recovery Registration API exchanges that token → OIDC idToken + Mobile->>Cubist: createOidcSession(orgId, idToken, scopes) + Cubist-->>Mobile: CubeSigner session + Note over Mobile: new CubistEscrowProvider(session, wrap key, receipt key) + Mobile->>Id: new TestIdentifierAuthProvider(address, signPersonalMessage) + Mobile->>C: new MfaRecoveryController(authProvider, identifierAuthProvider, [escrow]) ``` -**Notes** - -- For testing, the Bearer `accessToken` is replaced with a JWT minted by the - MPC service's development-only `POST /token` endpoint - (`{ user: profileId }`, `x-api-key`). That route is not mounted in +- `authProvider` (`StubAuthProvider` in mobile) takes the profile ID from the + access token and mints request-bound JWTs from the MPC service's + development-only `POST /token` endpoint. That route is not mounted in production builds. +- `identifierAuthProvider` (`TestIdentifierAuthProvider` in mobile) signs SIWE + proofs with the primary account and passkey proofs with a software passkey. -## Write path: `#mutate` - -`register`, `updateRecoverySecret`, and `updateIdentifiers` share this path -(`#mutate` and `#replicateMutation` in `src/MfaRecoveryController.ts`). The -controller holds a lock for the whole mutation. Repair of an existing pending -mutation is `resume()` only (see -[Crash / repair](./ARCHITECTURE.md#crash--repair)). +## Write: `register`, `updateRecoverySecret`, `updateIdentifiers` -`register` and `updateIdentifiers` require at least two distinct identifiers -(`type + namespace + value`). `siwe` and `passkey` are both `key-bound`. - -### Happy path +All three mutations share one path. ```mermaid sequenceDiagram autonumber - participant Client + participant Mobile as Mobile client participant C as MfaRecoveryController participant Auth as authProvider participant Id as identifierAuthProvider - participant Enc as pendingOperationEncryptor - participant Escrow as Cubist escrow + participant Cubist as Cubist escrow - Client->>C: register / updateRecoverySecret / updateIdentifiers - C->>C: withLock, no pendingOperation + Mobile->>C: register / updateRecoverySecret / updateIdentifiers C->>Auth: getAuthenticatedProfileId() Auth-->>C: profileId - Note over C: build mutation and requestHash
(expectedVersion = epoch, newVersion = epoch + 1) - C->>Enc: encrypt phase authorizing + Note over C: Build the mutation (version epoch → epoch + 1)
and its requestHash C->>Auth: authorizeRecoveryRequest(requestHash, ...) - Auth-->>C: AuthController JWT - C->>Escrow: isAvailable() - Escrow-->>C: available - - opt not register - C->>Id: getKeyBoundIdentifierToken(identifier, proofPublicKey, requestHash) - Id-->>C: key-bound token - C->>Escrow: generateChallenge() - Escrow-->>C: challenge - C->>C: sign proof over token, challengeId, requestHash + Auth-->>C: JWT bound to requestHash + C->>Cubist: isAvailable + + opt updates only (not register) + Note over C,Cubist: Proof of possession for requestHash (see below) end - C->>C: wrap secret to escrow wrap public key (if present) - C->>Enc: encrypt phase writing - C->>Escrow: applyMutation(mutation, JWT, authorization, payload) - Escrow-->>C: signed receipt - C->>C: verifyReceipt - C->>C: clear pendingOperation - C-->>Client: done + Note over C: Encrypt the secret to the Cubist wrap key + C->>Cubist: applyMutation(mutation, JWT, { token, proof } or null, payload) + Cubist-->>C: signed receipt + Note over C: Verify the receipt with the pinned receipt key + C-->>Mobile: done ``` -### Full path with errors +| Operation | Identifier proof | JWT requires | Payload | +| ---------------------- | ----------------------- | ---------------- | -------------------- | +| `register` | none | identifiers | identifiers + secret | +| `updateRecoverySecret` | the identifier argument | 2FA | secret | +| `updateIdentifiers` | the identifier argument | 2FA, identifiers | identifiers | + +If `applyMutation` fails, the controller keeps the pending mutation and throws +`incomplete_mutation`; call `resume()` to finish it. + +## 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 Client participant C as MfaRecoveryController - participant Auth as authProvider participant Id as identifierAuthProvider - participant Enc as pendingOperationEncryptor - participant Escrow as Cubist escrow - - Client->>C: register / updateRecoverySecret / updateIdentifiers - C->>C: withLock - C->>Enc: decrypt pendingOperation - alt pending exists - C-->>Client: throw pending_mutation - else no pending - Enc-->>C: null - end - C->>Auth: getAuthenticatedProfileId() - Auth-->>C: profileId - - Note over C: mutation id = randomId()
expectedVersion = epoch
newVersion = epoch + 1
audiences = ["cubist"]
payloadHash = hash(logical payload)
requestHash = hash(id, profileId, operation,
expectedVersion, newVersion, payloadHash, audiences) + participant Cubist as Cubist escrow - C->>Enc: encrypt phase authorizing - Note over Enc: payload is plaintext hex secret
plus identifiers, not wrapped yet - - alt register - C->>Auth: authorizeRecoveryRequest(requestHash, identifiers) - else updateRecoverySecret - C->>Auth: authorizeRecoveryRequest(requestHash, requireTwoFactor) - else updateIdentifiers - C->>Auth: authorizeRecoveryRequest(requestHash, requireTwoFactor, identifiers) - end - Note over Auth: mints JWT with ext claims:
requestHash, aal2 when requireTwoFactor,
identifiersHash when identifiers are present - Auth-->>C: AuthController JWT - - C->>Escrow: isAvailable() - alt userGet() fails - C-->>Client: throw escrow_unavailable (pending stays authorizing) - else available - Escrow-->>C: userGet() succeeds - end - - C->>C: decode JWT, re-authorize if exp <= now - - alt not register - C->>C: generateSigningKey() proof key - C->>Id: getKeyBoundIdentifierToken(identifier, proofPublicKey, requestHash) - Note over Id: SIWE: personal_sign of SIWE message
nonce = hash([proofPublicKey, requestHash])
Passkey: assertion, challenge = that same digest - Id-->>C: token + providerAssertion - C->>Escrow: generateChallenge() - Escrow-->>C: challenge id, expiresAt - C->>C: sign(proofPrivateKey, hash([token, challengeId, requestHash])) - opt challenge or signing fails - C-->>Client: throw identifier_auth_failed - end - end - - alt payload contains recoverySecret - C->>C: fresh ephemeral key, encrypt secret to escrow wrap public key - Note over C: apply body is { pkE, ciphertext }
plus identifiers on register - else updateIdentifiers - Note over C: apply body is { identifiers } only + rect rgba(128, 128, 128, 0.1) + Note over C,Id: Part 1: proof key + identifier token + Note over C: generateSigningKey() → P-256 proof key pair + C->>Id: getKeyBoundIdentifierToken(identifier, proofPublicKey, requestHash) + Note over Id: binding = hash([proofPublicKey, requestHash])
SIWE: personal_sign a SIWE message, Nonce = binding
Passkey: WebAuthn assertion, challenge = binding + Id-->>C: token { identifier, proofPublicKey, requestHash, providerAssertion } end - C->>Enc: encrypt phase writing, receipts [] - C->>Escrow: applyMutation(mutation, JWT, authorization or null, wrapped payload) - Note over Escrow: C2F cubist_secret_escrow
register sends identifierAuthorization null - alt applyMutation rejects - C->>Enc: encrypt writing, receipts [] - C-->>Client: throw incomplete_mutation (call resume) - else receipt returned - Escrow-->>C: receipt { mutationId, requestHash, escrowId, version, receiptKeyId, signature } - C->>C: verifyReceipt with pinned receipt public key - alt receipt invalid - C-->>Client: throw invalid_receipt (pending stays writing) - else valid - C->>Enc: encrypt writing with receipt - C->>C: clear pendingOperation (every escrow has a valid receipt) - end + 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,
hash([token, challenge.id, requestHash])) + Note over C: authorization = { kind: key-bound, token,
proof: { challengeId, requestHash, signature } } + C->>Cubist: applyMutation or getSecret(authorization, ...) + Note over Cubist: providerAssertion is valid for the identifier
and binds proofPublicKey + requestHash
identifier is registered, challenge id is its own
signature verifies under proofPublicKey end ``` -| Operation | Epoch in | Payload hashed | Identifier proof | AuthController token | -| ---------------------- | ------------------------ | ------------------------ | ----------------------- | -------------------------------------------------- | -| `register` | `0` | identifiers + hex secret | none | `requestHash` + identifiers, no `requireTwoFactor` | -| `updateRecoverySecret` | epoch from the last read | hex secret only | the identifier argument | `requireTwoFactor` | -| `updateIdentifiers` | epoch from the last read | identifiers only | the identifier argument | `requireTwoFactor` + identifiers | - -**Notes** - -- `newVersion` must be `expectedVersion + 1`; the escrow rejects a mismatch. -- The secret is wrapped per escrow only at `applyMutation` - (`#payloadForEscrow`); pending state keeps the hex secret, protected by - `pendingOperationEncryptor`. -- Receipts are checked by `verifyMutationReceipt` (`src/escrow-utils.ts`), - which requires a matching `escrowId` and then calls the provider's - `verifyReceipt` (`src/escrow-providers/cubist-escrow-provider.ts`). -- Key-bound identifier proofs are built by `requestKeyBoundIdentifierToken` - and `authorizeEscrowsWithToken` (`src/identifier-auth.ts`). +- 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. -## Read path - -`getRecoverySecret` (`src/MfaRecoveryController.ts`) does not wait for or -repair a pending mutation. It takes the session from `authenticateIdentifier` -and checks that the session token is still bound to the same read hash. See -also [Read sequence](./ARCHITECTURE.md#read-sequence-getrecoverysecret). +## Read: `authenticateIdentifier`, `getRecoverySecret` ```mermaid sequenceDiagram autonumber - participant Client + participant Mobile as Mobile client participant C as MfaRecoveryController participant Id as identifierAuthProvider - participant Escrow as Cubist escrow - - Client->>C: authenticateIdentifier(identifier) - C->>C: requestId = randomId(), ephemeral key pkE - Note over C: requestHash = hash({ operation: getRecoverySecret, requestId, pkE }) - C->>C: generateSigningKey() proof key - C->>Id: getKeyBoundIdentifierToken(identifier, proofPublicKey, requestHash) - Id-->>C: key-bound token - C-->>Client: session { token, proofPrivateKey, requestId, ephemeralPrivateKey, pkE } - - Client->>C: getRecoverySecret(session) - C->>C: withLock, recompute requestHash - alt token.requestHash differs - C-->>Client: throw invalid_identifier_session - end - C->>Escrow: isAvailable() - alt unavailable - C-->>Client: throw no_available_escrow - end - C->>Escrow: generateChallenge() - Escrow-->>C: challenge - C->>C: sign(proofPrivateKey, hash([token, challengeId, requestHash])) - C->>Escrow: getSecret(key-bound authorization, requestId, pkE) - Escrow-->>C: ciphertext wrapped to pkE, version, lastMutationId, wrapKeyId - C->>C: wrapKeyId must match escrow wrap public key - C->>C: decryptFromPublic(ephemeralPrivateKey, wrapPublicKey, ciphertext) - C->>C: selectHighestConsistentVersion - alt no successful reply - C-->>Client: throw read_failed - else replies at highest version disagree - C-->>Client: throw replica_corruption - else ok - C-->>Client: { recoverySecret, epoch: version } - end + participant Cubist as Cubist escrow + + Mobile->>C: authenticateIdentifier(identifier) + Note over C: Create a one-time read key pkE
requestHash = hash({ getRecoverySecret, requestId, pkE }) + Note over C,Id: Proof of possession, part 1: proof key + identifier token + C-->>Mobile: session { token, proofPrivateKey, requestId, read key } + + Mobile->>C: getRecoverySecret(session) + Note over C: Recompute requestHash, must equal token.requestHash + C->>Cubist: isAvailable + Note over C,Cubist: Proof of possession, part 2: challenge + signature + C->>Cubist: getSecret({ token, proof }, requestId, pkE) + Cubist-->>C: secret encrypted to the read key, version + Note over C: Decrypt the secret + C-->>Mobile: { recoverySecret, epoch: version } ``` -**Notes** - -- `selectHighestConsistentVersion` (`src/escrow-utils.ts`) keeps successful - escrow replies, takes the highest `version`, and requires every reply at - that version to share the same `lastMutationId` and secret bytes. -- Per-escrow failures (`generateChallenge` or `getSecret` rejecting, - `wrap_key_mismatch`, a failed decrypt) only drop that reply. With one escrow, any of them surfaces as - `read_failed`. +With one escrow, any failure during the read (challenge, `getSecret`, wrong +wrap key, or decrypt) surfaces as `read_failed`. From c29b284ee8a978ae008136ad384a0f036b0c1d23 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:42:17 +0800 Subject: [PATCH 30/55] feat(mfa-recovery-controller): update CubistEscrowProvider to use policyInvoke for command execution, and add ping function call --- packages/mfa-recovery-controller/CHANGELOG.md | 2 +- .../scripts/invoke-escrow.ts | 14 +-- .../cubist-escrow-provider.test.ts | 118 +++++++++++------- .../cubist-escrow-provider.ts | 40 +++--- 4 files changed, 108 insertions(+), 66 deletions(-) diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 4f3801c2068..3f923dfe258 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `MutationReceipt` includes `receiptKeyId`. `RecoveryEscrowProvider.verifyReceipt` takes the expected escrow id and verifies with that replica's receipt key - `AuthControllerToken.expiresAt` and `PoPChallenge.expiresAt` are Unix seconds - Mutations require idle pending state (`resume()` / `abort()` first). `abort()` drops `authorizing` only; `writing` must be finished with `resume()` - - `CubistEscrowProvider` invokes Cubist escrow commands through `@cubist-labs/cubesigner-sdk` (`generateChallenge`, `applyMutation`, `getSecret` on `cubist_secret_escrow`) and verifies receipts with a pinned receipt public key + - `CubistEscrowProvider` invokes Cubist escrow commands through `@cubist-labs/cubesigner-sdk` `policyInvoke` (`ping`, `generateChallenge`, `applyMutation`, `getSecret` on `cubist_secret_escrow`) and verifies receipts with a pinned receipt public key - `authenticateIdentifier` returns an in-memory `IdentifierSession` for key-bound recovery-secret reads ### Changed diff --git a/packages/mfa-recovery-controller/scripts/invoke-escrow.ts b/packages/mfa-recovery-controller/scripts/invoke-escrow.ts index 12b0fb0d985..d99a5454a8f 100644 --- a/packages/mfa-recovery-controller/scripts/invoke-escrow.ts +++ b/packages/mfa-recovery-controller/scripts/invoke-escrow.ts @@ -6,17 +6,17 @@ * Usage: * yarn workspace @metamask/mfa-recovery-controller run cubesigner:invoke-escrow */ -import { CubeSignerClient } from '@cubist-labs/cubesigner-sdk'; +import { C2FInvocation, CubeSignerClient } from '@cubist-labs/cubesigner-sdk'; import { defaultUserSessionManager } from '@cubist-labs/cubesigner-sdk-fs-storage'; const client = await CubeSignerClient.create(defaultUserSessionManager()); -const fn = await client.org().getFunction('cubist_secret_escrow'); -// or: getFunction("NamedPolicy#e0a26d77-6b47-4507-abc5-ab9a3485e22f") -const result = await fn.invoke( - undefined, - 'latest', // or "v0" - { cmd: 'generateChallenge' }, // or applyMutation or getSecret +const result = new C2FInvocation( + await client.apiClient.policyInvoke( + 'cubist_secret_escrow', // or "NamedPolicy#…" + 'latest', // or "v0" + { request: { cmd: 'generateChallenge' } }, // or applyMutation or getSecret + ), ); console.log(result.response); 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 5c972cb6256..355afb7284b 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 @@ -1,4 +1,5 @@ import type { CubeSignerClient } from '@cubist-labs/cubesigner-sdk'; +import { bytesToHex } from '@metamask/utils'; import { generateSigningKey, @@ -77,28 +78,40 @@ describe('CubistEscrowProvider', () => { }); describe('isAvailable', () => { - it('returns true when the C2F function can be loaded', async () => { - const { provider, getFunction } = createProvider(); + it('returns true when ping is allowed and reports ok', async () => { + const { provider, invoke } = createProvider({ + stdout: { ok: true }, + }); expect(await provider.isAvailable()).toBe(true); - expect(getFunction).toHaveBeenCalledWith('cubist_secret_escrow'); + expect(invoke).toHaveBeenCalledWith('cubist_secret_escrow', 'latest', { + request: { cmd: 'ping' }, + }); }); - it('loads a caller-specified function id', async () => { - const { provider, getFunction } = createProvider({ + it('pings a caller-specified function id', async () => { + const { provider, invoke } = createProvider({ functionId: 'NamedPolicy#escrow', + stdout: { ok: true }, }); expect(await provider.isAvailable()).toBe(true); - expect(getFunction).toHaveBeenCalledWith('NamedPolicy#escrow'); + expect(invoke).toHaveBeenCalledWith('NamedPolicy#escrow', 'latest', { + request: { cmd: 'ping' }, + }); }); - it('returns false when getFunction throws', async () => { + it('returns false when policyInvoke throws', async () => { const { provider } = createProvider({ - getFunction: async () => { + invoke: async () => { throw new Error('offline'); }, }); expect(await provider.isAvailable()).toBe(false); }); + + it('returns false when ping does not report ok', async () => { + const { provider } = createProvider({ stdout: { ok: false } }); + expect(await provider.isAvailable()).toBe(false); + }); }); describe('generateChallenge', () => { @@ -107,8 +120,8 @@ describe('CubistEscrowProvider', () => { stdout: CHALLENGE, }); expect(await provider.generateChallenge()).toStrictEqual(CHALLENGE); - expect(invoke).toHaveBeenCalledWith(undefined, 'latest', { - cmd: 'generateChallenge', + expect(invoke).toHaveBeenCalledWith('cubist_secret_escrow', 'latest', { + request: { cmd: 'generateChallenge' }, }); }); @@ -143,12 +156,14 @@ describe('CubistEscrowProvider', () => { expect( await provider.applyMutation(MUTATION, TOKEN, null, PAYLOAD), ).toStrictEqual(receipt); - expect(invoke).toHaveBeenCalledWith(undefined, 'latest', { - cmd: 'applyMutation', - mutation: MUTATION, - authControllerToken: TOKEN, - identifierAuthorization: null, - payload: PAYLOAD, + expect(invoke).toHaveBeenCalledWith('cubist_secret_escrow', 'latest', { + request: { + cmd: 'applyMutation', + mutation: MUTATION, + authControllerToken: TOKEN, + identifierAuthorization: null, + payload: PAYLOAD, + }, }); }); @@ -173,11 +188,13 @@ describe('CubistEscrowProvider', () => { expect( await provider.getSecret(AUTHORIZATION, 'req-1', PK_E), ).toStrictEqual(secretResponse); - expect(invoke).toHaveBeenCalledWith(undefined, 'latest', { - cmd: 'getSecret', - identifierAuthorization: AUTHORIZATION, - requestId: 'req-1', - pkE: PK_E, + expect(invoke).toHaveBeenCalledWith('cubist_secret_escrow', 'latest', { + request: { + cmd: 'getSecret', + identifierAuthorization: AUTHORIZATION, + requestId: 'req-1', + pkE: PK_E, + }, }); }); @@ -220,6 +237,31 @@ describe('CubistEscrowProvider', () => { }); }); + it('throws when stdout is not valid hex', async () => { + const { provider } = createProvider({ + invoke: async () => ({ + response: { response: 'Allow' }, + stdout: 'not hex', + }), + }); + await expect(provider.generateChallenge()).rejects.toMatchObject({ + code: 'escrow_response_invalid', + }); + }); + + it('reports the deny reason even when stdout is empty', async () => { + const { provider } = createProvider({ + invoke: async () => ({ + response: { response: 'Deny', reason: 'not allowed' }, + stdout: '', + }), + }); + await expect(provider.generateChallenge()).rejects.toMatchObject({ + code: 'escrow_request_failed', + message: 'not allowed', + }); + }); + it('throws when stdout is not JSON', async () => { const { provider } = createProvider({ stdoutBytes: new TextEncoder().encode('nope'), @@ -260,9 +302,9 @@ describe('CubistEscrowProvider', () => { const { provider } = createProvider(); it('accepts a receipt signed by the pinned receipt key', () => { - expect( - provider.verifyReceipt(signedReceipt(), MUTATION, 'cubist'), - ).toBe(true); + expect(provider.verifyReceipt(signedReceipt(), MUTATION, 'cubist')).toBe( + true, + ); }); it.each([ @@ -314,32 +356,27 @@ function createProvider( | { response: 'Deny'; reason: string } | { response: 'Error'; error: string }; invoke?: () => Promise; - getFunction?: () => Promise<{ invoke: jest.Mock }>; } = {}, ): { provider: CubistEscrowProvider; invoke: jest.Mock; - getFunction: jest.Mock; } { + const stdoutBytes = + options.stdoutBytes ?? + new TextEncoder().encode( + JSON.stringify( + Object.hasOwn(options, 'stdout') ? options.stdout : CHALLENGE, + ), + ); const invoke = options.invoke === undefined ? jest.fn(async () => ({ response: options.policy ?? { response: 'Allow' }, - stdoutBytes: - options.stdoutBytes ?? - new TextEncoder().encode( - JSON.stringify( - Object.hasOwn(options, 'stdout') ? options.stdout : CHALLENGE, - ), - ), + stdout: bytesToHex(stdoutBytes), })) : jest.fn(options.invoke); - const getFunction = - options.getFunction === undefined - ? jest.fn(async () => ({ invoke })) - : jest.fn(options.getFunction); const client = { - org: () => ({ getFunction }), + apiClient: { policyInvoke: invoke }, } as unknown as CubeSignerClient; return { provider: new CubistEscrowProvider({ @@ -350,13 +387,10 @@ function createProvider( version: options.version, }), invoke, - getFunction, }; } -function signedReceipt( - patch: Partial = {}, -): MutationReceipt { +function signedReceipt(patch: Partial = {}): MutationReceipt { const unsigned = { mutationId: MUTATION.id, requestHash: MUTATION.requestHash, 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 a2341622b74..dad7cd89938 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 @@ -1,9 +1,9 @@ import type { - C2FResponse, CubeSignerClient, - JsonValue, + InvokePolicyResponse, Version, } from '@cubist-labs/cubesigner-sdk'; +import { bytesToString, hexToBytes } from '@metamask/utils'; import { hashMutationReceipt, @@ -32,13 +32,14 @@ const DEFAULT_VERSION: Version = 'latest'; /** * CubeSigner C2F client for the Cubist recovery escrow. * - * Commands are invoked as `{ cmd, ...fields }` on `cubist_secret_escrow`. + * Commands are sent with `policyInvoke` as `{ cmd, ...fields }` on + * `cubist_secret_escrow`. * Escrow JSON is read from C2F stdout. Receipt signatures are checked locally * with the pinned receipt public key. */ export type CubistEscrowProviderOptions = { /** - * Authenticated CubeSigner client used to load and invoke the escrow C2F. + * Authenticated CubeSigner client used to invoke the escrow C2F. */ client: CubeSignerClient; /** @@ -86,11 +87,11 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { } /** - * @returns Whether the escrow C2F can be loaded. + * @returns Whether a ping of the escrow C2F is allowed and reports ok. */ async isAvailable(): Promise { try { - await this.#client.org().getFunction(this.#functionId); + await this.#invoke('ping', {}, parsePing); return true; } catch { return false; @@ -192,22 +193,20 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { body: Record, parse: (value: unknown) => Result, ): Promise { - let policy: C2FResponse; - let stdoutBytes: Uint8Array; + let invocation: InvokePolicyResponse; try { - const fn = await this.#client.org().getFunction(this.#functionId); - const result = await fn.invoke(undefined, this.#version, { - cmd, - ...body, - } as JsonValue); - policy = result.response; - stdoutBytes = result.stdoutBytes; + invocation = await this.#client.apiClient.policyInvoke( + this.#functionId, + this.#version, + { request: { cmd, ...body } }, + ); } catch (error) { throw new MfaRecoveryError( error instanceof Error ? error.message : 'Escrow request failed', 'escrow_request_failed', ); } + const policy = invocation.response; if (policy.response !== 'Allow') { throw new MfaRecoveryError( policy.response === 'Deny' ? policy.reason : policy.error, @@ -216,7 +215,7 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { } let json: unknown; try { - json = JSON.parse(new TextDecoder().decode(stdoutBytes)); + json = JSON.parse(bytesToString(hexToBytes(invocation.stdout))); } catch { throw new MfaRecoveryError( 'Escrow response was not JSON', @@ -227,6 +226,15 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { } } +function parsePing(value: unknown): void { + if (!isRecord(value) || value.ok !== true) { + throw new MfaRecoveryError( + 'Invalid ping response', + 'escrow_response_invalid', + ); + } +} + function parseChallenge(value: unknown): PoPChallenge { if ( !isRecord(value) || From 4d1203d4ae1049d77b8bfae769b80084b479cb28 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:51:49 +0800 Subject: [PATCH 31/55] fix: update hex handling in crypto functions Updated the `verifySignature` and `decodeHex` functions to remove unnecessary type casting for the `signature` and `hexString` parameters, improving type safety and clarity in the code. This change ensures that the `hexToBytes` function receives the correct input format without explicit type assertions. --- packages/mfa-recovery-controller/src/crypto.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mfa-recovery-controller/src/crypto.ts b/packages/mfa-recovery-controller/src/crypto.ts index 8fabac0ba49..3c2e5cf81dc 100644 --- a/packages/mfa-recovery-controller/src/crypto.ts +++ b/packages/mfa-recovery-controller/src/crypto.ts @@ -159,7 +159,7 @@ export function verifySignature( } try { return p256.verify( - hexToBytes(signature as Hex), + hexToBytes(signature), sha256Sync(stringToBytes(message)), concatBytes([ new Uint8Array([0x04]), @@ -208,7 +208,7 @@ export function hashMutationReceipt(receipt: { */ export function decodeHex(hexString: string): Uint8Array { return hexToBytes( - (hexString.startsWith('0x') ? hexString : `0x${hexString}`) as Hex, + (hexString.startsWith('0x') ? hexString : `0x${hexString}`), ); } From cff5361ebea758077e235d918ea0d3a6c098f9c5 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:03:05 +0800 Subject: [PATCH 32/55] chore: update linting scripts and dependencies in mfa-recovery-controller Replaced TypeScript execution in linting scripts with Node.js for improved compatibility. Updated dependencies including `@noble/curves`, `@metamask/auto-changelog`, `deepmerge`, `jest`, `rimraf`, `ts-jest`, `typedoc`, and `typedoc-plugin-missing-exports` to their latest versions for better performance and security. This ensures the package remains up-to-date with the latest features and fixes. --- packages/mfa-recovery-controller/package.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/mfa-recovery-controller/package.json b/packages/mfa-recovery-controller/package.json index 5badac5cb0e..d0fca6a6c14 100644 --- a/packages/mfa-recovery-controller/package.json +++ b/packages/mfa-recovery-controller/package.json @@ -41,8 +41,8 @@ "changelog:validate": "../../scripts/validate-changelog.sh @metamask/mfa-recovery-controller", "cubesigner:invoke-escrow": "tsx scripts/invoke-escrow.ts", "cubesigner:register": "tsx scripts/register-with-controller.ts", - "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", - "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "lint:tsconfigs": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../scripts/lint-tsconfigs/lint-tsconfigs.ts", + "lint:tsconfigs:fix": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../scripts/lint-tsconfigs/lint-tsconfigs.ts --fix", "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", "since-latest-release": "../../scripts/since-latest-release.sh", @@ -57,21 +57,21 @@ "@metamask/messenger": "^3.0.0", "@metamask/utils": "^12.0.0", "@noble/ciphers": "^1.3.0", - "@noble/curves": "^1.9.2", + "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" }, "devDependencies": { "@cubist-labs/cubesigner-sdk-fs-storage": "^0.4.284", - "@metamask/auto-changelog": "^6.1.0", + "@metamask/auto-changelog": "^6.2.1", "@types/jest": "^30.0.0", "@typescript/native": "npm:typescript@^7.0.2", - "deepmerge": "^4.2.2", - "jest": "^30.4.2", - "rimraf": "^5.0.5", - "ts-jest": "^29.4.11", + "deepmerge": "^4.3.1", + "jest": "^30.5.2", + "rimraf": "^6.1.3", + "ts-jest": "^29.4.14", "tsx": "^4.20.5", - "typedoc": "^0.25.13", - "typedoc-plugin-missing-exports": "^2.0.0", + "typedoc": "^0.28.20", + "typedoc-plugin-missing-exports": "^4.1.4", "typescript": "npm:@typescript/typescript6@^6.0.2" }, "engines": { From 4793eb93fdd7d96c00523d9d8f0d19206a107341 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:19:07 +0800 Subject: [PATCH 33/55] fix: handle mutation rejection in mfa-recovery-controller Added a new error class `MutationRejectedError` to manage cases where every escrow rejects a mutation without applying it. Updated the architecture and documentation to reflect this change. The controller now clears pending mutations in such scenarios, ensuring that clients can start new mutations without lingering states. Tests have been added to verify the new behavior, enhancing the robustness of the mutation handling process. --- .../mfa-recovery-controller/ARCHITECTURE.md | 3 + packages/mfa-recovery-controller/CHANGELOG.md | 4 + .../CUBIST_TEST_SEQUENCE.md | 6 +- .../src/MfaRecoveryController.test.ts | 84 +++++++++++++++++-- .../src/MfaRecoveryController.ts | 38 ++++++++- .../mfa-recovery-controller/src/errors.ts | 13 +++ .../cubist-escrow-provider.test.ts | 11 ++- .../cubist-escrow-provider.ts | 7 +- packages/mfa-recovery-controller/src/index.ts | 6 +- .../mfa-recovery-controller/tests/stubs.ts | 29 ++++++- 10 files changed, 188 insertions(+), 13 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index b827d62b7dd..08121ee0463 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -106,6 +106,9 @@ sequenceDiagram M->>M: persist receipts alt all receipts valid M->>M: clear pending → idle + else every escrow rejects and none has a receipt + M->>M: clear pending → idle + M-->>C: MutationRejectedError (start a new mutation) else some replicas missing M-->>C: IncompleteMutationError (call resume) end diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 3f923dfe258..852ef6ede19 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -29,4 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** `getRecoverySecret` now accepts an `IdentifierSession` returned by `authenticateIdentifier` instead of an `Identifier` +### Fixed + +- Clear a pending mutation when every escrow rejects it before applying, so a second `register` or an update with a stale epoch does not stay in `writing`. `resume()` still retries an incomplete write, and `abort()` still refuses a `writing` mutation that may have been applied + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md index ebd6bee5f45..29e17ff35fb 100644 --- a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md +++ b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md @@ -119,8 +119,10 @@ sequenceDiagram | `updateRecoverySecret` | the identifier argument | 2FA | secret | | `updateIdentifiers` | the identifier argument | 2FA, identifiers | identifiers | -If `applyMutation` fails, the controller keeps the pending mutation and throws -`incomplete_mutation`; call `resume()` to finish it. +If an escrow refuses the mutation (`MutationRejectedError`) and every replica +does the same, the controller clears the pending mutation. A transport failure +or a partial receipt keeps it and throws `incomplete_mutation`; call `resume()` +to finish that write. ## Proof of possession diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index e2d121d87cd..6b59f56aeed 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -20,7 +20,10 @@ import { hash, unixNow, } from './crypto.js'; -import { IncompleteMutationError } from './errors.js'; +import { + IncompleteMutationError, + MutationRejectedError, +} from './errors.js'; import type { MfaRecoveryControllerMessenger, MfaRecoveryControllerOptions, @@ -227,8 +230,14 @@ describe('MfaRecoveryController', () => { await controller.register(SECRET, IDENTIFIERS); await expect( controller.register(SECRET, IDENTIFIERS), - ).rejects.toBeInstanceOf(IncompleteMutationError); - expect(await controller.getPhase()).toBe('writing'); + ).rejects.toBeInstanceOf(MutationRejectedError); + expect(await controller.getPhase()).toBe('idle'); + await controller.updateRecoverySecret( + PASSKEY, + SECRET_2, + REGISTERED_EPOCH, + ); + expect(await controller.getPhase()).toBe('idle'); }); }); @@ -600,14 +609,36 @@ describe('MfaRecoveryController', () => { controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toBeInstanceOf(IncompleteMutationError); expect(await controller.getPhase()).toBe('writing'); + await expect(controller.abort()).rejects.toThrow( + 'Cannot abort a mutation that may have been applied', + ); }); }); - it('cannot abort a writing mutation', async () => { + it('drops an update every escrow rejects for a stale epoch', async () => { await withController(async ({ controller }) => { await controller.register(SECRET, IDENTIFIERS); await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, 0), + ).rejects.toBeInstanceOf(MutationRejectedError); + expect(await controller.getPhase()).toBe('idle'); + await controller.updateRecoverySecret( + PASSKEY, + SECRET_2, + REGISTERED_EPOCH, + ); + expect( + bytesToHex((await recover(controller, PASSKEY)).recoverySecret), + ).toBe(bytesToHex(SECRET_2)); + }); + }); + + it('cannot abort a writing mutation that may have been applied', async () => { + await withController(async ({ controller, escrowA }) => { + await controller.register(SECRET, IDENTIFIERS); + escrowA.failNextApplyCount = 1; + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toBeInstanceOf(IncompleteMutationError); expect(await controller.getPhase()).toBe('writing'); await expect(controller.abort()).rejects.toThrow( @@ -696,6 +727,46 @@ describe('MfaRecoveryController', () => { }); }); + it('clears a persisted writing mutation that every escrow rejects', async () => { + await withController(async ({ controller, escrowA, escrowB, options }) => { + await controller.register(SECRET, IDENTIFIERS); + const pending = await getValidWritingPending([escrowA, escrowB], 0); + const resumed = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { + pendingOperation: await passthroughEncryptor.encrypt(pending), + }, + }); + + await expect(resumed.resume()).rejects.toBeInstanceOf( + MutationRejectedError, + ); + expect(await resumed.getPhase()).toBe('idle'); + await resumed.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH); + expect(await resumed.getPhase()).toBe('idle'); + }); + }); + + it('keeps a writing mutation that one escrow already acknowledged', async () => { + await withController(async ({ controller, escrowA }) => { + await controller.register(SECRET, IDENTIFIERS); + escrowA.failNextApplyCount = 1; + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), + ).rejects.toBeInstanceOf(IncompleteMutationError); + escrowA.patchRecord('profile-1', { version: 9 }); + + await expect(controller.resume()).rejects.toBeInstanceOf( + IncompleteMutationError, + ); + expect(await controller.getPhase()).toBe('writing'); + await expect(controller.abort()).rejects.toThrow( + 'Cannot abort a mutation that may have been applied', + ); + }); + }); + it('repairs only the missing replica when an acknowledged replica is offline', async () => { await withController(async ({ controller, escrowA, escrowB }) => { await controller.register(SECRET, IDENTIFIERS); @@ -1546,6 +1617,7 @@ async function recover( async function getValidWritingPending( escrows: RecoveryEscrowProvider[], + expectedVersion = 1, ): Promise { const payload = { recoverySecret: bytesToHex(SECRET_2), @@ -1555,8 +1627,8 @@ async function getValidWritingPending( id: '0xvalid-pending', profileId: 'profile-1', operation: 'updateRecoverySecret' as const, - expectedVersion: 1, - newVersion: 2, + expectedVersion, + newVersion: expectedVersion + 1, payloadHash, audiences: escrows.map((escrow) => escrow.id), }; diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 1ba38ff64ec..9140a2f702a 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -18,7 +18,11 @@ import { unixNow, wrapKeyId, } from './crypto.js'; -import { IncompleteMutationError, MfaRecoveryError } from './errors.js'; +import { + IncompleteMutationError, + MfaRecoveryError, + MutationRejectedError, +} from './errors.js'; import { isFulfilledResult, isMutationReceipt, @@ -599,6 +603,11 @@ export class MfaRecoveryController extends BaseController< mutation, writing.receipts, ); + const rejection = unanimousMutationRejection(results, writing.receipts); + if (rejection) { + await this.#clearPending(); + throw rejection; + } await this.#persistPending({ ...writing, receipts }); if (hasInvalidReceipt) { @@ -809,3 +818,30 @@ export class MfaRecoveryController extends BaseController< return await run; } } + +/** + * Every remaining escrow refused the mutation, and none has a receipt. + * + * @param results - Settled apply results for the remaining escrows. + * @param existingReceipts - Receipts already stored for this mutation. + * @returns The first refusal, or `null` when the write may have been applied. + */ +function unanimousMutationRejection( + results: PromiseSettledResult[], + existingReceipts: MutationReceipt[], +): MutationRejectedError | null { + if (existingReceipts.length > 0 || results.length === 0) { + return null; + } + let rejection: MutationRejectedError | null = null; + for (const result of results) { + if ( + result.status !== 'rejected' || + !(result.reason instanceof MutationRejectedError) + ) { + return null; + } + rejection ??= result.reason; + } + return rejection; +} diff --git a/packages/mfa-recovery-controller/src/errors.ts b/packages/mfa-recovery-controller/src/errors.ts index f807207e878..5dd24de619d 100644 --- a/packages/mfa-recovery-controller/src/errors.ts +++ b/packages/mfa-recovery-controller/src/errors.ts @@ -28,3 +28,16 @@ export class IncompleteMutationError extends MfaRecoveryError { this.mutationId = mutationId; } } + +/** + * Thrown when an escrow refuses a mutation without applying it. + * + * When every escrow does this and none has a receipt, the controller clears + * the pending mutation. Start a new mutation; do not call `resume()`. + */ +export class MutationRejectedError extends MfaRecoveryError { + constructor(message: string) { + super(message, 'mutation_rejected'); + this.name = 'MutationRejectedError'; + } +} 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 355afb7284b..9878e9eb175 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 @@ -7,7 +7,7 @@ import { sign, wrapKeyId, } from '../crypto.js'; -import { MfaRecoveryError } from '../errors.js'; +import { MfaRecoveryError, MutationRejectedError } from '../errors.js'; import type { AuthControllerToken, EcPublicJwk, @@ -173,6 +173,15 @@ describe('CubistEscrowProvider', () => { provider.applyMutation(MUTATION, TOKEN, AUTHORIZATION, PAYLOAD), ).rejects.toMatchObject({ code: 'escrow_response_invalid' }); }); + + it('reports a policy deny as a mutation rejection', async () => { + const { provider } = createProvider({ + policy: { response: 'Deny', reason: 'version mismatch' }, + }); + await expect( + provider.applyMutation(MUTATION, TOKEN, null, PAYLOAD), + ).rejects.toBeInstanceOf(MutationRejectedError); + }); }); describe('getSecret', () => { 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 dad7cd89938..468447d5ce1 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 @@ -10,7 +10,7 @@ import { verifySignature, wrapKeyId, } from '../crypto.js'; -import { MfaRecoveryError } from '../errors.js'; +import { MfaRecoveryError, MutationRejectedError } from '../errors.js'; import { isMutationReceipt } from '../escrow-utils.js'; import type { AuthControllerToken, @@ -149,6 +149,7 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { payload, }, parseReceipt, + true, // a policy Deny did not apply the mutation ); } @@ -192,6 +193,7 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { cmd: string, body: Record, parse: (value: unknown) => Result, + denyIsRejection = false, ): Promise { let invocation: InvokePolicyResponse; try { @@ -208,6 +210,9 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { } const policy = invocation.response; if (policy.response !== 'Allow') { + if (denyIsRejection && policy.response === 'Deny') { + throw new MutationRejectedError(policy.reason); + } throw new MfaRecoveryError( policy.response === 'Deny' ? policy.reason : policy.error, 'escrow_request_failed', diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts index 4c56a2bc474..b5c694433e7 100644 --- a/packages/mfa-recovery-controller/src/index.ts +++ b/packages/mfa-recovery-controller/src/index.ts @@ -23,7 +23,11 @@ export type { } from './MfaRecoveryController-method-action-types.js'; export { CubistEscrowProvider } from './escrow-providers/cubist-escrow-provider.js'; export type { CubistEscrowProviderOptions } from './escrow-providers/cubist-escrow-provider.js'; -export { IncompleteMutationError, MfaRecoveryError } from './errors.js'; +export { + IncompleteMutationError, + MfaRecoveryError, + MutationRejectedError, +} from './errors.js'; export { IDENTIFIER_AUTH_MODES, MIN_IDENTIFIERS, diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index e3600b33373..53ec2632131 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -14,7 +14,7 @@ import { verifySignature, wrapKeyId, } from '../src/crypto.js'; -import { MfaRecoveryError } from '../src/errors.js'; +import { MfaRecoveryError, MutationRejectedError } from '../src/errors.js'; import { MIN_IDENTIFIERS, getIdentifierAuthMode, @@ -184,6 +184,8 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { readonly #replicaIds: string[]; + #applyCommitted = false; + constructor( id: string, wrapKey: { publicKey: string; privateKey: string } = generateSigningKey(), @@ -282,6 +284,28 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { throw new MfaRecoveryError('Injected apply failure', 'apply_failed'); } + this.#applyCommitted = false; + try { + return await this.#applyMutation( + mutation, + authControllerToken, + identifierAuthorization, + payload, + ); + } catch (error) { + if (!this.#applyCommitted && error instanceof MfaRecoveryError) { + throw new MutationRejectedError(error.message); + } + throw error; + } + } + + async #applyMutation( + mutation: Mutation, + authControllerToken: AuthControllerToken, + identifierAuthorization: IdentifierAuthorization | null, + payload: MutationPayload, + ): Promise { await this.#verifyMutationAuthorization(mutation, authControllerToken); const existing = this.#records.get(mutation.profileId)?.appliedMutations[ @@ -329,6 +353,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { mutation.profileId, registerPayload.identifiers, ); + this.#applyCommitted = true; const next: RecoveryRecord = { profileId: mutation.profileId, identifiers: registerPayload.identifiers, @@ -379,12 +404,14 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { await this.#assertPayloadHash(mutation, { recoverySecret, }); + this.#applyCommitted = true; record.recoverySecret = recoverySecret; } else { const { identifiers } = payload as UpdateIdentifiersPayload; await this.#assertPayloadHash(mutation, payload); await this.#assertIdentifierOwnership(authControllerToken, identifiers); this.#assertIdentifiersAvailable(mutation.profileId, identifiers); + this.#applyCommitted = true; this.#replaceIdentifierIndex( mutation.profileId, record.identifiers, From 9d862db9e5d5477e95d5e87e164537397b578ab1 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:12:56 +0800 Subject: [PATCH 34/55] chore: update dependencies in yarn.lock Updated several package versions in yarn.lock, including `@metamask/auto-changelog`, `@noble/curves`, `deepmerge`, `jest`, `rimraf`, `ts-jest`, `typedoc`, and `typedoc-plugin-missing-exports` to their latest versions. Removed outdated entries and ensured the lock file reflects the current dependency tree for improved performance and security. --- yarn.lock | 115 +++++++----------------------------------------------- 1 file changed, 15 insertions(+), 100 deletions(-) diff --git a/yarn.lock b/yarn.lock index 45ba740fdc8..2004056b05b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6028,7 +6028,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/auto-changelog@npm:^6.1.0, @metamask/auto-changelog@npm:^6.1.1, @metamask/auto-changelog@npm:^6.2.1": +"@metamask/auto-changelog@npm:^6.1.1, @metamask/auto-changelog@npm:^6.2.1": version: 6.2.1 resolution: "@metamask/auto-changelog@npm:6.2.1" dependencies: @@ -7707,22 +7707,22 @@ __metadata: dependencies: "@cubist-labs/cubesigner-sdk": "npm:^0.4.284" "@cubist-labs/cubesigner-sdk-fs-storage": "npm:^0.4.284" - "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/auto-changelog": "npm:^6.2.1" "@metamask/base-controller": "npm:^10.0.0" "@metamask/messenger": "npm:^3.0.0" "@metamask/utils": "npm:^12.0.0" "@noble/ciphers": "npm:^1.3.0" - "@noble/curves": "npm:^1.9.2" + "@noble/curves": "npm:^1.9.7" "@noble/hashes": "npm:^1.8.0" "@types/jest": "npm:^30.0.0" "@typescript/native": "npm:typescript@^7.0.2" - deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - rimraf: "npm:^5.0.5" - ts-jest: "npm:^29.4.11" + deepmerge: "npm:^4.3.1" + jest: "npm:^30.5.2" + rimraf: "npm:^6.1.3" + ts-jest: "npm:^29.4.14" tsx: "npm:^4.20.5" - typedoc: "npm:^0.25.13" - typedoc-plugin-missing-exports: "npm:^2.0.0" + typedoc: "npm:^0.28.20" + typedoc-plugin-missing-exports: "npm:^4.1.4" typescript: "npm:@typescript/typescript6@^6.0.2" languageName: unknown linkType: soft @@ -9748,7 +9748,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2, @noble/curves@npm:^1.9.7, @noble/curves@npm:~1.9.0": +"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.7, @noble/curves@npm:~1.9.0": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: @@ -14249,13 +14249,6 @@ __metadata: languageName: node linkType: hard -"ansi-sequence-parser@npm:^1.1.0": - version: 1.1.3 - resolution: "ansi-sequence-parser@npm:1.1.3" - checksum: 10/0f298aad5c9429dc71fc85668b4b502a5c0670c6dc1253b6e9ed5fc7d457a05ede5b10fd0d69a33a1435e41c0a841360bec9131053bdce04a2367c475acb97d6 - languageName: node - linkType: hard - "ansi-styles@npm:^3.2.0": version: 3.2.1 resolution: "ansi-styles@npm:3.2.1" @@ -16463,7 +16456,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.1": +"deepmerge@npm:^4.3.1": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" checksum: 10/058d9e1b0ff1a154468bf3837aea436abcfea1ba1d165ddaaf48ca93765fdd01a30d33c36173da8fbbed951dd0a267602bc782fe288b0fc4b7e1e7091afc4529 @@ -18411,7 +18404,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.4.1": +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.4.1": version: 10.5.0 resolution: "glob@npm:10.5.0" dependencies: @@ -20455,7 +20448,7 @@ __metadata: languageName: node linkType: hard -"jest@npm:^30.4.2, jest@npm:^30.5.2": +"jest@npm:^30.5.2": version: 30.5.2 resolution: "jest@npm:30.5.2" dependencies: @@ -20693,13 +20686,6 @@ __metadata: languageName: node linkType: hard -"jsonc-parser@npm:^3.2.0": - version: 3.3.1 - resolution: "jsonc-parser@npm:3.3.1" - checksum: 10/9b0dc391f20b47378f843ef1e877e73ec652a5bdc3c5fa1f36af0f119a55091d147a86c1ee86a232296f55c929bba174538c2bf0312610e0817a22de131cc3f4 - languageName: node - linkType: hard - "jsonfile@npm:^6.0.1": version: 6.1.0 resolution: "jsonfile@npm:6.1.0" @@ -21139,15 +21125,6 @@ __metadata: languageName: node linkType: hard -"marked@npm:^4.3.0": - version: 4.3.0 - resolution: "marked@npm:4.3.0" - bin: - marked: bin/marked.js - checksum: 10/c830bb4cb3705b754ca342b656e8a582d7428706b2678c898b856f6030c134ce2d1e19136efa3e6a1841f7330efbd24963d6bdeddc57d2938e906250f99895d0 - languageName: node - linkType: hard - "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" @@ -22160,7 +22137,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": +"minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": version: 9.0.9 resolution: "minimatch@npm:9.0.9" dependencies: @@ -25574,17 +25551,6 @@ __metadata: languageName: node linkType: hard -"rimraf@npm:^5.0.5": - version: 5.0.10 - resolution: "rimraf@npm:5.0.10" - dependencies: - glob: "npm:^10.3.7" - bin: - rimraf: dist/esm/bin.mjs - checksum: 10/f3b8ce81eecbde4628b07bdf9e2fa8b684e0caea4999acb1e3b0402c695cd41f28cd075609a808e61ce2672f528ca079f675ab1d8e8d5f86d56643a03e0b8d2e - languageName: node - linkType: hard - "rimraf@npm:^6.1.3": version: 6.1.3 resolution: "rimraf@npm:6.1.3" @@ -26013,18 +25979,6 @@ __metadata: languageName: node linkType: hard -"shiki@npm:^0.14.7": - version: 0.14.7 - resolution: "shiki@npm:0.14.7" - dependencies: - ansi-sequence-parser: "npm:^1.1.0" - jsonc-parser: "npm:^3.2.0" - vscode-oniguruma: "npm:^1.7.0" - vscode-textmate: "npm:^8.0.0" - checksum: 10/be3f2444c65bd0c57802026f171cb42ad571d361ee885be0c292b60785f68c70f19b69310f5ffe7f7a93db4c5ef50211e0a0248794bc6bb48d242bc43fe72a62 - languageName: node - linkType: hard - "side-channel-list@npm:^1.0.1": version: 1.0.1 resolution: "side-channel-list@npm:1.0.1" @@ -27010,7 +26964,7 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.4.11, ts-jest@npm:^29.4.14": +"ts-jest@npm:^29.4.14": version: 29.4.14 resolution: "ts-jest@npm:29.4.14" dependencies: @@ -27226,15 +27180,6 @@ __metadata: languageName: node linkType: hard -"typedoc-plugin-missing-exports@npm:^2.0.0": - version: 2.3.0 - resolution: "typedoc-plugin-missing-exports@npm:2.3.0" - peerDependencies: - typedoc: 0.24.x || 0.25.x - checksum: 10/83ff8affd82fa39a81931e825ef31b51b7470613c71601fde6ff413a5c7571e30734698092a38a437f12c5d3264010696ce9ca806d43485aa11e8208cb4cb323 - languageName: node - linkType: hard - "typedoc-plugin-missing-exports@npm:^4.1.4": version: 4.1.4 resolution: "typedoc-plugin-missing-exports@npm:4.1.4" @@ -27244,22 +27189,6 @@ __metadata: languageName: node linkType: hard -"typedoc@npm:^0.25.13": - version: 0.25.13 - resolution: "typedoc@npm:0.25.13" - dependencies: - lunr: "npm:^2.3.9" - marked: "npm:^4.3.0" - minimatch: "npm:^9.0.3" - shiki: "npm:^0.14.7" - peerDependencies: - typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x - bin: - typedoc: bin/typedoc - checksum: 10/3c82603894b5830c4b027b4f4f9ca70f770b6752c6512a42e780c40cb67fe4c9a144e34a837bb35aab14a125e00a5893e1e6feac1ec86a2add80f46833b279d4 - languageName: node - linkType: hard - "typedoc@npm:^0.28.20": version: 0.28.20 resolution: "typedoc@npm:0.28.20" @@ -27901,20 +27830,6 @@ __metadata: languageName: node linkType: hard -"vscode-oniguruma@npm:^1.7.0": - version: 1.7.0 - resolution: "vscode-oniguruma@npm:1.7.0" - checksum: 10/7da9d21459f9788544b258a5fd1b9752df6edd8b406a19eea0209c6bf76507d5717277016799301c4da0d536095f9ca8c06afd1ab8f4001189090c804ca4814e - languageName: node - linkType: hard - -"vscode-textmate@npm:^8.0.0": - version: 8.0.0 - resolution: "vscode-textmate@npm:8.0.0" - checksum: 10/9fa7d66d6042cb090d116c2d8820d34c8870cfcbaed6e404da89f66b899970ed0ac47b59a2e30fc40a25af5414822bb3ea27974f714e9b91910d69c894be95f7 - languageName: node - linkType: hard - "w3c-xmlserializer@npm:^5.0.0": version: 5.0.0 resolution: "w3c-xmlserializer@npm:5.0.0" From f175c7cdfbbb7d3dc9a75034525b378ae9ed87a3 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:11:28 +0800 Subject: [PATCH 35/55] feat: add unit tests for auth controller token decoding Introduced a new test file for the `decodeAuthControllerToken` function, covering various scenarios including valid claims, optional fields, and invalid inputs. This enhances test coverage and ensures robust handling of token decoding in the MFA recovery controller. --- .../src/MfaRecoveryController.test.ts | 11 ++ .../src/auth-controller-token.test.ts | 135 ++++++++++++++++++ .../src/crypto.test.ts | 9 ++ .../mfa-recovery-controller/src/crypto.ts | 8 +- 4 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 packages/mfa-recovery-controller/src/auth-controller-token.test.ts diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 6b59f56aeed..504d58e370f 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -1476,6 +1476,17 @@ describe('MfaRecoveryController', () => { pending.authControllerToken = {}; }, }, + { + name: 'an AuthController token without step-up assurance', + mutate: (pending: Record): void => { + const mutation = pending.mutation as Record; + pending.authControllerToken = createAuthControllerToken({ + sub: 'profile-1', + exp: unixNow() + 60, + requestHash: mutation.requestHash as string, + }); + }, + }, { name: 'an invalid receipt array', mutate: (pending: Record): void => { diff --git a/packages/mfa-recovery-controller/src/auth-controller-token.test.ts b/packages/mfa-recovery-controller/src/auth-controller-token.test.ts new file mode 100644 index 00000000000..05848a1039d --- /dev/null +++ b/packages/mfa-recovery-controller/src/auth-controller-token.test.ts @@ -0,0 +1,135 @@ +import { bytesToBase64, stringToBytes } from '@metamask/utils'; + +import { decodeAuthControllerToken } from './auth-controller-token.js'; + +/** + * Builds an unsigned compact JWT whose payload is the given raw JSON text. + * + * @param payloadJson - Raw JSON text for the payload segment. + * @returns A compact token string. + */ +function tokenWithPayload(payloadJson: string): string { + const payload = bytesToBase64(stringToBytes(payloadJson)) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); + return `header.${payload}.signature`; +} + +const VALID_CLAIMS = { + sub: 'profile-1', + exp: 1_700_000_000, + ext: { + request_hash: '0xrequest', + aal: 2, + identifiers_hash: '0xidentifiers', + }, +}; + +describe('decodeAuthControllerToken', () => { + it('decodes all claims when optional step-up bindings are present', () => { + expect( + decodeAuthControllerToken(tokenWithPayload(JSON.stringify(VALID_CLAIMS))), + ).toStrictEqual({ + sub: 'profile-1', + exp: 1_700_000_000, + ext: { + requestHash: '0xrequest', + aal: 2, + identifiersHash: '0xidentifiers', + }, + }); + }); + + it('decodes a token without optional aal or identifiers hash', () => { + expect( + decodeAuthControllerToken( + tokenWithPayload( + JSON.stringify({ + sub: 'profile-1', + exp: 1, + ext: { request_hash: '0xrequest' }, + }), + ), + ), + ).toStrictEqual({ + sub: 'profile-1', + exp: 1, + ext: { + requestHash: '0xrequest', + aal: undefined, + identifiersHash: undefined, + }, + }); + }); + + it('returns undefined for a non-string token', () => { + expect(decodeAuthControllerToken(null)).toBeUndefined(); + expect(decodeAuthControllerToken(123)).toBeUndefined(); + }); + + it('returns undefined for a token without a payload segment', () => { + expect(decodeAuthControllerToken('header')).toBeUndefined(); + }); + + it('returns undefined when the payload is not valid JSON', () => { + expect( + decodeAuthControllerToken(tokenWithPayload('{not json')), + ).toBeUndefined(); + }); + + it.each([ + ['null', 'null'], + ['a string', '"claims"'], + ['an array', '[]'], + ])('returns undefined when the payload is %s', (_name, payloadJson) => { + expect( + decodeAuthControllerToken(tokenWithPayload(payloadJson)), + ).toBeUndefined(); + }); + + it('returns undefined when ext is missing or not an object', () => { + expect( + decodeAuthControllerToken( + tokenWithPayload(JSON.stringify({ sub: 'profile-1', exp: 1 })), + ), + ).toBeUndefined(); + expect( + decodeAuthControllerToken( + tokenWithPayload( + JSON.stringify({ sub: 'profile-1', exp: 1, ext: 'nope' }), + ), + ), + ).toBeUndefined(); + }); + + it.each([ + ['sub is missing', { exp: 1, ext: { request_hash: '0x' } }], + ['sub is not a string', { sub: 1, exp: 1, ext: { request_hash: '0x' } }], + [ + 'exp is not a number', + { sub: 'profile-1', exp: '1', ext: { request_hash: '0x' } }, + ], + ['request_hash is missing', { sub: 'profile-1', exp: 1, ext: {} }], + [ + 'aal is not a number', + { + sub: 'profile-1', + exp: 1, + ext: { request_hash: '0x', aal: '2' }, + }, + ], + [ + 'identifiers_hash is not a string', + { + sub: 'profile-1', + exp: 1, + ext: { request_hash: '0x', identifiers_hash: 1 }, + }, + ], + ])('returns undefined when %s', (_name, claims) => { + expect( + decodeAuthControllerToken(tokenWithPayload(JSON.stringify(claims))), + ).toBeUndefined(); + }); +}); diff --git a/packages/mfa-recovery-controller/src/crypto.test.ts b/packages/mfa-recovery-controller/src/crypto.test.ts index e71997205bd..89d14b0df7c 100644 --- a/packages/mfa-recovery-controller/src/crypto.test.ts +++ b/packages/mfa-recovery-controller/src/crypto.test.ts @@ -3,6 +3,7 @@ import { bytesToHex } from '@metamask/utils'; import { canonicalize, canonicalizeIdentifiers, + compareUtf8, decodeHex, decryptFromPublic, encryptToPublic, @@ -112,6 +113,14 @@ describe('crypto', () => { it('orders a shorter identifier before its extension', () => { expect(sortedValues(['abc', 'ab'])).toStrictEqual(['ab', 'abc']); }); + + it('compares UTF-8 bytes, treating a shared prefix as shorter', () => { + expect(compareUtf8('ab', 'abc')).toBeLessThan(0); + expect(compareUtf8('abc', 'ab')).toBeGreaterThan(0); + expect(compareUtf8('abc', 'abc')).toBe(0); + expect(compareUtf8('a', 'b')).toBeLessThan(0); + expect(compareUtf8('b', 'a')).toBeGreaterThan(0); + }); }); it('round-trips wrap encryption to a wrap public key', () => { diff --git a/packages/mfa-recovery-controller/src/crypto.ts b/packages/mfa-recovery-controller/src/crypto.ts index 3c2e5cf81dc..b011638d4ad 100644 --- a/packages/mfa-recovery-controller/src/crypto.ts +++ b/packages/mfa-recovery-controller/src/crypto.ts @@ -46,12 +46,12 @@ export function hash(value: unknown): Hex { * @param right - Second string. * @returns Negative, zero, or positive like `Array.prototype.sort` expects. */ -function compareUtf8(left: string, right: string): number { +export function compareUtf8(left: string, right: string): number { const leftBytes = stringToBytes(left); const rightBytes = stringToBytes(right); const shared = Math.min(leftBytes.length, rightBytes.length); for (let index = 0; index < shared; index += 1) { - const difference = (leftBytes[index] ?? 0) - (rightBytes[index] ?? 0); + const difference = leftBytes[index] - rightBytes[index]; if (difference !== 0) { return difference; } @@ -207,9 +207,7 @@ export function hashMutationReceipt(receipt: { * @returns Raw bytes. */ export function decodeHex(hexString: string): Uint8Array { - return hexToBytes( - (hexString.startsWith('0x') ? hexString : `0x${hexString}`), - ); + return hexToBytes(hexString.startsWith('0x') ? hexString : `0x${hexString}`); } /** From aafff9f9189e4b00770ece13633580567f70335c Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:22:35 +0800 Subject: [PATCH 36/55] fix: update jest configuration and improve type safety in tests Added import unambiguous suppressions for new jest configuration files in the MFA recovery controller. Removed unnecessary process import in the register-with-controller script. Enhanced type safety in the MfaRecoveryController tests by explicitly casting lastMutationId to string. Updated cubist-escrow-provider tests to improve error handling and ensure correct invocation of the specified function version. --- oxlint-suppressions.json | 10 ++++++++++ .../scripts/register-with-controller.ts | 1 - .../src/MfaRecoveryController.test.ts | 6 +++--- .../escrow-providers/cubist-escrow-provider.test.ts | 8 +++++--- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 8d49c12be2b..8dc7496bc4d 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -3717,6 +3717,16 @@ "count": 1 } }, + "packages/mfa-recovery-controller/jest.config.cjs": { + "import/unambiguous": { + "count": 1 + } + }, + "packages/mfa-recovery-controller/jest.config.ts5.cjs": { + "import/unambiguous": { + "count": 1 + } + }, "packages/money-account-api-data-service/jest.config.cjs": { "import/unambiguous": { "count": 1 diff --git a/packages/mfa-recovery-controller/scripts/register-with-controller.ts b/packages/mfa-recovery-controller/scripts/register-with-controller.ts index fed4e364470..c1d4538a152 100644 --- a/packages/mfa-recovery-controller/scripts/register-with-controller.ts +++ b/packages/mfa-recovery-controller/scripts/register-with-controller.ts @@ -9,7 +9,6 @@ import type { } from '@metamask/messenger'; import { bytesToHex } from '@metamask/utils'; import { randomBytes } from 'node:crypto'; -import process from 'node:process'; import { CubistEscrowProvider } from '../src/escrow-providers/cubist-escrow-provider.js'; import { MfaRecoveryController } from '../src/MfaRecoveryController.js'; diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 504d58e370f..58d80c9f2cf 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -487,11 +487,11 @@ describe('MfaRecoveryController', () => { expect(await escrowA.getRecoveryMetadata('profile-1')).toStrictEqual({ version: 1, - lastMutationId: expect.any(String), + lastMutationId: expect.any(String) as string, }); expect(await escrowB.getRecoveryMetadata('profile-1')).toStrictEqual({ version: 1, - lastMutationId: expect.any(String), + lastMutationId: expect.any(String) as string, }); }, ); @@ -1222,7 +1222,7 @@ describe('MfaRecoveryController', () => { ]); expect(await escrowB.getRecoveryMetadata('profile-1')).toStrictEqual({ version: 1, - lastMutationId: expect.any(String), + lastMutationId: expect.any(String) as string, }); }); }); 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 9878e9eb175..405bf5a4c4a 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 @@ -128,7 +128,9 @@ describe('CubistEscrowProvider', () => { it('invokes a caller-specified function version', async () => { const { provider, invoke } = createProvider({ version: 'v0' }); await provider.generateChallenge(); - expect(invoke.mock.calls[0]?.[1]).toBe('v0'); + expect(invoke).toHaveBeenCalledWith('cubist_secret_escrow', 'v0', { + request: { cmd: 'generateChallenge' }, + }); }); it.each([ @@ -296,8 +298,8 @@ describe('CubistEscrowProvider', () => { const { provider } = createProvider({ invoke: async () => { // Cover the non-Error rejection path in `#invoke`. - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw 0; + const nonError: unknown = 0; + throw nonError; }, }); await expect(provider.generateChallenge()).rejects.toMatchObject({ From 5171d889770905622b3aa7449ddba17afc434e96 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:27:10 +0800 Subject: [PATCH 37/55] fix: lint:misc --- .../src/MfaRecoveryController.test.ts | 53 +++++++++---------- .../src/auth-controller-token.ts | 7 ++- .../cubist-escrow-provider.ts | 6 +-- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 58d80c9f2cf..b292e3ddcce 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -20,10 +20,7 @@ import { hash, unixNow, } from './crypto.js'; -import { - IncompleteMutationError, - MutationRejectedError, -} from './errors.js'; +import { IncompleteMutationError, MutationRejectedError } from './errors.js'; import type { MfaRecoveryControllerMessenger, MfaRecoveryControllerOptions, @@ -728,24 +725,30 @@ describe('MfaRecoveryController', () => { }); it('clears a persisted writing mutation that every escrow rejects', async () => { - await withController(async ({ controller, escrowA, escrowB, options }) => { - await controller.register(SECRET, IDENTIFIERS); - const pending = await getValidWritingPending([escrowA, escrowB], 0); - const resumed = new MfaRecoveryController({ - ...options, - messenger: getMessenger(getRootMessenger()), - state: { - pendingOperation: await passthroughEncryptor.encrypt(pending), - }, - }); + await withController( + async ({ controller, escrowA, escrowB, options }) => { + await controller.register(SECRET, IDENTIFIERS); + const pending = await getValidWritingPending([escrowA, escrowB], 0); + const resumed = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { + pendingOperation: await passthroughEncryptor.encrypt(pending), + }, + }); - await expect(resumed.resume()).rejects.toBeInstanceOf( - MutationRejectedError, - ); - expect(await resumed.getPhase()).toBe('idle'); - await resumed.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH); - expect(await resumed.getPhase()).toBe('idle'); - }); + await expect(resumed.resume()).rejects.toBeInstanceOf( + MutationRejectedError, + ); + expect(await resumed.getPhase()).toBe('idle'); + await resumed.updateRecoverySecret( + PASSKEY, + SECRET_2, + REGISTERED_EPOCH, + ); + expect(await resumed.getPhase()).toBe('idle'); + }, + ); }); it('keeps a writing mutation that one escrow already acknowledged', async () => { @@ -781,9 +784,7 @@ describe('MfaRecoveryController', () => { expect(await controller.getPhase()).toBe('idle'); expect( - bytesToHex( - (await recover(controller, PASSKEY)).recoverySecret, - ), + bytesToHex((await recover(controller, PASSKEY)).recoverySecret), ).toBe(bytesToHex(SECRET_2)); }); }); @@ -898,9 +899,7 @@ describe('MfaRecoveryController', () => { await controller.resume(); expect(await controller.getPhase()).toBe('idle'); expect( - bytesToHex( - (await recover(controller, PASSKEY)).recoverySecret, - ), + bytesToHex((await recover(controller, PASSKEY)).recoverySecret), ).toBe(bytesToHex(SECRET)); }); }); diff --git a/packages/mfa-recovery-controller/src/auth-controller-token.ts b/packages/mfa-recovery-controller/src/auth-controller-token.ts index 136f42e08e3..64adc1d5a90 100644 --- a/packages/mfa-recovery-controller/src/auth-controller-token.ts +++ b/packages/mfa-recovery-controller/src/auth-controller-token.ts @@ -55,8 +55,11 @@ export function decodeAuthControllerToken( return undefined; } const { sub, exp, ext } = claims; - const { aal, request_hash: requestHash, identifiers_hash: identifiersHash } = - ext; + const { + aal, + request_hash: requestHash, + identifiers_hash: identifiersHash, + } = ext; if ( typeof sub !== 'string' || typeof exp !== 'number' || 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 468447d5ce1..b44e31f5674 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 @@ -5,11 +5,7 @@ import type { } from '@cubist-labs/cubesigner-sdk'; import { bytesToString, hexToBytes } from '@metamask/utils'; -import { - hashMutationReceipt, - verifySignature, - wrapKeyId, -} from '../crypto.js'; +import { hashMutationReceipt, verifySignature, wrapKeyId } from '../crypto.js'; import { MfaRecoveryError, MutationRejectedError } from '../errors.js'; import { isMutationReceipt } from '../escrow-utils.js'; import type { From c5fdaa9c8b49b8e1b694512a4acc6e995fc3a4fd Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:14:05 +0800 Subject: [PATCH 38/55] Enhance MfaRecoveryController to assert escrow availability - Introduced a new private method `#assertEscrowsAvailable` to check the availability of configured escrows before performing mutations. - Updated existing methods to call this new assertion method, ensuring that all configured escrows are available before proceeding with operations. - Enhanced tests to verify that recovery requests are not authorized when escrows are unavailable, and that appropriate errors are thrown. - Improved test coverage for scenarios involving escrow availability during recovery secret updates and resumptions. --- .../src/MfaRecoveryController.test.ts | 29 ++++++++++++++++++- .../src/MfaRecoveryController.ts | 24 ++++++++++----- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index b292e3ddcce..8d4497c31a3 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -512,12 +512,39 @@ describe('MfaRecoveryController', () => { }); it('throws when a configured escrow is unavailable', async () => { - await withController(async ({ controller, escrowB }) => { + await withController(async ({ controller, escrowB, options }) => { await controller.register(SECRET, IDENTIFIERS); escrowB.available = false; + const auth = jest.spyOn( + options.authProvider, + 'authorizeRecoveryRequest', + ); + auth.mockClear(); await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), ).rejects.toThrow('All configured escrows are required for mutation'); + expect(auth).not.toHaveBeenCalled(); + expect(await controller.getPhase()).toBe('idle'); + }); + }); + + it('does not authorize a resumed authorizing mutation when an escrow is unavailable', async () => { + await withController(async ({ controller, escrowB, options }) => { + await controller.register(SECRET, IDENTIFIERS); + const auth = jest + .spyOn(options.authProvider, 'authorizeRecoveryRequest') + .mockRejectedValueOnce(new Error('auth down')); + await expect( + controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), + ).rejects.toThrow('auth down'); + expect(await controller.getPhase()).toBe('authorizing'); + + escrowB.available = false; + auth.mockClear(); + await expect(controller.resume()).rejects.toThrow( + 'All configured escrows are required for mutation', + ); + expect(auth).not.toHaveBeenCalled(); expect(await controller.getPhase()).toBe('authorizing'); await controller.abort(); }); diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 9140a2f702a..8b708827eb7 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -465,6 +465,8 @@ export class MfaRecoveryController extends BaseController< ); } + await this.#assertEscrowsAvailable(this.#escrows); + const profileId = await this.#authProvider.getAuthenticatedProfileId(); const audiences = this.#escrows.map((escrow) => escrow.id); const currentVersion = this.#resolveCurrentRecoveryVersion(epoch); @@ -526,6 +528,7 @@ export class MfaRecoveryController extends BaseController< } if (pending.phase === 'authorizing') { + await this.#assertEscrowsAvailable(this.#escrows); await this.#replicateMutation({ ...pending, phase: 'writing', @@ -550,13 +553,7 @@ export class MfaRecoveryController extends BaseController< return; } - const availableEscrows = await this.#getAvailableEscrows(remainingEscrows); - if (availableEscrows.length !== remainingEscrows.length) { - throw new MfaRecoveryError( - 'All configured escrows are required for mutation', - 'escrow_unavailable', - ); - } + await this.#assertEscrowsAvailable(remainingEscrows); const claims = decodeAuthControllerToken(pending.authControllerToken); const authControllerToken = @@ -623,6 +620,19 @@ export class MfaRecoveryController extends BaseController< await this.#clearPending(); } + async #assertEscrowsAvailable( + escrows: RecoveryEscrowProvider[], + ): Promise { + const availableEscrows = await this.#getAvailableEscrows(escrows); + // Do we want to wait for all escrows to be available? + if (availableEscrows.length !== escrows.length) { + throw new MfaRecoveryError( + 'All configured escrows are required for mutation', + 'escrow_unavailable', + ); + } + } + #getRemainingEscrows( pending: WritingPendingOperation, ): RecoveryEscrowProvider[] { From 251cbc0f4ac496265a74b9cace0d23af8a5812df Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:24:52 +0800 Subject: [PATCH 39/55] chore: Initialize `mfa-recovery-controller` package Co-authored-by: Cursor --- .github/CODEOWNERS | 7 + README.md | 1 + codeowners.ts | 3 + oxlint-suppressions.json | 5 + packages/mfa-recovery-controller/CHANGELOG.md | 10 + packages/mfa-recovery-controller/LICENSE | 6 + .../mfa-recovery-controller/LICENSE.APACHE2 | 201 ++++++++++++++++++ packages/mfa-recovery-controller/LICENSE.MIT | 21 ++ packages/mfa-recovery-controller/README.md | 15 ++ .../mfa-recovery-controller/jest.config.cjs | 26 +++ packages/mfa-recovery-controller/package.json | 67 ++++++ .../mfa-recovery-controller/src/index.test.ts | 9 + packages/mfa-recovery-controller/src/index.ts | 9 + .../tsconfig.build.json | 9 + .../mfa-recovery-controller/tsconfig.json | 5 + .../tsconfig.lint.json | 8 + packages/mfa-recovery-controller/typedoc.json | 7 + teams.json | 1 + tsconfig.build.json | 3 + tsconfig.json | 3 + tsconfig.lint.json | 3 + yarn.lock | 17 ++ 22 files changed, 436 insertions(+) create mode 100644 packages/mfa-recovery-controller/CHANGELOG.md create mode 100644 packages/mfa-recovery-controller/LICENSE create mode 100644 packages/mfa-recovery-controller/LICENSE.APACHE2 create mode 100644 packages/mfa-recovery-controller/LICENSE.MIT create mode 100644 packages/mfa-recovery-controller/README.md create mode 100644 packages/mfa-recovery-controller/jest.config.cjs create mode 100644 packages/mfa-recovery-controller/package.json create mode 100644 packages/mfa-recovery-controller/src/index.test.ts create mode 100644 packages/mfa-recovery-controller/src/index.ts create mode 100644 packages/mfa-recovery-controller/tsconfig.build.json create mode 100644 packages/mfa-recovery-controller/tsconfig.json create mode 100644 packages/mfa-recovery-controller/tsconfig.lint.json create mode 100644 packages/mfa-recovery-controller/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2801180a62e..63dfec54bf6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -382,6 +382,13 @@ /packages/messenger-cli/tsconfig.* @MetaMask/core-platform /packages/messenger-cli/typedoc.json @MetaMask/core-platform +### mfa-recovery-controller +/packages/mfa-recovery-controller @MetaMask/web3auth +/packages/mfa-recovery-controller/CHANGELOG.md @MetaMask/core-platform @MetaMask/web3auth +/packages/mfa-recovery-controller/package.json @MetaMask/core-platform @MetaMask/web3auth +/packages/mfa-recovery-controller/tsconfig.* @MetaMask/core-platform @MetaMask/web3auth +/packages/mfa-recovery-controller/typedoc.json @MetaMask/core-platform @MetaMask/web3auth + ### money-account-api-data-service /packages/money-account-api-data-service @MetaMask/earn /packages/money-account-api-data-service/CHANGELOG.md @MetaMask/core-platform @MetaMask/earn diff --git a/README.md b/README.md index 1cffa168d49..af67b113eb4 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/message-manager`](packages/message-manager) - [`@metamask/messenger`](packages/messenger) - [`@metamask/messenger-cli`](packages/messenger-cli) +- [`@metamask/mfa-recovery-controller`](packages/mfa-recovery-controller) - [`@metamask/money-account-api-data-service`](packages/money-account-api-data-service) - [`@metamask/money-account-balance-service`](packages/money-account-balance-service) - [`@metamask/money-account-controller`](packages/money-account-controller) diff --git a/codeowners.ts b/codeowners.ts index d4bb2dcd660..ea06b856a32 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -200,6 +200,9 @@ const config = { 'messenger-cli': { teams: ['@MetaMask/core-platform'], }, + 'mfa-recovery-controller': { + teams: ['@MetaMask/web3auth'], + }, 'money-account-api-data-service': { teams: ['@MetaMask/earn'], }, diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 7df780443d1..7bc0bb68f01 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -3714,6 +3714,11 @@ "count": 1 } }, + "packages/mfa-recovery-controller/jest.config.cjs": { + "import/unambiguous": { + "count": 1 + } + }, "packages/money-account-api-data-service/jest.config.cjs": { "import/unambiguous": { "count": 1 diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/mfa-recovery-controller/LICENSE b/packages/mfa-recovery-controller/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/mfa-recovery-controller/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/mfa-recovery-controller/LICENSE.APACHE2 b/packages/mfa-recovery-controller/LICENSE.APACHE2 new file mode 100644 index 00000000000..e6e77b08909 --- /dev/null +++ b/packages/mfa-recovery-controller/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/mfa-recovery-controller/LICENSE.MIT b/packages/mfa-recovery-controller/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/mfa-recovery-controller/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md new file mode 100644 index 00000000000..2b67b9c608a --- /dev/null +++ b/packages/mfa-recovery-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/mfa-recovery-controller` + +Manages MFA recovery flows across MetaMask clients + +## Installation + +`yarn add @metamask/mfa-recovery-controller` + +or + +`npm install @metamask/mfa-recovery-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/mfa-recovery-controller/jest.config.cjs b/packages/mfa-recovery-controller/jest.config.cjs new file mode 100644 index 00000000000..6456e074bb0 --- /dev/null +++ b/packages/mfa-recovery-controller/jest.config.cjs @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages.cjs'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/mfa-recovery-controller/package.json b/packages/mfa-recovery-controller/package.json new file mode 100644 index 00000000000..0346b2a2704 --- /dev/null +++ b/packages/mfa-recovery-controller/package.json @@ -0,0 +1,67 @@ +{ + "name": "@metamask/mfa-recovery-controller", + "version": "0.0.0", + "description": "Manages MFA recovery flows across MetaMask clients", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/mfa-recovery-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "build:all": "tsc --build tsconfig.build.json --verbose", + "build:clean": "yarn build:only-clean && yarn build", + "build:docs": "typedoc", + "build:only-clean": "rimraf './dist' './tsconfig.build.tsbuildinfo'", + "changelog:update": "../../scripts/update-changelog.sh @metamask/mfa-recovery-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/mfa-recovery-controller", + "lint:tsconfigs": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../scripts/lint-tsconfigs/lint-tsconfigs.ts", + "lint:tsconfigs:fix": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../scripts/lint-tsconfigs/lint-tsconfigs.ts --fix", + "messenger-action-types:check": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.2.1", + "@types/jest": "^30.0.0", + "@typescript/native": "npm:typescript@^7.0.2", + "deepmerge": "^4.3.1", + "jest": "^30.5.2", + "rimraf": "^6.1.3", + "ts-jest": "^29.4.14", + "typedoc": "^0.28.20", + "typedoc-plugin-missing-exports": "^4.1.4", + "typescript": "npm:@typescript/typescript6@^6.0.2" + }, + "engines": { + "node": "^22.14.0 || ^24" + } +} diff --git a/packages/mfa-recovery-controller/src/index.test.ts b/packages/mfa-recovery-controller/src/index.test.ts new file mode 100644 index 00000000000..baa030ac10f --- /dev/null +++ b/packages/mfa-recovery-controller/src/index.test.ts @@ -0,0 +1,9 @@ +import greeter from './index.js'; + +describe('Test', () => { + it('greets', () => { + const name = 'Huey'; + const result = greeter(name); + expect(result).toBe('Hello, Huey!'); + }); +}); diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts new file mode 100644 index 00000000000..6972c117292 --- /dev/null +++ b/packages/mfa-recovery-controller/src/index.ts @@ -0,0 +1,9 @@ +/** + * Example function that returns a greeting for the given name. + * + * @param name - The name to greet. + * @returns The greeting. + */ +export default function greeter(name: string): string { + return `Hello, ${name}!`; +} diff --git a/packages/mfa-recovery-controller/tsconfig.build.json b/packages/mfa-recovery-controller/tsconfig.build.json new file mode 100644 index 00000000000..f7e0bc20778 --- /dev/null +++ b/packages/mfa-recovery-controller/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/mfa-recovery-controller/tsconfig.json b/packages/mfa-recovery-controller/tsconfig.json new file mode 100644 index 00000000000..fabfb3eca68 --- /dev/null +++ b/packages/mfa-recovery-controller/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.packages.json", + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/mfa-recovery-controller/tsconfig.lint.json b/packages/mfa-recovery-controller/tsconfig.lint.json new file mode 100644 index 00000000000..fb65dcfce34 --- /dev/null +++ b/packages/mfa-recovery-controller/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig.packages.lint.json"], + "compilerOptions": { + "outDir": "./.tsc-lint-cache", + "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" + }, + "references": [] +} diff --git a/packages/mfa-recovery-controller/typedoc.json b/packages/mfa-recovery-controller/typedoc.json new file mode 100644 index 00000000000..0373637ee8a --- /dev/null +++ b/packages/mfa-recovery-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "api-docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index fb06bc5d8f4..74ab5a246b7 100644 --- a/teams.json +++ b/teams.json @@ -67,6 +67,7 @@ "metamask/wallet": "team-core-platform", "metamask/wallet-cli": "team-core-platform", "metamask/passkey-controller": "team-onboarding", + "metamask/mfa-recovery-controller": "team-onboarding", "metamask/seedless-onboarding-controller": "team-onboarding", "metamask/shield-controller": "team-shield", "metamask/subscription-controller": "team-shield", diff --git a/tsconfig.build.json b/tsconfig.build.json index 1dc5af7a080..d6823e57990 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -166,6 +166,9 @@ { "path": "./packages/messenger/tsconfig.build.json" }, + { + "path": "./packages/mfa-recovery-controller/tsconfig.build.json" + }, { "path": "./packages/money-account-api-data-service/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index f68e1f73f94..d5e4ab61399 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -171,6 +171,9 @@ { "path": "./packages/messenger-cli" }, + { + "path": "./packages/mfa-recovery-controller" + }, { "path": "./packages/money-account-api-data-service" }, diff --git a/tsconfig.lint.json b/tsconfig.lint.json index 3df899447f3..9a862f49329 100644 --- a/tsconfig.lint.json +++ b/tsconfig.lint.json @@ -185,6 +185,9 @@ { "path": "./packages/message-manager/tsconfig.lint.json" }, + { + "path": "./packages/mfa-recovery-controller/tsconfig.lint.json" + }, { "path": "./packages/money-account-api-data-service/tsconfig.lint.json" }, diff --git a/yarn.lock b/yarn.lock index 5aa84d3e299..42f05a4a06a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7481,6 +7481,23 @@ __metadata: languageName: node linkType: hard +"@metamask/mfa-recovery-controller@workspace:packages/mfa-recovery-controller": + version: 0.0.0-use.local + resolution: "@metamask/mfa-recovery-controller@workspace:packages/mfa-recovery-controller" + dependencies: + "@metamask/auto-changelog": "npm:^6.2.1" + "@types/jest": "npm:^30.0.0" + "@typescript/native": "npm:typescript@^7.0.2" + deepmerge: "npm:^4.3.1" + jest: "npm:^30.5.2" + rimraf: "npm:^6.1.3" + ts-jest: "npm:^29.4.14" + typedoc: "npm:^0.28.20" + typedoc-plugin-missing-exports: "npm:^4.1.4" + typescript: "npm:@typescript/typescript6@^6.0.2" + languageName: unknown + linkType: soft + "@metamask/money-account-api-data-service@npm:^2.2.0, @metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service": version: 0.0.0-use.local resolution: "@metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service" From ab0d6aa6a57b45e60fb6453e9be60620695f7cd6 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:03:19 +0800 Subject: [PATCH 40/55] feat: integrate @metamask/cryptography and @metamask/superstruct for enhanced cryptographic operations - Added dependencies for `@metamask/cryptography` and `@metamask/superstruct` to improve cryptographic functionalities and data validation. - Updated various functions in the `crypto` module to support asynchronous operations, ensuring compatibility with the new cryptography methods. - Enhanced the `verifyReceipt` and `sign` functions to return promises, allowing for better handling of asynchronous operations. - Refactored tests to accommodate the changes in the cryptographic functions, ensuring all tests are now asynchronous. - Updated the changelog to reflect these significant changes in functionality and dependencies. --- packages/mfa-recovery-controller/CHANGELOG.md | 2 +- packages/mfa-recovery-controller/package.json | 4 +- .../src/MfaRecoveryController.test.ts | 103 ++++++++------- .../src/MfaRecoveryController.ts | 54 ++++---- .../src/crypto.test.ts | 117 +++++++++++++----- .../mfa-recovery-controller/src/crypto.ts | 62 +++++----- .../cubist-escrow-provider.test.ts | 54 ++++---- .../cubist-escrow-provider.ts | 10 +- .../src/escrow-utils.ts | 6 +- .../src/identifier-auth.ts | 4 +- .../src/pending-operation-validation.ts | 4 +- packages/mfa-recovery-controller/src/types.ts | 2 +- .../mfa-recovery-controller/tests/stubs.ts | 49 ++++---- .../tsconfig.build.json | 3 + .../mfa-recovery-controller/tsconfig.json | 3 + .../tsconfig.lint.json | 3 + yarn.lock | 2 +- 17 files changed, 284 insertions(+), 198 deletions(-) diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 852ef6ede19..84b2457cde2 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Mutation `payloadHash` is the hash of the logical pending payload (`identifiers` and/or `0x`-hex secret). At apply, each escrow receives `{ pkE, ciphertext }` wrapped to its own wrap key with escrow-wrap-v1 - `getSecret` request hashes bind the client's ephemeral `pkE`; responses are `{ ciphertext }` plus `wrapKeyId` - `Mutation.audiences` is the configured replica-id list, in that order. Each replica should require an exact match - - `MutationReceipt` includes `receiptKeyId`. `RecoveryEscrowProvider.verifyReceipt` takes the expected escrow id and verifies with that replica's receipt key + - `MutationReceipt` includes `receiptKeyId`. `RecoveryEscrowProvider.verifyReceipt` takes the expected escrow id, verifies with that replica's receipt key, and returns a `Promise` - `AuthControllerToken.expiresAt` and `PoPChallenge.expiresAt` are Unix seconds - Mutations require idle pending state (`resume()` / `abort()` first). `abort()` drops `authorizing` only; `writing` must be finished with `resume()` - `CubistEscrowProvider` invokes Cubist escrow commands through `@cubist-labs/cubesigner-sdk` `policyInvoke` (`ping`, `generateChallenge`, `applyMutation`, `getSecret` on `cubist_secret_escrow`) and verifies receipts with a pinned receipt public key diff --git a/packages/mfa-recovery-controller/package.json b/packages/mfa-recovery-controller/package.json index d0fca6a6c14..7a427b3e5e7 100644 --- a/packages/mfa-recovery-controller/package.json +++ b/packages/mfa-recovery-controller/package.json @@ -54,11 +54,11 @@ "dependencies": { "@cubist-labs/cubesigner-sdk": "^0.4.284", "@metamask/base-controller": "^10.0.0", + "@metamask/cryptography": "^1.1.2", "@metamask/messenger": "^3.0.0", "@metamask/utils": "^12.0.0", "@noble/ciphers": "^1.3.0", - "@noble/curves": "^1.9.7", - "@noble/hashes": "^1.8.0" + "@noble/curves": "^1.9.7" }, "devDependencies": { "@cubist-labs/cubesigner-sdk-fs-storage": "^0.4.284", diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 8d4497c31a3..757b8af71ce 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -280,7 +280,7 @@ describe('MfaRecoveryController', () => { const session = await controller.authenticateIdentifier(PASSKEY); expect(session.token.requestHash).toBe( - hash({ + await hash({ operation: 'getRecoverySecret', requestId: session.requestId, pkE: session.pkE, @@ -898,7 +898,7 @@ describe('MfaRecoveryController', () => { identifiers: IDENTIFIERS, recoverySecret: bytesToHex(SECRET), }; - const payloadHash = hash(payload); + const payloadHash = await hash(payload); const fields = { id: '0xmutauth', profileId: 'profile-1', @@ -910,7 +910,7 @@ describe('MfaRecoveryController', () => { }; const mutation = { ...fields, - requestHash: hash(fields), + requestHash: await hash(fields), }; const authorizing = await passthroughEncryptor.encrypt({ phase: 'authorizing', @@ -935,7 +935,7 @@ describe('MfaRecoveryController', () => { await withController(async ({ controller, options }) => { await controller.register(SECRET, IDENTIFIERS); const payload = { identifiers: IDENTIFIERS }; - const payloadHash = hash(payload); + const payloadHash = await hash(payload); const mutationFields = { id: '0xmutidentifiers', profileId: 'profile-1', @@ -947,7 +947,7 @@ describe('MfaRecoveryController', () => { }; const mutation = { ...mutationFields, - requestHash: hash(mutationFields), + requestHash: await hash(mutationFields), }; const authorizing = { phase: 'authorizing' as const, @@ -993,11 +993,11 @@ describe('MfaRecoveryController', () => { operation: 'updateRecoverySecret' as const, expectedVersion: 1, newVersion: 2, - payloadHash: hash(payload), + payloadHash: await hash(payload), audiences: ['escrow-a', 'escrow-b'], requestHash: '0x', }; - mutation.requestHash = hash({ + mutation.requestHash = await hash({ id: mutation.id, profileId: mutation.profileId, operation: mutation.operation, @@ -1108,7 +1108,7 @@ describe('MfaRecoveryController', () => { identifiers: IDENTIFIERS, recoverySecret: bytesToHex(SECRET), }; - const payloadHash = hash(payload); + const payloadHash = await hash(payload); const mutation = { id: '0xmut', profileId: 'profile-1', @@ -1119,7 +1119,7 @@ describe('MfaRecoveryController', () => { audiences: ['escrow-a', 'escrow-b'], requestHash: '', }; - mutation.requestHash = hash({ + mutation.requestHash = await hash({ id: mutation.id, profileId: mutation.profileId, operation: mutation.operation, @@ -1135,7 +1135,9 @@ describe('MfaRecoveryController', () => { sub: 'profile-1', exp: unixNow() + 60, requestHash: mutation.requestHash, - identifiersHash: hash(canonicalizeIdentifiers(payload.identifiers)), + identifiersHash: await hash( + canonicalizeIdentifiers(payload.identifiers), + ), }), payload, identifier: null, @@ -1168,7 +1170,7 @@ describe('MfaRecoveryController', () => { identifiers: IDENTIFIERS, recoverySecret: bytesToHex(SECRET), }; - const payloadHash = hash(payload); + const payloadHash = await hash(payload); const mutation = { id: '0xmut', profileId: 'profile-1', @@ -1179,7 +1181,7 @@ describe('MfaRecoveryController', () => { audiences: ['escrow-a', 'escrow-b'], requestHash: '', }; - mutation.requestHash = hash({ + mutation.requestHash = await hash({ id: mutation.id, profileId: mutation.profileId, operation: mutation.operation, @@ -1195,7 +1197,9 @@ describe('MfaRecoveryController', () => { sub: 'profile-1', exp: unixNow() + 60, requestHash: mutation.requestHash, - identifiersHash: hash(canonicalizeIdentifiers(payload.identifiers)), + identifiersHash: await hash( + canonicalizeIdentifiers(payload.identifiers), + ), }), payload, identifier: null, @@ -1263,7 +1267,7 @@ describe('MfaRecoveryController', () => { receiptKeyId: '0xreceipt', signature: '0xsignature', }); - jest.spyOn(escrowA, 'verifyReceipt').mockReturnValue(true); + jest.spyOn(escrowA, 'verifyReceipt').mockResolvedValue(true); await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( 'Invalid mutation receipt', @@ -1273,9 +1277,9 @@ describe('MfaRecoveryController', () => { it('treats receipt verification errors as invalid receipts', async () => { await withController(async ({ controller, escrowA }) => { - jest.spyOn(escrowA, 'verifyReceipt').mockImplementation(() => { - throw new Error('verification failed'); - }); + jest + .spyOn(escrowA, 'verifyReceipt') + .mockRejectedValue(new Error('verification failed')); await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( 'Invalid mutation receipt', @@ -1336,19 +1340,19 @@ describe('MfaRecoveryController', () => { it.each([ { name: 'an invalid phase', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.phase = 'invalid'; }, }, { name: 'a malformed mutation', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.mutation = null; }, }, { name: 'an operation and payload mismatch', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { const mutation = pending.mutation as Record; mutation.operation = 'updateIdentifiers'; pending.payload = { recoverySecret: bytesToHex(SECRET) }; @@ -1356,19 +1360,19 @@ describe('MfaRecoveryController', () => { }, { name: 'a malformed payload', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.payload = null; }, }, { name: 'a payload without a recovery secret', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.payload = {}; }, }, { name: 'an invalid recovery secret encoding', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.payload = { recoverySecret: '0xzz', }; @@ -1376,7 +1380,7 @@ describe('MfaRecoveryController', () => { }, { name: 'an identifier payload with invalid entries', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { const mutation = pending.mutation as Record; mutation.operation = 'updateIdentifiers'; pending.payload = { identifiers: [{}] }; @@ -1384,10 +1388,10 @@ describe('MfaRecoveryController', () => { }, { name: 'duplicate identifiers', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { const mutation = pending.mutation as Record; const payload = { identifiers: [PASSKEY, PASSKEY] }; - const payloadHash = hash(payload); + const payloadHash = await hash(payload); const fields = { id: mutation.id, profileId: mutation.profileId, @@ -1397,19 +1401,22 @@ describe('MfaRecoveryController', () => { payloadHash, audiences: mutation.audiences, }; - pending.mutation = { ...fields, requestHash: hash(fields) }; + pending.mutation = { + ...fields, + requestHash: await hash(fields), + }; pending.payload = payload; pending.authControllerToken = createAuthControllerToken({ sub: 'profile-1', exp: unixNow() + 60, - requestHash: hash(fields), - identifiersHash: hash(payload.identifiers), + requestHash: await hash(fields), + identifiersHash: await hash(payload.identifiers), }); }, }, { name: 'a non-null register identifier', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { const mutation = pending.mutation as Record; mutation.operation = 'register'; pending.payload = { @@ -1421,7 +1428,7 @@ describe('MfaRecoveryController', () => { }, { name: 'a non-zero register expectedVersion', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { const mutation = pending.mutation as Record; mutation.operation = 'register'; mutation.expectedVersion = 1; @@ -1435,13 +1442,13 @@ describe('MfaRecoveryController', () => { }, { name: 'a null update identifier', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.identifier = null; }, }, { name: 'an unknown identifier type', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.identifier = { type: 'unknown', namespace: 'example.com', @@ -1452,7 +1459,7 @@ describe('MfaRecoveryController', () => { }, { name: 'a non-consecutive version', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { const mutation = pending.mutation as Record; mutation.expectedVersion = 4; mutation.newVersion = 6; @@ -1460,28 +1467,28 @@ describe('MfaRecoveryController', () => { }, { name: 'a mismatched payload hash', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { const mutation = pending.mutation as Record; mutation.payloadHash = '0xwrong-payload-hash'; }, }, { name: 'a mismatched mutation request hash', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { const mutation = pending.mutation as Record; mutation.requestHash = '0xwrong-request-hash'; }, }, { name: 'an out-of-order audience list', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { const mutation = pending.mutation as Record; mutation.audiences = ['escrow-b', 'escrow-a']; }, }, { name: 'an invalid AuthController token', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.authControllerToken = createAuthControllerToken({ sub: 'profile-1', exp: unixNow() + 60, @@ -1492,19 +1499,19 @@ describe('MfaRecoveryController', () => { }, { name: 'a malformed AuthController token', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.authControllerToken = null; }, }, { name: 'an AuthController token with invalid fields', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.authControllerToken = {}; }, }, { name: 'an AuthController token without step-up assurance', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { const mutation = pending.mutation as Record; pending.authControllerToken = createAuthControllerToken({ sub: 'profile-1', @@ -1515,19 +1522,19 @@ describe('MfaRecoveryController', () => { }, { name: 'an invalid receipt array', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.receipts = null; }, }, { name: 'a receipt with invalid fields', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { pending.receipts = [{}]; }, }, { name: 'duplicate receipts', - mutate: (pending: Record): void => { + mutate: async (pending: Record): Promise => { const mutation = pending.mutation as Record; const receipt = { mutationId: mutation.id, @@ -1547,7 +1554,7 @@ describe('MfaRecoveryController', () => { const pending = JSON.parse( JSON.stringify(await getValidWritingPending(options.escrows)), ) as Record; - mutate(pending); + await mutate(pending); const auth = jest.spyOn( options.authProvider, 'authorizeRecoveryRequest', @@ -1659,7 +1666,7 @@ async function getValidWritingPending( const payload = { recoverySecret: bytesToHex(SECRET_2), }; - const payloadHash = hash(payload); + const payloadHash = await hash(payload); const fields = { id: '0xvalid-pending', profileId: 'profile-1', @@ -1673,12 +1680,12 @@ async function getValidWritingPending( phase: 'writing', mutation: { ...fields, - requestHash: hash(fields), + requestHash: await hash(fields), }, authControllerToken: createAuthControllerToken({ sub: 'profile-1', exp: unixNow() + 60, - requestHash: hash(fields), + requestHash: await hash(fields), aal: 2, }), payload, diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 8b708827eb7..ea85c390e2e 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -336,7 +336,7 @@ export class MfaRecoveryController extends BaseController< const requestId = randomId(); const ephemeral = generateSigningKey(); const pkE = JSON.parse(ephemeral.publicKey) as EcPublicJwk; - const requestHash = hash({ + const requestHash = await hash({ operation: 'getRecoverySecret', requestId, pkE, @@ -369,7 +369,7 @@ export class MfaRecoveryController extends BaseController< return await this.#withLock(async () => { const { token, requestId, pkE } = session; this.#assertKnownIdentifierTypes([token.identifier]); - const requestHash = hash({ + const requestHash = await hash({ operation: 'getRecoverySecret', requestId, pkE, @@ -398,7 +398,7 @@ export class MfaRecoveryController extends BaseController< requestId, pkE, ); - if (response.wrapKeyId !== wrapKeyId(escrow.wrapPublicKey)) { + if (response.wrapKeyId !== (await wrapKeyId(escrow.wrapPublicKey))) { throw new MfaRecoveryError( 'Unexpected wrap key', 'wrap_key_mismatch', @@ -406,7 +406,7 @@ export class MfaRecoveryController extends BaseController< } return { escrowId: escrow.id, - recoverySecret: decryptFromPublic( + recoverySecret: await decryptFromPublic( session.ephemeralPrivateKey, escrow.wrapPublicKey, response.recoverySecret.ciphertext, @@ -470,7 +470,7 @@ export class MfaRecoveryController extends BaseController< const profileId = await this.#authProvider.getAuthenticatedProfileId(); const audiences = this.#escrows.map((escrow) => escrow.id); const currentVersion = this.#resolveCurrentRecoveryVersion(epoch); - const payloadHash = hash(payload); + const payloadHash = await hash(payload); const mutationFields = { id: randomId(), @@ -483,7 +483,7 @@ export class MfaRecoveryController extends BaseController< }; const mutation: Mutation = { ...mutationFields, - requestHash: hash(mutationFields), + requestHash: await hash(mutationFields), }; const pending: PendingOperation = { @@ -547,7 +547,7 @@ export class MfaRecoveryController extends BaseController< async #replicateMutation(pending: WritingPendingOperation): Promise { const { mutation, payload, identifier } = pending; - const remainingEscrows = this.#getRemainingEscrows(pending); + const remainingEscrows = await this.#getRemainingEscrows(pending); if (remainingEscrows.length === 0) { await this.#clearPending(); return; @@ -578,8 +578,10 @@ export class MfaRecoveryController extends BaseController< 'identifier_auth_failed', ); } - const payloads = remainingEscrows.map((escrow) => - this.#payloadForEscrow(writing, escrow), + const payloads = await Promise.all( + remainingEscrows.map(async (escrow) => + this.#payloadForEscrow(writing, escrow), + ), ); await this.#persistPending(writing); @@ -594,7 +596,7 @@ export class MfaRecoveryController extends BaseController< ), ); - const { receipts, hasInvalidReceipt } = this.#collectMutationReceipts( + const { receipts, hasInvalidReceipt } = await this.#collectMutationReceipts( results, remainingEscrows, mutation, @@ -633,9 +635,9 @@ export class MfaRecoveryController extends BaseController< } } - #getRemainingEscrows( + async #getRemainingEscrows( pending: WritingPendingOperation, - ): RecoveryEscrowProvider[] { + ): Promise { const { mutation } = pending; const remainingEscrows: RecoveryEscrowProvider[] = []; for (const escrow of this.#escrows) { @@ -646,7 +648,7 @@ export class MfaRecoveryController extends BaseController< remainingEscrows.push(escrow); continue; } - if (!verifyMutationReceipt(receipt, mutation, escrow)) { + if (!(await verifyMutationReceipt(receipt, mutation, escrow))) { throw new MfaRecoveryError( 'Invalid mutation receipt', 'invalid_receipt', @@ -665,28 +667,28 @@ export class MfaRecoveryController extends BaseController< return remainingEscrows; } - #collectMutationReceipts( + async #collectMutationReceipts( results: PromiseSettledResult[], escrows: RecoveryEscrowProvider[], mutation: Mutation, existingReceipts: MutationReceipt[], - ): { receipts: MutationReceipt[]; hasInvalidReceipt: boolean } { + ): Promise<{ receipts: MutationReceipt[]; hasInvalidReceipt: boolean }> { const receipts = [...existingReceipts]; let hasInvalidReceipt = false; - results.forEach((result, index) => { + for (const [index, result] of results.entries()) { if (!isFulfilledResult(result)) { - return; + continue; } const escrow = escrows[index]; if ( !isMutationReceipt(result.value) || - !verifyMutationReceipt(result.value, mutation, escrow) + !(await verifyMutationReceipt(result.value, mutation, escrow)) ) { hasInvalidReceipt = true; - return; + continue; } receipts.push(result.value); - }); + } return { receipts, hasInvalidReceipt }; } @@ -708,17 +710,17 @@ export class MfaRecoveryController extends BaseController< return await authorizeEscrowsWithToken({ escrows, credential }); } - #payloadForEscrow( + async #payloadForEscrow( pending: WritingPendingOperation, escrow: RecoveryEscrowProvider, - ): MutationPayload { + ): Promise { const { payload } = pending; if (!pendingPayloadHasRecoverySecret(payload)) { return { identifiers: payload.identifiers, }; } - const recoverySecret = this.#wrapRecoverySecret( + const recoverySecret = await this.#wrapRecoverySecret( decodeHex(payload.recoverySecret), escrow.wrapPublicKey, ); @@ -769,14 +771,14 @@ export class MfaRecoveryController extends BaseController< } } - #wrapRecoverySecret( + async #wrapRecoverySecret( plaintext: Uint8Array, wrapPublicKey: string, - ): WrappedSecret { + ): Promise { const ephemeral = generateSigningKey(); return { pkE: JSON.parse(ephemeral.publicKey) as EcPublicJwk, - ciphertext: encryptToPublic( + ciphertext: await encryptToPublic( ephemeral.privateKey, wrapPublicKey, plaintext, diff --git a/packages/mfa-recovery-controller/src/crypto.test.ts b/packages/mfa-recovery-controller/src/crypto.test.ts index 89d14b0df7c..5deedd934e9 100644 --- a/packages/mfa-recovery-controller/src/crypto.test.ts +++ b/packages/mfa-recovery-controller/src/crypto.test.ts @@ -34,7 +34,7 @@ describe('crypto', () => { }); it('hashes independently of key order', async () => { - expect(hash({ b: 1, a: 2 })).toBe(hash({ a: 2, b: 1 })); + expect(await hash({ b: 1, a: 2 })).toBe(await hash({ a: 2, b: 1 })); }); it('decodes hex with or without a 0x prefix', () => { @@ -46,17 +46,23 @@ describe('crypto', () => { it('round-trips a P-256 proof signature', async () => { const key = generateSigningKey(); - const signature = sign(key.privateKey, 'hello'); - - expect(verifySignature(key.publicKey, signature, 'hello')).toBe(true); - expect(verifySignature(key.publicKey, signature, 'other')).toBe(false); - expect(verifySignature(key.publicKey, '0x00', 'hello')).toBe(false); - expect(verifySignature(key.publicKey, 'not-hex', 'hello')).toBe(false); - expect(verifySignature('{}', signature, 'hello')).toBe(false); - expect(() => sign('{}', 'hello')).toThrow('Invalid P-256 private JWK'); + const signature = await sign(key.privateKey, 'hello'); + + expect(await verifySignature(key.publicKey, signature, 'hello')).toBe(true); + expect(await verifySignature(key.publicKey, signature, 'other')).toBe( + false, + ); + expect(await verifySignature(key.publicKey, '0x00', 'hello')).toBe(false); + expect(await verifySignature(key.publicKey, 'not-hex', 'hello')).toBe( + false, + ); + expect(await verifySignature('{}', signature, 'hello')).toBe(false); + await expect(sign('{}', 'hello')).rejects.toThrow( + 'Invalid P-256 private JWK', + ); }); - it('sorts identifiers for ownership hashes', () => { + it('sorts identifiers for ownership hashes', async () => { const first: Identifier = { type: 'passkey', namespace: 'b.com', @@ -123,11 +129,43 @@ describe('crypto', () => { }); }); - it('round-trips wrap encryption to a wrap public key', () => { + describe('wire-format vectors', () => { + // Fixed keys and outputs captured from the pre-migration implementation. + // These guard against changes to the escrow wire format. + const pubA = + '{"kty":"EC","crv":"P-256","x":"-GnlZ7gZANe0Gs7QZ4pSpahACCd4QRDreFl7p1o0-fU","y":"Bn-YKRTZNOHs2Fxs-cfnQeslwbGX7FEThxshVEjNTEY"}'; + const pubB = + '{"kty":"EC","crv":"P-256","x":"5maYv6oliw0cvzGW9YTpxc5lgzzXQWSi19xQPuQPCFE","y":"39L_TUNqvNOOVU-wxFIBhnPSMUlYJE9EAEwGdLNrBGY"}'; + const privB = + '{"kty":"EC","crv":"P-256","x":"5maYv6oliw0cvzGW9YTpxc5lgzzXQWSi19xQPuQPCFE","y":"39L_TUNqvNOOVU-wxFIBhnPSMUlYJE9EAEwGdLNrBGY","d":"AgkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk"}'; + const ciphertext = + '030303030303030303030303e2b9aa448de9d87c62ef5c18066076f9e5fdd4b575'; + + it('hashes canonical JSON to the pinned SHA-256 digest', async () => { + expect(await hash({ c: { d: null }, b: [1, 'x'], a: 1 })).toBe( + '0x665cec53b6bd2aa401cf30969b6b2e67aa897b90432758ca89f6268970ce2d4c', + ); + }); + + it('derives the pinned wrap key id', async () => { + expect(await wrapKeyId(pubB)).toBe( + '0xaccd2e1ccd3c2c0ebf7daf42ae30c8192dada53086772829e2695504940f67ad', + ); + }); + + it('decrypts the pinned escrow-wrap-v1 ciphertext', async () => { + expect(await decryptFromPublic(privB, pubA, ciphertext)).toStrictEqual( + new Uint8Array([1, 2, 3, 4, 5]), + ); + }); + }); + + it('round-trips wrap encryption to a wrap public key', async () => { const wrapKey = generateSigningKey(); const ephemeral = generateSigningKey(); const plaintext = new Uint8Array([1, 2, 3, 4, 5]); - const ciphertext = encryptToPublic( + + const ciphertext = await encryptToPublic( ephemeral.privateKey, wrapKey.publicKey, plaintext, @@ -135,24 +173,32 @@ describe('crypto', () => { expect(ciphertext.startsWith('0x')).toBe(false); expect( - decryptFromPublic(wrapKey.privateKey, ephemeral.publicKey, ciphertext), + await decryptFromPublic( + wrapKey.privateKey, + ephemeral.publicKey, + ciphertext, + ), ).toStrictEqual(plaintext); }); - it('round-trips getSecret wrapping with an ephemeral client key', () => { + it('round-trips getSecret wrapping with an ephemeral client key', async () => { const wrapKey = generateSigningKey(); const ephemeral = generateSigningKey(); const plaintext = new Uint8Array([9, 8, 7]); - const ciphertext = encryptToPublic( + const ciphertext = await encryptToPublic( wrapKey.privateKey, ephemeral.publicKey, plaintext, ); expect( - decryptFromPublic(ephemeral.privateKey, wrapKey.publicKey, ciphertext), + await decryptFromPublic( + ephemeral.privateKey, + wrapKey.publicKey, + ciphertext, + ), ).toStrictEqual(plaintext); - expect(wrapKeyId(wrapKey.publicKey)).toMatch(/^0x[0-9a-f]{64}$/u); + expect(await wrapKeyId(wrapKey.publicKey)).toMatch(/^0x[0-9a-f]{64}$/u); }); it('hashes an unsigned mutation receipt independently of key order', async () => { @@ -164,8 +210,8 @@ describe('crypto', () => { receiptKeyId: '0xabc', }; - expect(hashMutationReceipt(receipt)).toBe( - hashMutationReceipt({ + expect(await hashMutationReceipt(receipt)).toBe( + await hashMutationReceipt({ version: 1, receiptKeyId: '0xabc', requestHash: '0x2', @@ -173,8 +219,8 @@ describe('crypto', () => { escrowId: 'cubist', }), ); - expect(hashMutationReceipt(receipt)).toBe( - hash({ + expect(await hashMutationReceipt(receipt)).toBe( + await hash({ escrowId: 'cubist', mutationId: '0x1', receiptKeyId: '0xabc', @@ -191,32 +237,39 @@ describe('crypto', () => { requestHash: '0x2', escrowId: 'cubist', version: 1, - receiptKeyId: wrapKeyId(key.publicKey), + receiptKeyId: await wrapKeyId(key.publicKey), }; - const signature = sign(key.privateKey, hashMutationReceipt(unsigned)); + const signature = await sign( + key.privateKey, + await hashMutationReceipt(unsigned), + ); expect( - verifySignature(key.publicKey, signature, hashMutationReceipt(unsigned)), + await verifySignature( + key.publicKey, + signature, + await hashMutationReceipt(unsigned), + ), ).toBe(true); expect( - verifySignature( + await verifySignature( key.publicKey, signature, - hashMutationReceipt({ ...unsigned, version: 2 }), + await hashMutationReceipt({ ...unsigned, version: 2 }), ), ).toBe(false); }); - it('rejects truncated wrap ciphertext and invalid JWKs', () => { + it('rejects truncated wrap ciphertext and invalid JWKs', async () => { const wrapKey = generateSigningKey(); - expect(() => + await expect( decryptFromPublic(wrapKey.privateKey, wrapKey.publicKey, '00'), - ).toThrow('Wrapped secret is truncated'); - expect(() => wrapKeyId('{"kty":"OKP"}')).toThrow( + ).rejects.toThrow('Wrapped secret is truncated'); + await expect(wrapKeyId('{"kty":"OKP"}')).rejects.toThrow( 'Invalid P-256 public JWK', ); - expect(() => + await expect( decryptFromPublic('{}', wrapKey.publicKey, '00'.repeat(32)), - ).toThrow('Invalid P-256 private JWK'); + ).rejects.toThrow('Invalid P-256 private JWK'); }); }); diff --git a/packages/mfa-recovery-controller/src/crypto.ts b/packages/mfa-recovery-controller/src/crypto.ts index b011638d4ad..29755043cde 100644 --- a/packages/mfa-recovery-controller/src/crypto.ts +++ b/packages/mfa-recovery-controller/src/crypto.ts @@ -1,3 +1,6 @@ +import { getRandomBytes } from '@metamask/cryptography'; +import { hkdfSha256 } from '@metamask/cryptography/hkdf'; +import { sha256 } from '@metamask/cryptography/sha'; import { base64ToBytes, bytesToBase64, @@ -9,8 +12,6 @@ import { import type { Hex } from '@metamask/utils'; import { chacha20poly1305 } from '@noble/ciphers/chacha'; import { p256 } from '@noble/curves/nist'; -import { hkdf } from '@noble/hashes/hkdf'; -import { sha256 as sha256Sync } from '@noble/hashes/sha2'; import type { EcPublicJwk, Identifier } from './types.js'; @@ -34,8 +35,8 @@ export function canonicalize(value: unknown): string { * @param value - Value to hash. * @returns Hex digest. */ -export function hash(value: unknown): Hex { - return bytesToHex(sha256Sync(stringToBytes(canonicalize(value)))); +export async function hash(value: unknown): Promise { + return bytesToHex(await sha256(stringToBytes(canonicalize(value)))); } /** @@ -83,7 +84,7 @@ export function canonicalizeIdentifiers(identifiers: Identifier[]): unknown { * @returns A random 0x-prefixed id. */ export function randomId(): Hex { - return bytesToHex(randomBytes(16)); + return bytesToHex(getRandomBytes(16)); } /** @@ -128,14 +129,14 @@ export function generateSigningKey(): { * @param message - Message string; hashed with SHA-256 before ECDSA. * @returns Compact IEEE P1363 hex signature (`r‖s`). */ -export function sign(privateKey: string, message: string): Hex { +export async function sign(privateKey: string, message: string): Promise { const jwk = JSON.parse(privateKey) as { d?: string }; if (typeof jwk.d !== 'string') { throw new Error('Invalid P-256 private JWK'); } return bytesToHex( p256 - .sign(sha256Sync(stringToBytes(message)), fromBase64Url(jwk.d)) + .sign(await sha256(stringToBytes(message)), fromBase64Url(jwk.d)) .toBytes('compact'), ); } @@ -148,11 +149,11 @@ export function sign(privateKey: string, message: string): Hex { * @param message - Message string; hashed with SHA-256 before ECDSA. * @returns Whether the signature is valid. */ -export function verifySignature( +export async function verifySignature( publicKey: string, signature: string, message: string, -): boolean { +): Promise { const jwk = JSON.parse(publicKey) as { x?: string; y?: string }; if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') { return false; @@ -160,7 +161,7 @@ export function verifySignature( try { return p256.verify( hexToBytes(signature), - sha256Sync(stringToBytes(message)), + await sha256(stringToBytes(message)), concatBytes([ new Uint8Array([0x04]), fromBase64Url(jwk.x), @@ -184,13 +185,13 @@ export function verifySignature( * @param receipt.version - Applied version. * @returns Hex digest. */ -export function hashMutationReceipt(receipt: { +export async function hashMutationReceipt(receipt: { escrowId: string; mutationId: string; receiptKeyId: string; requestHash: string; version: number; -}): Hex { +}): Promise { return hash({ escrowId: receipt.escrowId, mutationId: receipt.mutationId, @@ -216,8 +217,10 @@ export function decodeHex(hexString: string): Uint8Array { * @param wrapPublicKey - Wrap public JWK JSON. * @returns Key id matching cubist `wrapKeyId`. */ -export function wrapKeyId(wrapPublicKey: string): Hex { - return bytesToHex(sha256Sync(publicKeyBytesFromJwk(wrapPublicKey))); +export async function wrapKeyId(wrapPublicKey: string): Promise { + return bytesToHex( + await sha256(new Uint8Array(publicKeyBytesFromJwk(wrapPublicKey))), + ); } /** @@ -228,13 +231,13 @@ export function wrapKeyId(wrapPublicKey: string): Hex { * @param plaintext - Bytes to encrypt. * @returns Unprefixed hex of `nonce || ciphertext+tag`. */ -export function encryptToPublic( +export async function encryptToPublic( privateKey: string, publicKey: string, plaintext: Uint8Array, -): string { - const key = deriveTransportKey(privateKey, publicKey); - const nonce = randomBytes(CHACHA_NONCE_LEN); +): Promise { + const key = await deriveTransportKey(privateKey, publicKey); + const nonce = getRandomBytes(CHACHA_NONCE_LEN); const ciphertext = chacha20poly1305(key, nonce).encrypt(plaintext); return bytesToHex(concatBytes([nonce, ciphertext])).slice(2); } @@ -247,12 +250,12 @@ export function encryptToPublic( * @param ciphertextHex - Unprefixed or `0x` hex of `nonce || ciphertext+tag`. * @returns Plaintext bytes. */ -export function decryptFromPublic( +export async function decryptFromPublic( privateKey: string, publicKey: string, ciphertextHex: string, -): Uint8Array { - const key = deriveTransportKey(privateKey, publicKey); +): Promise { + const key = await deriveTransportKey(privateKey, publicKey); const blob = decodeHex(ciphertextHex); if (blob.length < CHACHA_NONCE_LEN) { throw new Error('Wrapped secret is truncated'); @@ -262,13 +265,18 @@ export function decryptFromPublic( ); } -function deriveTransportKey(privateKey: string, publicKey: string): Uint8Array { +async function deriveTransportKey( + privateKey: string, + publicKey: string, +): Promise { const privateBytes = privateKeyBytesFromJwk(privateKey); const publicBytes = publicKeyBytesFromJwk(publicKey); const shared = p256.getSharedSecret(privateBytes, publicBytes); // noble returns a compressed point (33 bytes). Cubist uses the 32-byte x-coordinate. const ikm = shared.slice(1, 33); - return hkdf(sha256Sync, ikm, undefined, WRAP_INFO, CHACHA_KEY_LEN); + // An empty salt is equivalent to the previous `undefined` salt: HMAC zero-pads + // the key to the block size either way, so derived keys are unchanged. + return hkdfSha256(ikm, new Uint8Array(0), WRAP_INFO, CHACHA_KEY_LEN); } function privateKeyBytesFromJwk(privateKey: string): Uint8Array { @@ -309,14 +317,6 @@ function toBase64Url(bytes: Uint8Array): string { .replace(/[=]+$/u, ''); } -/** - * @param length - Number of bytes to generate. - * @returns CSPRNG bytes. - */ -function randomBytes(length: number): Uint8Array { - return globalThis.crypto.getRandomValues(new Uint8Array(length)); -} - /** * Decodes unpadded base64url, as used by JWK coordinates and JWT segments. * 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 405bf5a4c4a..3fa9b8033a7 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 @@ -153,7 +153,7 @@ describe('CubistEscrowProvider', () => { describe('applyMutation', () => { it('invokes applyMutation, including a null identifier authorization', async () => { - const receipt = signedReceipt(); + const receipt = await signedReceipt(); const { provider, invoke } = createProvider({ stdout: receipt }); expect( await provider.applyMutation(MUTATION, TOKEN, null, PAYLOAD), @@ -312,10 +312,10 @@ describe('CubistEscrowProvider', () => { describe('verifyReceipt', () => { const { provider } = createProvider(); - it('accepts a receipt signed by the pinned receipt key', () => { - expect(provider.verifyReceipt(signedReceipt(), MUTATION, 'cubist')).toBe( - true, - ); + it('accepts a receipt signed by the pinned receipt key', async () => { + expect( + await provider.verifyReceipt(await signedReceipt(), MUTATION, 'cubist'), + ).toBe(true); }); it.each([ @@ -324,30 +324,33 @@ describe('CubistEscrowProvider', () => { ['escrow id', { escrowId: 'other' }], ['version', { version: 2 }], ['receipt key id', { receiptKeyId: '0xother' }], - ] as const)('rejects a receipt with a different %s', (_label, patch) => { - expect( - provider.verifyReceipt( - { ...signedReceipt(), ...patch }, - MUTATION, - 'cubist', - ), - ).toBe(false); - }); + ] as const)( + 'rejects a receipt with a different %s', + async (_label, patch) => { + expect( + await provider.verifyReceipt( + { ...(await signedReceipt()), ...patch }, + MUTATION, + 'cubist', + ), + ).toBe(false); + }, + ); - it('rejects a receipt aimed at a different escrow id', () => { + it('rejects a receipt aimed at a different escrow id', async () => { expect( - provider.verifyReceipt( - { ...signedReceipt(), escrowId: 'other' }, + await provider.verifyReceipt( + { ...(await signedReceipt()), escrowId: 'other' }, MUTATION, 'other', ), ).toBe(false); }); - it('rejects a receipt with a bad signature', () => { + it('rejects a receipt with a bad signature', async () => { expect( - provider.verifyReceipt( - { ...signedReceipt(), signature: '00' }, + await provider.verifyReceipt( + { ...(await signedReceipt()), signature: '00' }, MUTATION, 'cubist', ), @@ -401,17 +404,22 @@ function createProvider( }; } -function signedReceipt(patch: Partial = {}): MutationReceipt { +async function signedReceipt( + patch: Partial = {}, +): Promise { const unsigned = { mutationId: MUTATION.id, requestHash: MUTATION.requestHash, escrowId: 'cubist', version: MUTATION.newVersion, - receiptKeyId: wrapKeyId(receiptKey.publicKey), + receiptKeyId: await wrapKeyId(receiptKey.publicKey), ...patch, }; return { ...unsigned, - signature: sign(receiptKey.privateKey, hashMutationReceipt(unsigned)), + signature: await sign( + receiptKey.privateKey, + await hashMutationReceipt(unsigned), + ), }; } 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 b44e31f5674..90cbd7299e3 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 @@ -157,25 +157,25 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { * @param expectedEscrowId - Escrow identity expected by the caller. * @returns Whether the receipt is valid for this escrow. */ - verifyReceipt( + async verifyReceipt( receipt: MutationReceipt, mutation: Mutation, expectedEscrowId: string, - ): boolean { + ): Promise { if ( receipt.mutationId !== mutation.id || receipt.requestHash !== mutation.requestHash || receipt.escrowId !== expectedEscrowId || expectedEscrowId !== this.id || receipt.version !== mutation.newVersion || - receipt.receiptKeyId !== wrapKeyId(this.#receiptPublicKey) + receipt.receiptKeyId !== (await wrapKeyId(this.#receiptPublicKey)) ) { return false; } - return verifySignature( + return await verifySignature( this.#receiptPublicKey, receipt.signature, - hashMutationReceipt({ + await hashMutationReceipt({ escrowId: receipt.escrowId, mutationId: receipt.mutationId, receiptKeyId: receipt.receiptKeyId, diff --git a/packages/mfa-recovery-controller/src/escrow-utils.ts b/packages/mfa-recovery-controller/src/escrow-utils.ts index fa04acf1c3e..a0e39f157d3 100644 --- a/packages/mfa-recovery-controller/src/escrow-utils.ts +++ b/packages/mfa-recovery-controller/src/escrow-utils.ts @@ -57,16 +57,16 @@ export function isMutationReceipt(value: unknown): value is MutationReceipt { * @param escrow - Expected target escrow provider. * @returns Whether the receipt is valid for the target. */ -export function verifyMutationReceipt( +export async function verifyMutationReceipt( receipt: MutationReceipt, mutation: Mutation, escrow: RecoveryEscrowProvider, -): boolean { +): Promise { if (receipt.escrowId !== escrow.id) { return false; } try { - return escrow.verifyReceipt(receipt, mutation, escrow.id); + return await escrow.verifyReceipt(receipt, mutation, escrow.id); } catch { return false; } diff --git a/packages/mfa-recovery-controller/src/identifier-auth.ts b/packages/mfa-recovery-controller/src/identifier-auth.ts index 0df9298bed2..1369f393eb0 100644 --- a/packages/mfa-recovery-controller/src/identifier-auth.ts +++ b/packages/mfa-recovery-controller/src/identifier-auth.ts @@ -101,7 +101,7 @@ export async function authorizeEscrowsWithToken({ ); const authorizationResults = await Promise.allSettled( challenges.map(async ({ escrow, challenge }) => { - const message = hash([token, challenge.id, token.requestHash]); + const message = await hash([token, challenge.id, token.requestHash]); return { escrow, authorization: { @@ -110,7 +110,7 @@ export async function authorizeEscrowsWithToken({ proof: { challengeId: challenge.id, requestHash: token.requestHash, - signature: sign(proofPrivateKey, message), + signature: await sign(proofPrivateKey, message), }, }, }; diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts index 3bf11dc83ca..8755c79c22f 100644 --- a/packages/mfa-recovery-controller/src/pending-operation-validation.ts +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -88,13 +88,13 @@ export async function assertValidPendingOperation( if (mutation.operation === 'register' && mutation.expectedVersion !== 0) { throwInvalidPendingOperation(); } - if (hash(payload) !== mutation.payloadHash) { + if ((await hash(payload)) !== mutation.payloadHash) { throw new MfaRecoveryError( 'Pending payload does not match mutation', 'payload_mismatch', ); } - const requestHash = hash({ + const requestHash = await hash({ id: mutation.id, profileId: mutation.profileId, operation: mutation.operation, diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index d394dfc9bbb..dd34e59e3bf 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -297,7 +297,7 @@ export type RecoveryEscrowProvider = { receipt: MutationReceipt, mutation: Mutation, expectedEscrowId: string, - ) => boolean; + ) => Promise; }; /** diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index 53ec2632131..d7bfb04c53c 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -117,7 +117,9 @@ export class StubAuthProvider implements RecoveryAuthProvider { ...(params.identifiers === undefined ? {} : { - identifiersHash: hash(canonicalizeIdentifiers(params.identifiers)), + identifiersHash: await hash( + canonicalizeIdentifiers(params.identifiers), + ), }), }); } @@ -137,7 +139,7 @@ export class StubIdentifierAuthProvider implements RecoveryIdentifierAuthProvide proofPublicKey: params.proofPublicKey, requestHash: params.requestHash, providerAssertion: { - bound: hash({ + bound: await hash({ proofPublicKey: params.proofPublicKey, requestHash: params.requestHash, }), @@ -246,7 +248,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { 'get_secret_failed', ); } - const requestHash = hash({ + const requestHash = await hash({ operation: 'getRecoverySecret', requestId, pkE, @@ -261,7 +263,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { } return { recoverySecret: { - ciphertext: encryptToPublic( + ciphertext: await encryptToPublic( this.#wrapPrivateKey, JSON.stringify(pkE), decodeHex(record.recoverySecret), @@ -269,7 +271,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { }, version: record.version, lastMutationId: record.lastMutationId, - wrapKeyId: wrapKeyId(this.wrapPublicKey), + wrapKeyId: await wrapKeyId(this.wrapPublicKey), }; } @@ -338,7 +340,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { throw new MfaRecoveryError('Invalid registration', 'invalid_register'); } const registerPayload = payload as RegisterPayload; - const recoverySecret = this.#unwrapPayloadSecret( + const recoverySecret = await this.#unwrapPayloadSecret( registerPayload.recoverySecret, ); await this.#assertPayloadHash(mutation, { @@ -398,7 +400,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { } if (mutation.operation === 'updateRecoverySecret') { - const recoverySecret = this.#unwrapPayloadSecret( + const recoverySecret = await this.#unwrapPayloadSecret( (payload as UpdateRecoverySecretPayload).recoverySecret, ); await this.#assertPayloadHash(mutation, { @@ -428,11 +430,11 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { return await this.#signReceipt(mutation); } - verifyReceipt( + async verifyReceipt( receipt: MutationReceipt, mutation: Mutation, expectedEscrowId: string, - ): boolean { + ): Promise { if (this.invalidReceipts) { return false; } @@ -442,14 +444,14 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { receipt.escrowId !== expectedEscrowId || expectedEscrowId !== this.id || receipt.version !== mutation.newVersion || - receipt.receiptKeyId !== wrapKeyId(this.receiptPublicKey) + receipt.receiptKeyId !== (await wrapKeyId(this.receiptPublicKey)) ) { return false; } - return verifySignature( + return await verifySignature( this.receiptPublicKey, receipt.signature, - hashMutationReceipt({ + await hashMutationReceipt({ escrowId: receipt.escrowId, mutationId: receipt.mutationId, receiptKeyId: receipt.receiptKeyId, @@ -503,11 +505,14 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { requestHash: mutation.requestHash, escrowId: this.id, version: mutation.newVersion, - receiptKeyId: wrapKeyId(this.receiptPublicKey), + receiptKeyId: await wrapKeyId(this.receiptPublicKey), }; return { ...unsigned, - signature: sign(this.#receiptPrivateKey, hashMutationReceipt(unsigned)), + signature: await sign( + this.#receiptPrivateKey, + await hashMutationReceipt(unsigned), + ), }; } @@ -515,7 +520,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { mutation: Mutation, token: AuthControllerToken, ): Promise { - const requestHash = hash({ + const requestHash = await hash({ id: mutation.id, profileId: mutation.profileId, operation: mutation.operation, @@ -552,7 +557,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { ): Promise { if ( decodeAuthControllerToken(token)?.ext.identifiersHash !== - hash(canonicalizeIdentifiers(identifiers)) + (await hash(canonicalizeIdentifiers(identifiers))) ) { throw new MfaRecoveryError( 'Identifier ownership not approved', @@ -589,8 +594,10 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { ) { throw new MfaRecoveryError('Invalid PoP challenge', 'invalid_challenge'); } - const message = hash([token, proof.challengeId, requestHash]); - if (!verifySignature(token.proofPublicKey, proof.signature, message)) { + const message = await hash([token, proof.challengeId, requestHash]); + if ( + !(await verifySignature(token.proofPublicKey, proof.signature, message)) + ) { throw new MfaRecoveryError('Invalid PoP signature', 'invalid_pop'); } challenge.consumed = true; @@ -636,7 +643,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { mutation: Mutation, payload: unknown, ): Promise { - if (hash(payload) !== mutation.payloadHash) { + if ((await hash(payload)) !== mutation.payloadHash) { throw new MfaRecoveryError( 'Mutation payload does not match payloadHash', 'payload_mismatch', @@ -644,9 +651,9 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { } } - #unwrapPayloadSecret(wrapped: WrappedSecret): string { + async #unwrapPayloadSecret(wrapped: WrappedSecret): Promise { return bytesToHex( - decryptFromPublic( + await decryptFromPublic( this.#wrapPrivateKey, JSON.stringify(wrapped.pkE), wrapped.ciphertext, diff --git a/packages/mfa-recovery-controller/tsconfig.build.json b/packages/mfa-recovery-controller/tsconfig.build.json index c145368e144..cf795055652 100644 --- a/packages/mfa-recovery-controller/tsconfig.build.json +++ b/packages/mfa-recovery-controller/tsconfig.build.json @@ -8,6 +8,9 @@ { "path": "../base-controller/tsconfig.build.json" }, + { + "path": "../cryptography/tsconfig.build.json" + }, { "path": "../messenger/tsconfig.build.json" }, diff --git a/packages/mfa-recovery-controller/tsconfig.json b/packages/mfa-recovery-controller/tsconfig.json index 719d902d0a4..fdeaa07f238 100644 --- a/packages/mfa-recovery-controller/tsconfig.json +++ b/packages/mfa-recovery-controller/tsconfig.json @@ -4,6 +4,9 @@ { "path": "../base-controller" }, + { + "path": "../cryptography" + }, { "path": "../messenger" }, diff --git a/packages/mfa-recovery-controller/tsconfig.lint.json b/packages/mfa-recovery-controller/tsconfig.lint.json index 6165159ae46..829917a673f 100644 --- a/packages/mfa-recovery-controller/tsconfig.lint.json +++ b/packages/mfa-recovery-controller/tsconfig.lint.json @@ -11,6 +11,9 @@ { "path": "../base-controller/tsconfig.lint.json" }, + { + "path": "../cryptography/tsconfig.lint.json" + }, { "path": "../utils/tsconfig.lint.json" } diff --git a/yarn.lock b/yarn.lock index 9d42638057b..0dfe9ea7c0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7710,11 +7710,11 @@ __metadata: "@cubist-labs/cubesigner-sdk-fs-storage": "npm:^0.4.284" "@metamask/auto-changelog": "npm:^6.2.1" "@metamask/base-controller": "npm:^10.0.0" + "@metamask/cryptography": "npm:^1.1.2" "@metamask/messenger": "npm:^3.0.0" "@metamask/utils": "npm:^12.0.0" "@noble/ciphers": "npm:^1.3.0" "@noble/curves": "npm:^1.9.7" - "@noble/hashes": "npm:^1.8.0" "@types/jest": "npm:^30.0.0" "@typescript/native": "npm:typescript@^7.0.2" deepmerge: "npm:^4.3.1" From a141908f5bdd6ba55bbdd4cc7ded9128ae790586 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:03:30 +0800 Subject: [PATCH 41/55] feat: add @metamask/superstruct for structured data validation in mfa-recovery-controller - Integrated `@metamask/superstruct` to enhance data validation for mutation receipts and other structures. - Refactored `isMutationReceipt` function to utilize `superstruct` for improved validation logic. - Added unit tests for `isMutationReceipt` to ensure correct handling of valid and invalid receipt shapes. - Updated `cubist-escrow-provider` to leverage structured validation for response parsing, enhancing error handling. - Included new test cases to validate receipt rejection scenarios based on various invalid fields. --- packages/mfa-recovery-controller/package.json | 1 + .../cubist-escrow-provider.test.ts | 14 ++++++ .../cubist-escrow-provider.ts | 43 +++++++++---------- .../src/escrow-utils.test.ts | 36 ++++++++++++++++ .../src/escrow-utils.ts | 27 ++++++------ yarn.lock | 1 + 6 files changed, 85 insertions(+), 37 deletions(-) create mode 100644 packages/mfa-recovery-controller/src/escrow-utils.test.ts diff --git a/packages/mfa-recovery-controller/package.json b/packages/mfa-recovery-controller/package.json index 7a427b3e5e7..fc70e3fdfac 100644 --- a/packages/mfa-recovery-controller/package.json +++ b/packages/mfa-recovery-controller/package.json @@ -56,6 +56,7 @@ "@metamask/base-controller": "^10.0.0", "@metamask/cryptography": "^1.1.2", "@metamask/messenger": "^3.0.0", + "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^12.0.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7" 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 3fa9b8033a7..986e3e3bc32 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 @@ -176,6 +176,20 @@ describe('CubistEscrowProvider', () => { ).rejects.toMatchObject({ code: 'escrow_response_invalid' }); }); + it.each([ + ['fractional version', { version: 1.5 }], + ['negative version', { version: -1 }], + ['non-numeric version', { version: '1' }], + ['non-string signature', { signature: 1 }], + ['missing escrow id', { escrowId: undefined }], + ])('rejects a receipt with a %s', async (_label, patch) => { + const receipt = { ...(await signedReceipt()), ...patch }; + const { provider } = createProvider({ stdout: receipt }); + await expect( + provider.applyMutation(MUTATION, TOKEN, AUTHORIZATION, PAYLOAD), + ).rejects.toMatchObject({ code: 'escrow_response_invalid' }); + }); + it('reports a policy deny as a mutation rejection', async () => { const { provider } = createProvider({ policy: { response: 'Deny', reason: 'version mismatch' }, 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 90cbd7299e3..50b6dd6c79f 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,6 +3,7 @@ import type { InvokePolicyResponse, Version, } from '@cubist-labs/cubesigner-sdk'; +import { integer, is, literal, min, string, type } from '@metamask/superstruct'; import { bytesToString, hexToBytes } from '@metamask/utils'; import { hashMutationReceipt, verifySignature, wrapKeyId } from '../crypto.js'; @@ -227,8 +228,25 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { } } +// `type()` is non-strict, so unknown fields are ignored. The explicit field +// mapping below still strips them from the returned value. +const PingStruct = type({ ok: literal(true) }); + +const ChallengeStruct = type({ + id: string(), + escrowId: literal(ESCROW_ID), + expiresAt: integer(), +}); + +const GetSecretResponseStruct = type({ + recoverySecret: type({ ciphertext: string() }), + version: min(integer(), 0), + lastMutationId: string(), + wrapKeyId: string(), +}); + function parsePing(value: unknown): void { - if (!isRecord(value) || value.ok !== true) { + if (!is(value, PingStruct)) { throw new MfaRecoveryError( 'Invalid ping response', 'escrow_response_invalid', @@ -237,13 +255,7 @@ function parsePing(value: unknown): void { } function parseChallenge(value: unknown): PoPChallenge { - if ( - !isRecord(value) || - typeof value.id !== 'string' || - value.escrowId !== ESCROW_ID || - typeof value.expiresAt !== 'number' || - !Number.isInteger(value.expiresAt) - ) { + if (!is(value, ChallengeStruct)) { throw new MfaRecoveryError( 'Invalid PoP challenge', 'escrow_response_invalid', @@ -257,16 +269,7 @@ function parseChallenge(value: unknown): PoPChallenge { } function parseGetSecretResponse(value: unknown): GetRecoverySecretResponse { - if ( - !isRecord(value) || - !isRecord(value.recoverySecret) || - typeof value.recoverySecret.ciphertext !== 'string' || - typeof value.version !== 'number' || - !Number.isInteger(value.version) || - value.version < 0 || - typeof value.lastMutationId !== 'string' || - typeof value.wrapKeyId !== 'string' - ) { + if (!is(value, GetSecretResponseStruct)) { throw new MfaRecoveryError( 'Invalid recovery secret response', 'escrow_response_invalid', @@ -289,7 +292,3 @@ function parseReceipt(value: unknown): MutationReceipt { } return value; } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} diff --git a/packages/mfa-recovery-controller/src/escrow-utils.test.ts b/packages/mfa-recovery-controller/src/escrow-utils.test.ts new file mode 100644 index 00000000000..e30b22a979a --- /dev/null +++ b/packages/mfa-recovery-controller/src/escrow-utils.test.ts @@ -0,0 +1,36 @@ +import { isMutationReceipt } from './escrow-utils.js'; + +const RECEIPT = { + mutationId: '0x1', + requestHash: '0x2', + escrowId: 'cubist', + version: 1, + receiptKeyId: '0xabc', + signature: '0xdef', +}; + +describe('isMutationReceipt', () => { + it('accepts a receipt with the expected shape', () => { + expect(isMutationReceipt(RECEIPT)).toBe(true); + }); + + it('accepts extra fields', () => { + expect(isMutationReceipt({ ...RECEIPT, extra: true })).toBe(true); + }); + + it.each([ + ['null', null], + ['a string', 'receipt'], + ['an array', [RECEIPT]], + ['a missing mutation id', { ...RECEIPT, mutationId: undefined }], + ['a non-string request hash', { ...RECEIPT, requestHash: 2 }], + ['a non-string escrow id', { ...RECEIPT, escrowId: 1 }], + ['a fractional version', { ...RECEIPT, version: 1.5 }], + ['a NaN version', { ...RECEIPT, version: Number.NaN }], + ['a negative version', { ...RECEIPT, version: -1 }], + ['a non-string receipt key id', { ...RECEIPT, receiptKeyId: null }], + ['a non-string signature', { ...RECEIPT, signature: 1 }], + ])('rejects %s', (_label, value) => { + expect(isMutationReceipt(value)).toBe(false); + }); +}); diff --git a/packages/mfa-recovery-controller/src/escrow-utils.ts b/packages/mfa-recovery-controller/src/escrow-utils.ts index a0e39f157d3..0712126e12a 100644 --- a/packages/mfa-recovery-controller/src/escrow-utils.ts +++ b/packages/mfa-recovery-controller/src/escrow-utils.ts @@ -1,3 +1,4 @@ +import { integer, is, min, string, type } from '@metamask/superstruct'; import { areUint8ArraysEqual } from '@metamask/utils'; import { MfaRecoveryError } from './errors.js'; @@ -26,6 +27,16 @@ export function isFulfilledResult( return result.status === 'fulfilled'; } +// `type()` (not `object()`) so unknown fields are still accepted, as before. +const MutationReceiptStruct = type({ + mutationId: string(), + requestHash: string(), + escrowId: string(), + version: min(integer(), 0), + receiptKeyId: string(), + signature: string(), +}); + /** * Checks the serialized shape of a mutation receipt. * @@ -33,17 +44,7 @@ export function isFulfilledResult( * @returns Whether the value has a mutation receipt shape. */ export function isMutationReceipt(value: unknown): value is MutationReceipt { - return ( - isRecord(value) && - typeof value.mutationId === 'string' && - typeof value.requestHash === 'string' && - typeof value.escrowId === 'string' && - typeof value.version === 'number' && - Number.isInteger(value.version) && - value.version >= 0 && - typeof value.receiptKeyId === 'string' && - typeof value.signature === 'string' - ); + return is(value, MutationReceiptStruct); } /** @@ -109,7 +110,3 @@ export function selectHighestConsistentVersion( } return selected; } - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} diff --git a/yarn.lock b/yarn.lock index 0dfe9ea7c0c..a9a8f1b8492 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7712,6 +7712,7 @@ __metadata: "@metamask/base-controller": "npm:^10.0.0" "@metamask/cryptography": "npm:^1.1.2" "@metamask/messenger": "npm:^3.0.0" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^12.0.0" "@noble/ciphers": "npm:^1.3.0" "@noble/curves": "npm:^1.9.7" From 1e6a2acde0a08f5406a69638950486a66bc9a067 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:10:25 +0800 Subject: [PATCH 42/55] feat: integrate json-stable-stringify for enhanced JSON canonicalization - Added `json-stable-stringify` to the `mfa-recovery-controller` package for improved JSON serialization. - Updated the `canonicalize` function to utilize `json-stable-stringify`, ensuring consistent output for complex data structures. - Introduced new unit tests to validate the handling of `Uint8Array` values and the removal of undefined values in objects and arrays. - Removed the previous `sortKeys` function, streamlining the canonicalization process. --- packages/mfa-recovery-controller/package.json | 3 +- .../src/crypto.test.ts | 12 ++++++++ .../mfa-recovery-controller/src/crypto.ts | 29 ++++--------------- yarn.lock | 28 ++++++++++++++++++ 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/packages/mfa-recovery-controller/package.json b/packages/mfa-recovery-controller/package.json index fc70e3fdfac..1b99e28519f 100644 --- a/packages/mfa-recovery-controller/package.json +++ b/packages/mfa-recovery-controller/package.json @@ -59,7 +59,8 @@ "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^12.0.0", "@noble/ciphers": "^1.3.0", - "@noble/curves": "^1.9.7" + "@noble/curves": "^1.9.7", + "json-stable-stringify": "^1.3.0" }, "devDependencies": { "@cubist-labs/cubesigner-sdk-fs-storage": "^0.4.284", diff --git a/packages/mfa-recovery-controller/src/crypto.test.ts b/packages/mfa-recovery-controller/src/crypto.test.ts index 5deedd934e9..dd6ea6a0587 100644 --- a/packages/mfa-recovery-controller/src/crypto.test.ts +++ b/packages/mfa-recovery-controller/src/crypto.test.ts @@ -33,6 +33,18 @@ describe('crypto', () => { ); }); + it('canonicalizes Uint8Array values nested in arrays and objects as hex', () => { + expect( + canonicalize({ list: [new Uint8Array([1]), { z: new Uint8Array([2]) }] }), + ).toBe(`{"list":["0x01",{"z":"0x02"}]}`); + }); + + it('drops undefined object values and nulls undefined array items', () => { + expect(canonicalize({ a: undefined, b: [undefined, 1] })).toBe( + '{"b":[null,1]}', + ); + }); + it('hashes independently of key order', async () => { expect(await hash({ b: 1, a: 2 })).toBe(await hash({ a: 2, b: 1 })); }); diff --git a/packages/mfa-recovery-controller/src/crypto.ts b/packages/mfa-recovery-controller/src/crypto.ts index 29755043cde..97289dab366 100644 --- a/packages/mfa-recovery-controller/src/crypto.ts +++ b/packages/mfa-recovery-controller/src/crypto.ts @@ -12,6 +12,7 @@ import { import type { Hex } from '@metamask/utils'; import { chacha20poly1305 } from '@noble/ciphers/chacha'; import { p256 } from '@noble/curves/nist'; +import stableStringify from 'json-stable-stringify'; import type { EcPublicJwk, Identifier } from './types.js'; @@ -26,7 +27,10 @@ const CHACHA_NONCE_LEN = 12; * @returns A canonical JSON string. */ export function canonicalize(value: unknown): string { - return JSON.stringify(sortKeys(value)); + return stableStringify(value, { + replacer: (_key, node) => + node instanceof Uint8Array ? bytesToHex(node) : node, + }) as string; } /** @@ -331,26 +335,3 @@ export function fromBase64Url(value: string): Uint8Array { .padEnd(value.length + ((4 - (value.length % 4)) % 4), '='), ); } - -/** - * Recursively sorts keys and encodes Uint8Array as hex. - * - * @param value - Value to normalize. - * @returns JSON-safe canonical form. - */ -function sortKeys(value: unknown): unknown { - if (value instanceof Uint8Array) { - return bytesToHex(value); - } - if (Array.isArray(value)) { - return value.map(sortKeys); - } - if (value !== null && typeof value === 'object') { - return Object.fromEntries( - Object.keys(value as Record) - .sort() - .map((key) => [key, sortKeys((value as Record)[key])]), - ); - } - return value; -} diff --git a/yarn.lock b/yarn.lock index a9a8f1b8492..bb565203479 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7720,6 +7720,7 @@ __metadata: "@typescript/native": "npm:typescript@^7.0.2" deepmerge: "npm:^4.3.1" jest: "npm:^30.5.2" + json-stable-stringify: "npm:^1.3.0" rimraf: "npm:^6.1.3" ts-jest: "npm:^29.4.14" tsx: "npm:^4.20.5" @@ -19878,6 +19879,13 @@ __metadata: languageName: node linkType: hard +"isarray@npm:^2.0.5": + version: 2.0.5 + resolution: "isarray@npm:2.0.5" + checksum: 10/1d8bc7911e13bb9f105b1b3e0b396c787a9e63046af0b8fe0ab1414488ab06b2b099b87a2d8a9e31d21c9a6fad773c7fc8b257c4880f2d957274479d28ca3414 + languageName: node + linkType: hard + "isarray@npm:~1.0.0": version: 1.0.0 resolution: "isarray@npm:1.0.0" @@ -20727,6 +20735,19 @@ __metadata: languageName: node linkType: hard +"json-stable-stringify@npm:^1.3.0": + version: 1.3.0 + resolution: "json-stable-stringify@npm:1.3.0" + dependencies: + call-bind: "npm:^1.0.8" + call-bound: "npm:^1.0.4" + isarray: "npm:^2.0.5" + jsonify: "npm:^0.0.1" + object-keys: "npm:^1.1.1" + checksum: 10/6661e9704733d2826b2012fea7b152ca216c82d8c725c8d390ee6434eabdf43c66fa6e6b423cce9bf95f8fec0ef52004c09a99043c7daf6e58595a0cff204629 + languageName: node + linkType: hard + "json-stringify-safe@npm:^5.0.1": version: 5.0.1 resolution: "json-stringify-safe@npm:5.0.1" @@ -20763,6 +20784,13 @@ __metadata: languageName: node linkType: hard +"jsonify@npm:^0.0.1": + version: 0.0.1 + resolution: "jsonify@npm:0.0.1" + checksum: 10/7b86b6f4518582ff1d8b7624ed6c6277affd5246445e864615dbdef843a4057ac58587684faf129ea111eeb80e01c15f0a4d9d03820eb3f3985fa67e81b12398 + languageName: node + linkType: hard + "jsonschema@npm:^1.4.1": version: 1.4.1 resolution: "jsonschema@npm:1.4.1" From 62d96293f704d687d029bd775e86bcff721cbacd Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:21:52 +0800 Subject: [PATCH 43/55] chore: update CHANGELOG for initial release of MfaRecoveryController - Documented the initial release of the `MfaRecoveryController` in the changelog. - Removed detailed entries from the previous changelog section to streamline the release notes. --- packages/mfa-recovery-controller/CHANGELOG.md | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 84b2457cde2..bd08fd022fa 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -9,28 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `MfaRecoveryController` for replicating an MFA recovery secret across injected escrow replicas ([#10022](https://github.com/MetaMask/core/pull/10022)) - - Public methods: `register`, `updateRecoverySecret`, `updateIdentifiers`, `authenticateIdentifier`, `getRecoverySecret`, `resume`, `abort`, and `getPhase` - - Inject `RecoveryAuthProvider`, `RecoveryIdentifierAuthProvider`, `RecoveryEscrowProvider[]`, and `PendingOperationEncryptor` - - Persist only an encrypted `authorizing` / `writing` pending mutation (`idle` when `pendingOperation` is `null`) - - `register` and `updateIdentifiers` require at least two distinct identifiers (`MIN_IDENTIFIERS`) - - Identifier types `passkey`, `oidc`, and `siwe` are key-bound; `emailOtp` / `smsOtp` are not wired yet - - `getRecoverySecret` returns `{ recoverySecret, epoch }`; pass that `epoch` into later `updateRecoverySecret` / `updateIdentifiers` calls (register uses `0`). Does not block on a pending mutation; returns the highest consistent version across available escrows - - Mutation `payloadHash` is the hash of the logical pending payload (`identifiers` and/or `0x`-hex secret). At apply, each escrow receives `{ pkE, ciphertext }` wrapped to its own wrap key with escrow-wrap-v1 - - `getSecret` request hashes bind the client's ephemeral `pkE`; responses are `{ ciphertext }` plus `wrapKeyId` - - `Mutation.audiences` is the configured replica-id list, in that order. Each replica should require an exact match - - `MutationReceipt` includes `receiptKeyId`. `RecoveryEscrowProvider.verifyReceipt` takes the expected escrow id, verifies with that replica's receipt key, and returns a `Promise` - - `AuthControllerToken.expiresAt` and `PoPChallenge.expiresAt` are Unix seconds - - Mutations require idle pending state (`resume()` / `abort()` first). `abort()` drops `authorizing` only; `writing` must be finished with `resume()` - - `CubistEscrowProvider` invokes Cubist escrow commands through `@cubist-labs/cubesigner-sdk` `policyInvoke` (`ping`, `generateChallenge`, `applyMutation`, `getSecret` on `cubist_secret_escrow`) and verifies receipts with a pinned receipt public key - - `authenticateIdentifier` returns an in-memory `IdentifierSession` for key-bound recovery-secret reads - -### Changed - -- **BREAKING:** `getRecoverySecret` now accepts an `IdentifierSession` returned by `authenticateIdentifier` instead of an `Identifier` - -### Fixed - -- Clear a pending mutation when every escrow rejects it before applying, so a second `register` or an update with a stale epoch does not stay in `writing`. `resume()` still retries an incomplete write, and `abort()` still refuses a `writing` mutation that may have been applied +- Initial release ([#10022](https://github.com/MetaMask/core/pull/10022)) [Unreleased]: https://github.com/MetaMask/core/ From 891b9f74a81dcaaba2c53987ad811c682e779561 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:38:38 +0800 Subject: [PATCH 44/55] chore: update yarn.lock to consolidate zod dependency versions - Merged the entries for `zod` from version `4.4.3` into `4.3.6`, ensuring a single version (`4.6.6`) is used across the project. - Removed the redundant `zod@npm:^4.4.3` entry to streamline the dependency management. --- yarn.lock | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index bb565203479..9e957af5522 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28946,20 +28946,13 @@ __metadata: languageName: node linkType: hard -"zod@npm:^4.3.6": +"zod@npm:^4.3.6, zod@npm:^4.4.3": version: 4.6.6 resolution: "zod@npm:4.6.6" checksum: 10/ce628fb33400b531f48b63ad1bd9b5ab95ec3ac60d4e8025892d90af9ca5d03b168aaaf74071928345ad1b1bcd735a7d5cf33230ece7de885240bb0f2b7bb492 languageName: node linkType: hard -"zod@npm:^4.4.3": - version: 4.6.5 - resolution: "zod@npm:4.6.5" - checksum: 10/db004952e4dc6eab38b70f0cdef9251a65da84f5d08c5a0ef85e2590cfa9c477544ce734161ff5c05f89a15f0967137607a7feb16716195e04965f030db47213 - languageName: node - linkType: hard - "zwitch@npm:^2.0.0": version: 2.0.4 resolution: "zwitch@npm:2.0.4" From 61ddf80304fe31b25ab6f3a8efd04d245e647304 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:52:32 +0800 Subject: [PATCH 45/55] docs: enhance architecture documentation for mfa-recovery-controller - Updated the architecture documentation to clarify roles and responsibilities of components, including `pendingOperationEncryptor` and `now`. - Improved descriptions of identifier types and their authentication modes. - Added details on pending state validation and mutation sequence for better understanding of the recovery process. - Streamlined the public surface section to reflect all exposed messenger actions. - Enhanced clarity on the state machine transitions and rules for mutation handling. --- .../mfa-recovery-controller/ARCHITECTURE.md | 219 +++++++++++++----- 1 file changed, 157 insertions(+), 62 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 08121ee0463..7d213c158d0 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -30,28 +30,40 @@ flowchart LR Ctrl --> EB ``` -| Piece | Role | -| ------------------------ | ------------------------------------------------------------------------- | -| `pendingOperation` | Only persisted field: encrypted `authorizing` / `writing` blob, or `null` | -| `escrows[]` | Build-time replica set; mutations require **all** of them | -| `authProvider` | Profile id + request-bound AuthController token | -| `identifierAuthProvider` | Key-bound IdP assertion (passkey / OIDC / SIWE) | -| `#withLock` | Serializes public methods so two mutations cannot interleave | +| Piece | Role | +| --------------------------- | --------------------------------------------------------------------------- | +| `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) | +| `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). Identifier types pick the auth mode from a trusted table, not a client flag: -`passkey` / `oidc` / `siwe` → key-bound. `emailOtp` / `smsOtp` escrow-challenge -is not wired yet (MFA-605 / MFA-606 / MFA-568). +`passkey` / `oidc` / `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). -`register` and `updateIdentifiers` require at least two identifiers. +`register` and `updateIdentifiers` require at least two **distinct** +identifiers (by `type` + `namespace` + `value`). -Recovery secrets are wrapped in transit with escrow-wrap-v1 (P-256 ECDH, HKDF, -ChaCha20-Poly1305). Pending state stores the secret as 0x-hex; `payloadHash` -is the hash of that pending payload. At apply, each escrow gets -`{ pkE, ciphertext }` wrapped to its own wrap key. Reads bind the client's -ephemeral `pkE` into the `getSecret` request hash. +Recovery secrets are wrapped in transit with escrow-wrap-v1: P-256 ECDH (x +coordinate), HKDF-SHA256, ChaCha20-Poly1305; ciphertext is hex of +`nonce || ciphertext+tag`. Pending state stores the secret as 0x-hex; +`payloadHash` is the hash of that pending payload. At apply, each escrow gets +`{ pkE, ciphertext }` wrapped to its own `wrapPublicKey` with a fresh +ephemeral key. Reads bind the client's ephemeral `pkE` into the `getSecret` +request hash, and the escrow wraps its response to `pkE`. -Public surface: `register`, `updateRecoverySecret`, `updateIdentifiers`, -`authenticateIdentifier`, `getRecoverySecret`, `resume`, `abort`, `getPhase`. +Public surface (all exposed as messenger actions): `register`, +`updateRecoverySecret`, `updateIdentifiers`, `authenticateIdentifier`, +`getRecoverySecret`, `resume`, `abort`, `getPhase`. ## State machine @@ -65,18 +77,41 @@ stateDiagram-v2 authorizing --> writing: identifier auth succeeds, before first apply writing --> writing: more receipts, still missing replicas writing --> idle: every configured escrow has a valid receipt + writing --> idle: every escrow rejects and none has a receipt writing --> writing: resume() retries only missing receipts authorizing --> writing: resume() authorizes then writes ``` Rules: -- **`abort()`** drops `authorizing` only. `writing` must `resume()`. -- `writing` is persisted before the **first escrow apply** after identifier - authorization succeeds, so an ambiguous apply failure remains resumable while - pre-write authorization failures stay abortable. +- **`abort()`** drops `authorizing` only. `writing` must `resume()` + (`abort_not_allowed`). +- `writing` is persisted before the **first escrow apply**, after the + AuthController token is issued, every target escrow has accepted identifier + authorization, and the per-escrow payloads are wrapped. An ambiguous apply + failure remains resumable, while pre-write failures stay abortable. - `register` / `updateRecoverySecret` / `updateIdentifiers` require idle - pending state. `resume()` finishes a persisted mutation. + pending state (`pending_mutation`). `resume()` finishes a persisted + mutation and is a no-op when idle. + +### Pending state validation + +The decrypted blob is validated on every load and before every persist. +Anything that fails is rejected rather than acted on: + +- Shape: phase, mutation fields, `newVersion = expectedVersion + 1`, payload + keys for the operation, hex secret, at least two distinct identifiers, known + identifier types. +- `audiences` must equal the configured escrow ids, in order + (`audience_mismatch`). +- `payloadHash` must match the payload (`payload_mismatch`) and `requestHash` + 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. ## Mutation sequence (`register` / updates) @@ -87,62 +122,106 @@ sequenceDiagram participant C as Client participant M as MfaRecoveryController participant A as AuthProvider + participant I as IdentifierAuthProvider participant E as Escrows C->>M: register / updateRecoverySecret / updateIdentifiers - M->>M: lock; reject if a mutation is already pending + M->>M: lock, reject if a mutation is already pending + M->>E: isAvailable (every escrow required) M->>A: getAuthenticatedProfileId - M->>E: require every escrow available M->>M: expectedVersion from caller epoch (register uses 0) + M->>M: build mutation, requestHash = hash(mutation fields) M->>M: persist pending = authorizing M->>A: authorizeRecoveryRequest (AuthController token) + M->>M: skip escrows with a stored receipt, re-verify stored receipts + M->>E: isAvailable (remaining escrows) + M->>M: reuse token, or re-issue it if exp <= now() alt not register - M->>E: identifier auth per escrow + M->>I: getKeyBoundIdentifierToken + M->>E: generateChallenge + PoP per remaining escrow (all required) end - M->>M: persist writing before apply - par write every replica + M->>M: wrap secret per escrow, persist writing + par write every remaining replica M->>E: applyMutation end - M->>M: persist receipts - alt all receipts valid - M->>M: clear pending → idle - else every escrow rejects and none has a receipt + alt every escrow rejects and none has a receipt M->>M: clear pending → idle M-->>C: MutationRejectedError (start a new mutation) - else some replicas missing - M-->>C: IncompleteMutationError (call resume) + else + M->>M: persist valid receipts + alt a receipt failed verification + M-->>C: invalid_receipt (call resume) + else some replicas missing + M-->>C: IncompleteMutationError (call resume) + else all receipts valid + M->>M: clear pending → idle + end 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. + +Receipts: + +- A receipt counts only if it is well-formed, its `escrowId` matches the + target, and `escrow.verifyReceipt` accepts it (pinned receipt key). +- Stored receipts are re-verified before every retry. A receipt for an + escrow that is no longer configured fails with `unknown_receipt_escrow`. +- `MutationRejectedError` is only surfaced when **every** remaining escrow + threw `MutationRejectedError` and no receipt exists yet; anything else may + have been applied and stays `writing`. + Versioning: - Version lives on the mutation (`expectedVersion` / `newVersion`), which is what escrows apply. `epoch` is only the client-facing name for the current replica version returned by `getRecoverySecret`. - `register` uses expectedVersion `0` → version `0 → 1` -- updates take the caller-supplied `epoch` as `expectedVersion`, then - `n → n+1`. Escrows reject a mismatch. +- updates take the caller-supplied `epoch` (a non-negative integer, else + `invalid_epoch`) as `expectedVersion`, then `n → n+1`. Escrows reject a + mismatch. - Mutation `audiences` is the full configured replica-id list, in that order. Each replica should require an exact match to its configured set (a cubist-only deploy is `["cubist"]`; adding another escrow means every replica is rebuilt with the expanded list). -`resume()` is the same write path without creating a new mutation: authorizing -pending gets a fresh token then replicates; writing pending retries only -escrows that do not already have a stored receipt. +`resume()` is the same write path without creating a new mutation: +authorizing pending gets a fresh token then replicates; writing pending +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 The controller does not call CubeSigner, the registration API, or -`KeyringController`. A Cubist deploy injects one `RecoveryEscrowProvider` -(`id: "cubist"`). The caller proves OIDC, calls `ensure-user`, opens the -session, and constructs `CubistEscrowProvider` before `register`. +`KeyringController`. A Cubist deploy injects one `CubistEscrowProvider` +(`id` and `authAudience`: `"cubist"`). 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`. + +`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 +`applyMutation` becomes `MutationRejectedError`; any other failure is +`escrow_request_failed` / `escrow_response_invalid`. `verifyReceipt` checks +the mutation id, request hash, escrow id, `version === newVersion`, +`receiptKeyId` (SHA-256 of the pinned receipt key), and the ECDSA 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 provider persists that payload. `AuthControllerToken` -is opaque; a stub may put an SRP token in `signature`. +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`. @@ -161,15 +240,15 @@ sequenceDiagram Note over Caller,Escrow: proveOidcIdentity, ensure-user, createOidcSession, then new CubistEscrowProvider Caller->>Ctrl: register(secret, identifiers) + Ctrl->>Escrow: isAvailable (ping) Ctrl->>Auth: getAuthenticatedProfileId - Ctrl->>Escrow: isAvailable Ctrl->>Ctrl: persist authorizing Ctrl->>Auth: authorizeRecoveryRequest - Note over Auth: stub may set signature to the SRP token + Auth-->>Ctrl: AuthController token (Hydra JWT) Ctrl->>Ctrl: wrap secret, persist writing Ctrl->>Escrow: applyMutation Escrow-->>Ctrl: receipt - Ctrl->>Ctrl: clear pending + Ctrl->>Ctrl: verify receipt, clear pending Caller->>Ctrl: authenticateIdentifier(siwe) Ctrl->>IdP: getKeyBoundIdentifierToken @@ -178,11 +257,12 @@ sequenceDiagram 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 - Ctrl->>Ctrl: unwrap + Escrow-->>Ctrl: wrapped secret + version + wrapKeyId + Ctrl->>Ctrl: check wrapKeyId, unwrap Ctrl-->>Caller: secret bytes + epoch ``` @@ -202,17 +282,22 @@ sequenceDiagram C->>M: authenticateIdentifier(identifier) M->>M: generate requestId, proof key, and ephemeral pkE + M->>M: requestHash = hash(getRecoverySecret, requestId, pkE) M->>I: getKeyBoundIdentifierToken M-->>C: IdentifierSession C->>M: getRecoverySecret(session) M->>M: recompute and validate requestHash M->>E: isAvailable (failures skipped) - M->>E: authorize token (partial OK) + alt none available + M-->>C: no_available_escrow + end + M->>E: generateChallenge + PoP (partial OK) par read M->>E: getSecret + M->>M: check wrapKeyId, unwrap with ephemeral key end M->>M: selectHighestConsistentVersion - alt versions disagree + alt highest versions disagree M-->>C: Replica corruption else none succeeded M-->>C: No escrow returned a recovery secret @@ -221,27 +306,36 @@ sequenceDiagram end ``` -`IdentifierSession` contains the key-bound token, request identifiers, and -ephemeral private keys needed for the read. The controller does not persist or -store the session; callers must keep it in memory and must not log it. +`IdentifierSession` contains the key-bound token, its proof private key, the +`requestId`, and the ephemeral `pkE` key pair needed for the read. The +controller does not persist or store the session; callers must keep it in +memory and must not log it. + +A replica whose `wrapKeyId` does not match its configured `wrapPublicKey`, or +whose ciphertext fails to unwrap, counts as a failed read. Among successful +reads, the highest `version` wins; every replica at that version must agree +on `lastMutationId` and the secret bytes. -`getRecoverySecret` does not block on a pending mutation. During `writing`, -replicas may hold different versions; the read takes the highest one. The -next mutation still requires `resume()` first. +`getRecoverySecret` runs under the controller lock but does not check pending +state. During `writing`, replicas may hold different versions; the read takes +the highest one. The next mutation still requires `resume()` first. ## Identifier auth (inside writes and reads) ```mermaid flowchart TD Start[identifier + requestHash] --> Mode{trusted type?} - Mode -->|passkey / oidc / siwe| KB[IdP key-bound token] + Mode -->|passkey / oidc / siwe| KB[fresh proof key, IdP key-bound token] KB --> Chal[each escrow generateChallenge] - Chal --> PoP[sign PoP over token + challenge + requestHash] + Chal --> PoP[sign PoP over hash of token + challengeId + requestHash] Mode -->|emailOtp / smsOtp| Todo[TODO MFA-605 / MFA-606 / MFA-568] + Mode -->|anything else| Reject[unknown_identifier_type] ``` -On **mutation**, every target escrow must authorize or the write does not -start. On **read**, failed authorizations are skipped. +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 @@ -254,8 +348,9 @@ flowchart TD AbortOrResume -->|resume| Auth[re-issue AuthController token] Auth --> Write[replicate] Phase -->|writing| ResumeOnly[resume only] - ResumeOnly --> Skip[skip escrows that already have receipts] - Skip --> Write + 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] From 150fc0688e1a27afa5bc64f32cdbca631b32f7d7 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:19:18 +0800 Subject: [PATCH 46/55] chore: update CHANGELOG for `mfa-recovery-controller` package - Enhanced the initial release notes to detail the functionalities of `MfaRecoveryController`, including exports for various types and error handling. - Added descriptions for `CubistEscrowProvider` and related types for recovery operations. --- packages/mfa-recovery-controller/CHANGELOG.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 53a7758a7ec..d54dfc7524a 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -9,10 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release ([#10022](https://github.com/MetaMask/core/pull/10022)) - -### Changed - -- Bump `@metamask/base-controller` from `^10.0.0` to `^10.1.0` ([#10864](https://github.com/MetaMask/core/pull/10864)) +- 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)) + - Exports `MfaRecoveryController`, `getDefaultMfaRecoveryControllerState`, and the controller's action, event, messenger, state, and options types. + - 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. [Unreleased]: https://github.com/MetaMask/core/ From ff4cb3c0f961f5f8616d5ecdff9ee054bac7a61e Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:07:25 +0800 Subject: [PATCH 47/55] refactor(mfa-recovery-controller): migrate tests from Jest to Vitest and remove Jest configurations - Deleted Jest configuration files and updated package.json to use Vitest for testing. - Updated test files to replace Jest APIs with Vitest equivalents, ensuring compatibility with the new testing framework. - Removed references to the `@metamask/mfa-recovery-controller` package from the Jest runner map in `test-runner-package-map.cjs`. - Adjusted oxlint suppressions to reflect the removal of Jest configurations. --- oxlint-suppressions.json | 10 --- .../mfa-recovery-controller/jest.config.cjs | 18 ---- .../jest.config.ts5.cjs | 19 ----- packages/mfa-recovery-controller/package.json | 15 ++-- .../src/MfaRecoveryController.test.ts | 83 +++++++++---------- .../src/auth-controller-token.test.ts | 1 + .../src/crypto.test.ts | 1 + .../cubist-escrow-provider.test.ts | 8 +- .../src/escrow-utils.test.ts | 2 + .../mfa-recovery-controller/vitest.config.js | 20 +++++ test-runner-package-map.cjs | 1 - yarn.lock | 5 +- 12 files changed, 75 insertions(+), 108 deletions(-) delete mode 100644 packages/mfa-recovery-controller/jest.config.cjs delete mode 100644 packages/mfa-recovery-controller/jest.config.ts5.cjs create mode 100644 packages/mfa-recovery-controller/vitest.config.js diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index d0c6760d77f..6eccc113937 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -3680,16 +3680,6 @@ "count": 1 } }, - "packages/mfa-recovery-controller/jest.config.cjs": { - "import/unambiguous": { - "count": 1 - } - }, - "packages/mfa-recovery-controller/jest.config.ts5.cjs": { - "import/unambiguous": { - "count": 1 - } - }, "packages/money-account-api-data-service/jest.config.cjs": { "import/unambiguous": { "count": 1 diff --git a/packages/mfa-recovery-controller/jest.config.cjs b/packages/mfa-recovery-controller/jest.config.cjs deleted file mode 100644 index 47459a7fc27..00000000000 --- a/packages/mfa-recovery-controller/jest.config.cjs +++ /dev/null @@ -1,18 +0,0 @@ -const merge = require('deepmerge'); -const path = require('path'); - -const baseConfig = require('../../jest.config.packages.cjs'); - -const displayName = path.basename(__dirname); - -module.exports = merge(baseConfig, { - displayName, - coverageThreshold: { - global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, - }, - }, -}); diff --git a/packages/mfa-recovery-controller/jest.config.ts5.cjs b/packages/mfa-recovery-controller/jest.config.ts5.cjs deleted file mode 100644 index e4b6c4ff7f3..00000000000 --- a/packages/mfa-recovery-controller/jest.config.ts5.cjs +++ /dev/null @@ -1,19 +0,0 @@ -const merge = require('deepmerge'); - -const baseConfig = require('./jest.config.cjs'); - -const config = merge(baseConfig, {}); -config.transform = { - '^.+\\.tsx?$': [ - 'ts-jest', - { - tsconfig: { - module: 'CommonJS', - moduleResolution: 'Node', - verbatimModuleSyntax: false, - }, - }, - ], -}; - -module.exports = config; diff --git a/packages/mfa-recovery-controller/package.json b/packages/mfa-recovery-controller/package.json index 2eb1622abfe..1a5c576b442 100644 --- a/packages/mfa-recovery-controller/package.json +++ b/packages/mfa-recovery-controller/package.json @@ -46,10 +46,10 @@ "messenger-action-types:check": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", "messenger-action-types:generate": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", "since-latest-release": "../../scripts/since-latest-release.sh", - "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", - "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", - "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", - "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + "test": "vitest", + "test:clean": "vitest --clearCache", + "test:verbose": "vitest --reporter=verbose", + "test:watch": "vitest --watch" }, "dependencies": { "@cubist-labs/cubesigner-sdk": "^0.4.284", @@ -65,16 +65,13 @@ "devDependencies": { "@cubist-labs/cubesigner-sdk-fs-storage": "^0.4.284", "@metamask/auto-changelog": "^6.2.1", - "@types/jest": "^30.0.0", "@typescript/native": "npm:typescript@^7.0.2", - "deepmerge": "^4.3.1", - "jest": "^30.5.2", "rimraf": "^6.1.3", - "ts-jest": "^29.4.14", "tsx": "^4.20.5", "typedoc": "^0.28.20", "typedoc-plugin-missing-exports": "^4.1.4", - "typescript": "npm:@typescript/typescript6@^6.0.2" + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vitest": "^5.0.0" }, "engines": { "node": "^22.14.0 || ^24" diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 757b8af71ce..5bb8800ca1b 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -6,6 +6,7 @@ import type { MessengerEvents, } from '@metamask/messenger'; import { bytesToHex } from '@metamask/utils'; +import { describe, expect, it, vi } from 'vitest'; import { StubAuthProvider, @@ -160,9 +161,9 @@ describe('MfaRecoveryController', () => { describe('register', () => { it('replicates the recovery secret to every escrow', async () => { await withController(async ({ controller, escrowA, escrowB }) => { - const verifyReceipt = jest.spyOn(escrowA, 'verifyReceipt'); - const applyA = jest.spyOn(escrowA, 'applyMutation'); - const applyB = jest.spyOn(escrowB, 'applyMutation'); + const verifyReceipt = vi.spyOn(escrowA, 'verifyReceipt'); + const applyA = vi.spyOn(escrowA, 'applyMutation'); + const applyB = vi.spyOn(escrowB, 'applyMutation'); await controller.register(SECRET, IDENTIFIERS); expect(controller.state.pendingOperation).toBeNull(); expect(await controller.getPhase()).toBe('idle'); @@ -185,7 +186,7 @@ describe('MfaRecoveryController', () => { it('publishes state changes through the stateChanged event', async () => { await withController(async ({ controller, rootMessenger }) => { - const listener = jest.fn(); + const listener = vi.fn(); rootMessenger.subscribe('MfaRecoveryController:stateChanged', listener); await controller.register(SECRET, IDENTIFIERS); @@ -244,8 +245,8 @@ describe('MfaRecoveryController', () => { publicKey: '{}', privateKey: '{}', }); - const applyA = jest.spyOn(escrowA, 'applyMutation'); - const applyB = jest.spyOn(escrowB, 'applyMutation'); + const applyA = vi.spyOn(escrowA, 'applyMutation'); + const applyB = vi.spyOn(escrowB, 'applyMutation'); const controller = new MfaRecoveryController({ ...getOptions(escrowA, escrowB), messenger: getMessenger(getRootMessenger()), @@ -323,7 +324,7 @@ describe('MfaRecoveryController', () => { it('does not request another token when reading with a session', async () => { await withController(async ({ controller, options }) => { await controller.register(SECRET, IDENTIFIERS); - const getToken = jest.spyOn( + const getToken = vi.spyOn( options.identifierAuthProvider, 'getKeyBoundIdentifierToken', ); @@ -403,9 +404,9 @@ describe('MfaRecoveryController', () => { it('continues reading when an escrow availability check fails', async () => { await withController(async ({ controller, escrowA }) => { await controller.register(SECRET, IDENTIFIERS); - jest - .spyOn(escrowA, 'isAvailable') - .mockRejectedValue(new Error('health check failed')); + vi.spyOn(escrowA, 'isAvailable').mockRejectedValue( + new Error('health check failed'), + ); const recovered = await recover(controller, PASSKEY); @@ -416,9 +417,9 @@ describe('MfaRecoveryController', () => { it('continues reading when key-bound authorization fails for one escrow', async () => { await withController(async ({ controller, escrowA }) => { await controller.register(SECRET, IDENTIFIERS); - jest - .spyOn(escrowA, 'generateChallenge') - .mockRejectedValue(new Error('challenge failed')); + vi.spyOn(escrowA, 'generateChallenge').mockRejectedValue( + new Error('challenge failed'), + ); const recovered = await recover(controller, PASSKEY); @@ -429,7 +430,7 @@ describe('MfaRecoveryController', () => { it('continues reading when an escrow returns an unexpected wrap key id', async () => { await withController(async ({ controller, escrowA }) => { await controller.register(SECRET, IDENTIFIERS); - jest.spyOn(escrowA, 'getSecret').mockResolvedValue({ + vi.spyOn(escrowA, 'getSecret').mockResolvedValue({ recoverySecret: { ciphertext: 'aa' }, version: 1, lastMutationId: '0x', @@ -498,7 +499,7 @@ describe('MfaRecoveryController', () => { describe('updateRecoverySecret', () => { it('replaces the secret at every escrow', async () => { await withController(async ({ controller, escrowA }) => { - const generateChallenge = jest.spyOn(escrowA, 'generateChallenge'); + const generateChallenge = vi.spyOn(escrowA, 'generateChallenge'); await controller.register(SECRET, IDENTIFIERS); await controller.updateRecoverySecret( PASSKEY, @@ -515,10 +516,7 @@ describe('MfaRecoveryController', () => { await withController(async ({ controller, escrowB, options }) => { await controller.register(SECRET, IDENTIFIERS); escrowB.available = false; - const auth = jest.spyOn( - options.authProvider, - 'authorizeRecoveryRequest', - ); + const auth = vi.spyOn(options.authProvider, 'authorizeRecoveryRequest'); auth.mockClear(); await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), @@ -531,7 +529,7 @@ describe('MfaRecoveryController', () => { it('does not authorize a resumed authorizing mutation when an escrow is unavailable', async () => { await withController(async ({ controller, escrowB, options }) => { await controller.register(SECRET, IDENTIFIERS); - const auth = jest + const auth = vi .spyOn(options.authProvider, 'authorizeRecoveryRequest') .mockRejectedValueOnce(new Error('auth down')); await expect( @@ -553,11 +551,11 @@ describe('MfaRecoveryController', () => { it('does not write when one escrow cannot be authorized', async () => { await withController(async ({ controller, escrowA, escrowB }) => { await controller.register(SECRET, IDENTIFIERS); - jest - .spyOn(escrowA, 'generateChallenge') - .mockRejectedValue(new Error('challenge failed')); - const applyA = jest.spyOn(escrowA, 'applyMutation'); - const applyB = jest.spyOn(escrowB, 'applyMutation'); + vi.spyOn(escrowA, 'generateChallenge').mockRejectedValue( + new Error('challenge failed'), + ); + const applyA = vi.spyOn(escrowA, 'applyMutation'); + const applyB = vi.spyOn(escrowB, 'applyMutation'); await expect( controller.updateRecoverySecret(PASSKEY, SECRET_2, REGISTERED_EPOCH), @@ -598,7 +596,7 @@ describe('MfaRecoveryController', () => { const applyStarted = new Promise((resolve) => { resolveApplyStarted = resolve; }); - jest.spyOn(escrowA, 'applyMutation').mockImplementation(async () => { + vi.spyOn(escrowA, 'applyMutation').mockImplementation(async () => { resolveApplyStarted(); await applyGate; throw new Error('apply failed'); @@ -826,8 +824,8 @@ describe('MfaRecoveryController', () => { ), ); const encrypted = await passthroughEncryptor.encrypt(pending); - const availabilityA = jest.spyOn(escrowA, 'isAvailable'); - const availabilityB = jest.spyOn(escrowB, 'isAvailable'); + const availabilityA = vi.spyOn(escrowA, 'isAvailable'); + const availabilityB = vi.spyOn(escrowB, 'isAvailable'); availabilityA.mockClear(); availabilityB.mockClear(); escrowA.available = false; @@ -1259,7 +1257,7 @@ describe('MfaRecoveryController', () => { it('rejects a receipt whose escrow id does not match its target', async () => { await withController(async ({ controller, escrowA }) => { - jest.spyOn(escrowA, 'applyMutation').mockResolvedValue({ + vi.spyOn(escrowA, 'applyMutation').mockResolvedValue({ mutationId: '0xmutation', requestHash: '0xrequest', escrowId: 'escrow-z', @@ -1267,7 +1265,7 @@ describe('MfaRecoveryController', () => { receiptKeyId: '0xreceipt', signature: '0xsignature', }); - jest.spyOn(escrowA, 'verifyReceipt').mockResolvedValue(true); + vi.spyOn(escrowA, 'verifyReceipt').mockResolvedValue(true); await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( 'Invalid mutation receipt', @@ -1277,9 +1275,9 @@ describe('MfaRecoveryController', () => { it('treats receipt verification errors as invalid receipts', async () => { await withController(async ({ controller, escrowA }) => { - jest - .spyOn(escrowA, 'verifyReceipt') - .mockRejectedValue(new Error('verification failed')); + vi.spyOn(escrowA, 'verifyReceipt').mockRejectedValue( + new Error('verification failed'), + ); await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( 'Invalid mutation receipt', @@ -1315,12 +1313,9 @@ describe('MfaRecoveryController', () => { it('rejects a null decrypted pending operation before external calls', async () => { await withController(async ({ escrowA, escrowB, options }) => { - const auth = jest.spyOn( - options.authProvider, - 'authorizeRecoveryRequest', - ); - const availabilityA = jest.spyOn(escrowA, 'isAvailable'); - const availabilityB = jest.spyOn(escrowB, 'isAvailable'); + const auth = vi.spyOn(options.authProvider, 'authorizeRecoveryRequest'); + const availabilityA = vi.spyOn(escrowA, 'isAvailable'); + const availabilityB = vi.spyOn(escrowB, 'isAvailable'); const controller = new MfaRecoveryController({ ...options, messenger: getMessenger(getRootMessenger()), @@ -1555,14 +1550,14 @@ describe('MfaRecoveryController', () => { JSON.stringify(await getValidWritingPending(options.escrows)), ) as Record; await mutate(pending); - const auth = jest.spyOn( + const auth = vi.spyOn( options.authProvider, 'authorizeRecoveryRequest', ); - const availabilityA = jest.spyOn(escrowA, 'isAvailable'); - const availabilityB = jest.spyOn(escrowB, 'isAvailable'); - const applyA = jest.spyOn(escrowA, 'applyMutation'); - const applyB = jest.spyOn(escrowB, 'applyMutation'); + const availabilityA = vi.spyOn(escrowA, 'isAvailable'); + const availabilityB = vi.spyOn(escrowB, 'isAvailable'); + const applyA = vi.spyOn(escrowA, 'applyMutation'); + const applyB = vi.spyOn(escrowB, 'applyMutation'); const controller = new MfaRecoveryController({ ...options, messenger: getMessenger(getRootMessenger()), diff --git a/packages/mfa-recovery-controller/src/auth-controller-token.test.ts b/packages/mfa-recovery-controller/src/auth-controller-token.test.ts index 05848a1039d..61def9373ae 100644 --- a/packages/mfa-recovery-controller/src/auth-controller-token.test.ts +++ b/packages/mfa-recovery-controller/src/auth-controller-token.test.ts @@ -1,4 +1,5 @@ import { bytesToBase64, stringToBytes } from '@metamask/utils'; +import { describe, expect, it } from 'vitest'; import { decodeAuthControllerToken } from './auth-controller-token.js'; diff --git a/packages/mfa-recovery-controller/src/crypto.test.ts b/packages/mfa-recovery-controller/src/crypto.test.ts index dd6ea6a0587..7ec3ede3af6 100644 --- a/packages/mfa-recovery-controller/src/crypto.test.ts +++ b/packages/mfa-recovery-controller/src/crypto.test.ts @@ -1,4 +1,5 @@ import { bytesToHex } from '@metamask/utils'; +import { describe, expect, it } from 'vitest'; import { canonicalize, 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 986e3e3bc32..b2f58e87143 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 @@ -1,5 +1,7 @@ import type { CubeSignerClient } from '@cubist-labs/cubesigner-sdk'; import { bytesToHex } from '@metamask/utils'; +import type { Mock } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { generateSigningKey, @@ -387,7 +389,7 @@ function createProvider( } = {}, ): { provider: CubistEscrowProvider; - invoke: jest.Mock; + invoke: Mock; } { const stdoutBytes = options.stdoutBytes ?? @@ -398,11 +400,11 @@ function createProvider( ); const invoke = options.invoke === undefined - ? jest.fn(async () => ({ + ? vi.fn(async () => ({ response: options.policy ?? { response: 'Allow' }, stdout: bytesToHex(stdoutBytes), })) - : jest.fn(options.invoke); + : vi.fn(options.invoke); const client = { apiClient: { policyInvoke: invoke }, } as unknown as CubeSignerClient; diff --git a/packages/mfa-recovery-controller/src/escrow-utils.test.ts b/packages/mfa-recovery-controller/src/escrow-utils.test.ts index e30b22a979a..49b60d3d6d4 100644 --- a/packages/mfa-recovery-controller/src/escrow-utils.test.ts +++ b/packages/mfa-recovery-controller/src/escrow-utils.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from 'vitest'; + import { isMutationReceipt } from './escrow-utils.js'; const RECEIPT = { diff --git a/packages/mfa-recovery-controller/vitest.config.js b/packages/mfa-recovery-controller/vitest.config.js new file mode 100644 index 00000000000..a07dec83436 --- /dev/null +++ b/packages/mfa-recovery-controller/vitest.config.js @@ -0,0 +1,20 @@ +import { basename, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mergeConfig } from 'vitest/config'; + +import baseConfig from '../../vitest.config.packages.js'; + +export default mergeConfig(baseConfig, { + test: { + name: basename(dirname(fileURLToPath(import.meta.url))), + + coverage: { + thresholds: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, + }, +}); diff --git a/test-runner-package-map.cjs b/test-runner-package-map.cjs index f562e4ec685..be823484807 100644 --- a/test-runner-package-map.cjs +++ b/test-runner-package-map.cjs @@ -55,7 +55,6 @@ const PACKAGES_ON_JEST = new Set([ '@metamask/message-manager', '@metamask/messenger', '@metamask/messenger-cli', - '@metamask/mfa-recovery-controller', '@metamask/money-account-api-data-service', '@metamask/money-account-balance-service', '@metamask/money-account-controller', diff --git a/yarn.lock b/yarn.lock index aecdc281545..62350e8acd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8002,17 +8002,14 @@ __metadata: "@metamask/utils": "npm:^12.0.0" "@noble/ciphers": "npm:^1.3.0" "@noble/curves": "npm:^1.9.7" - "@types/jest": "npm:^30.0.0" "@typescript/native": "npm:typescript@^7.0.2" - deepmerge: "npm:^4.3.1" - jest: "npm:^30.5.2" json-stable-stringify: "npm:^1.3.0" rimraf: "npm:^6.1.3" - ts-jest: "npm:^29.4.14" tsx: "npm:^4.20.5" typedoc: "npm:^0.28.20" typedoc-plugin-missing-exports: "npm:^4.1.4" typescript: "npm:@typescript/typescript6@^6.0.2" + vitest: "npm:^5.0.0" languageName: unknown linkType: soft From 221e8a10588e9d6ec88be3d9e512f0d11734d5f3 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:03:32 +0800 Subject: [PATCH 48/55] feat: align MFA recovery with Auth API approvals --- .../mfa-recovery-controller/ARCHITECTURE.md | 35 ++++---- packages/mfa-recovery-controller/README.md | 21 +++-- .../scripts/register-with-controller.ts | 23 +++-- .../src/MfaRecoveryController.test.ts | 37 ++++---- .../src/MfaRecoveryController.ts | 23 ++--- .../src/auth-controller-token.test.ts | 37 +++----- .../src/auth-controller-token.ts | 10 +-- .../src/crypto.test.ts | 90 +++++-------------- .../mfa-recovery-controller/src/crypto.ts | 43 +-------- .../cubist-escrow-provider.test.ts | 2 + .../cubist-escrow-provider.ts | 8 +- .../src/identifier-auth.ts | 2 +- .../src/pending-operation-validation.ts | 5 +- packages/mfa-recovery-controller/src/types.ts | 9 +- .../mfa-recovery-controller/tests/stubs.ts | 24 +++-- 15 files changed, 135 insertions(+), 234 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 7d213c158d0..62730e2b449 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -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). @@ -159,15 +158,15 @@ sequenceDiagram end ``` -AuthController token request: +Auth approvals: -- `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. +- The provider receives the mutation `requestHash` and each escrow's + `authAudience`. Updates set `requireTwoFactor` for AAL2. +- Approvals last five minutes. AAL2 requires MFA within the preceding five minutes. +- `register` and `updateIdentifiers` use Auth's `identifiers` unchanged. + Hashing sorts object keys, not arrays, and adds no fields. +- Escrows verify signatures. The controller decodes claims only for pending-state + validation and expiry-based refresh. Receipts: @@ -202,8 +201,8 @@ every attempt, so the identifier provider must be able to authenticate again. ## Cubist store sequence 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`. @@ -244,7 +243,7 @@ sequenceDiagram Ctrl->>Auth: getAuthenticatedProfileId Ctrl->>Ctrl: persist authorizing Ctrl->>Auth: authorizeRecoveryRequest - Auth-->>Ctrl: AuthController token (Hydra JWT) + Auth-->>Ctrl: Auth API approval Ctrl->>Ctrl: wrap secret, persist writing Ctrl->>Escrow: applyMutation Escrow-->>Ctrl: receipt @@ -325,7 +324,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] diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md index bbd5ca03500..763fa700710 100644 --- a/packages/mfa-recovery-controller/README.md +++ b/packages/mfa-recovery-controller/README.md @@ -1,13 +1,18 @@ # `@metamask/mfa-recovery-controller` -Manages MFA recovery flows across MetaMask clients. Auth and escrow storage are -injected so the same controller can run against different identity providers and -escrow backends. - -Pending mutations are persisted as encoded encrypted `authorizing` / `writing` -state so a crash can `resume()` the same mutation. `abort()` drops `authorizing` -only; `writing` must `resume()`. The encoded ciphertext is stored as a string so -BaseController state serialization preserves the encrypted value. +Coordinates MFA recovery through injected auth and escrow providers. + +Pending mutations are encrypted in controller state. `resume()` completes them; +`abort()` is allowed only before writing. See [Architecture](ARCHITECTURE.md). + +## Auth API Integration + +`RecoveryAuthProvider` posts `{ request_hash, audiences }` to +`/api/v2/recovery/approvals` with a Hydra bearer and returns `approval` directly. + +Pass Auth's `identifiers` unchanged to `register` and `updateIdentifiers`. +Set `CubistEscrowProviderOptions.authAudience` to the escrow's +`AUTH_CONTROLLER_AUDIENCE`. ## Installation diff --git a/packages/mfa-recovery-controller/scripts/register-with-controller.ts b/packages/mfa-recovery-controller/scripts/register-with-controller.ts index c1d4538a152..2a7722c3895 100644 --- a/packages/mfa-recovery-controller/scripts/register-with-controller.ts +++ b/packages/mfa-recovery-controller/scripts/register-with-controller.ts @@ -26,11 +26,11 @@ import { * Usage: * yarn workspace @metamask/mfa-recovery-controller run cubesigner:register -- \ * --wrap-key '' \ - * --receipt-key '' \ + * --receipt-key '' --auth-audience '' \ * [--function-id ] [--version latest|v0] */ const USAGE = - 'Usage: cubesigner:register --wrap-key --receipt-key [--function-id ] [--version latest|v0]'; + 'Usage: cubesigner:register --wrap-key --receipt-key --auth-audience [--function-id ] [--version latest|v0]'; const PASSKEY: Identifier = { type: 'passkey', @@ -39,11 +39,10 @@ const PASSKEY: Identifier = { verifier: { publicKey: 'mock-passkey' }, }; -const OIDC: Identifier = { - type: 'oidc', +const SOCIAL: Identifier = { + type: 'social', namespace: 'https://accounts.google.com', - value: 'sub-1', - verifier: { auds: ['mock-client'] }, + value: 'hashed-email', }; type RootMessenger = Messenger< @@ -107,7 +106,12 @@ function getControllerMessenger(): MfaRecoveryControllerMessenger { async function main(flags: Record): Promise { const wrapPublicKey = flags['wrap-key']; const receiptPublicKey = flags['receipt-key']; - if (wrapPublicKey === undefined || receiptPublicKey === undefined) { + const authAudience = flags['auth-audience']; + if ( + wrapPublicKey === undefined || + receiptPublicKey === undefined || + authAudience === undefined + ) { console.error(USAGE); process.exitCode = 1; return; @@ -116,6 +120,7 @@ async function main(flags: Record): Promise { const client = await CubeSignerClient.create(defaultUserSessionManager()); const escrow = new CubistEscrowProvider({ client, + authAudience, wrapPublicKey, receiptPublicKey, ...(flags['function-id'] === undefined @@ -125,14 +130,14 @@ async function main(flags: Record): Promise { }); const controller = new MfaRecoveryController({ messenger: getControllerMessenger(), - authProvider: new StubAuthProvider(), + authProvider: new StubAuthProvider([PASSKEY, SOCIAL]), identifierAuthProvider: new StubIdentifierAuthProvider(), escrows: [escrow], pendingOperationEncryptor: passthroughEncryptor, }); const recoverySecret = new Uint8Array(randomBytes(32)); - await controller.register(recoverySecret, [PASSKEY, OIDC]); + await controller.register(recoverySecret, [PASSKEY, SOCIAL]); const identifierSession = await controller.authenticateIdentifier(PASSKEY); const recovered = await controller.getRecoverySecret(identifierSession); diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 5bb8800ca1b..41940773e41 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -15,12 +15,7 @@ import { createAuthControllerToken, passthroughEncryptor, } from '../tests/stubs.js'; -import { - canonicalizeIdentifiers, - generateSigningKey, - hash, - unixNow, -} from './crypto.js'; +import { generateSigningKey, hash, unixNow } from './crypto.js'; import { IncompleteMutationError, MutationRejectedError } from './errors.js'; import type { MfaRecoveryControllerMessenger, @@ -43,14 +38,13 @@ const PASSKEY: Identifier = { verifier: { publicKey: 'pk' }, }; -const OIDC: Identifier = { - type: 'oidc', +const SOCIAL: Identifier = { + type: 'social', namespace: 'https://accounts.google.com', - value: 'sub-1', - verifier: { auds: ['client'] }, + value: 'hashed-email', }; -const IDENTIFIERS = [PASSKEY, OIDC]; +const IDENTIFIERS = [PASSKEY, SOCIAL]; const SECRET = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); const SECRET_2 = new Uint8Array([8, 7, 6, 5, 4, 3, 2, 1]); @@ -710,14 +704,15 @@ describe('MfaRecoveryController', () => { describe('updateIdentifiers', () => { it('replaces the identifier set', async () => { - await withController(async ({ controller }) => { + await withController(async ({ controller, authProvider }) => { await controller.register(SECRET, IDENTIFIERS); + authProvider.identifiers = [SOCIAL, PASSKEY]; await controller.updateIdentifiers( PASSKEY, - [OIDC, PASSKEY], + [SOCIAL, PASSKEY], REGISTERED_EPOCH, ); - const recovered = await recover(controller, OIDC); + const recovered = await recover(controller, SOCIAL); expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -1133,9 +1128,7 @@ describe('MfaRecoveryController', () => { sub: 'profile-1', exp: unixNow() + 60, requestHash: mutation.requestHash, - identifiersHash: await hash( - canonicalizeIdentifiers(payload.identifiers), - ), + identifiersHash: await hash(payload.identifiers), }), payload, identifier: null, @@ -1195,9 +1188,7 @@ describe('MfaRecoveryController', () => { sub: 'profile-1', exp: unixNow() + 60, requestHash: mutation.requestHash, - identifiersHash: await hash( - canonicalizeIdentifiers(payload.identifiers), - ), + identifiersHash: await hash(payload.identifiers), }), payload, identifier: null, @@ -1612,6 +1603,7 @@ type RootMessenger = Messenger< type WithControllerCallback = (payload: { controller: MfaRecoveryController; + authProvider: StubAuthProvider; rootMessenger: RootMessenger; controllerMessenger: MfaRecoveryControllerMessenger; escrowA: StubEscrowProvider; @@ -1635,10 +1627,10 @@ function getMessenger( function getOptions( escrowA: StubEscrowProvider, escrowB: StubEscrowProvider, -): MfaRecoveryControllerOptions { +): MfaRecoveryControllerOptions & { authProvider: StubAuthProvider } { return { messenger: getMessenger(getRootMessenger()), - authProvider: new StubAuthProvider(), + authProvider: new StubAuthProvider(IDENTIFIERS), identifierAuthProvider: new StubIdentifierAuthProvider(), escrows: [escrowA, escrowB], pendingOperationEncryptor: passthroughEncryptor, @@ -1712,6 +1704,7 @@ async function withController( const controller = new MfaRecoveryController(options); return await testFunction({ controller, + authProvider: options.authProvider, rootMessenger, controllerMessenger, escrowA, diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index ea85c390e2e..23c33d44f3c 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -52,7 +52,6 @@ import type { Mutation, MutationPayload, MutationReceipt, - PendingMutationPayload, PendingOperation, PendingOperationEncryptor, PendingRegisterPayload, @@ -494,10 +493,7 @@ export class MfaRecoveryController extends BaseController< }; await this.#persistPending(pending); - const authControllerToken = await this.#authorizeMutation( - mutation, - payload, - ); + const authControllerToken = await this.#authorizeMutation(mutation); await this.#replicateMutation({ ...pending, phase: 'writing', @@ -507,17 +503,11 @@ export class MfaRecoveryController extends BaseController< }); } - async #authorizeMutation( - mutation: Mutation, - payload: PendingMutationPayload, - ): Promise { + async #authorizeMutation(mutation: Mutation): Promise { return await this.#authProvider.authorizeRecoveryRequest({ requestHash: mutation.requestHash, audiences: this.#escrows.map((escrow) => escrow.authAudience), ...(mutation.operation === 'register' ? {} : { requireTwoFactor: true }), - ...(pendingPayloadHasIdentifiers(payload) - ? { identifiers: payload.identifiers } - : {}), }); } @@ -532,10 +522,7 @@ export class MfaRecoveryController extends BaseController< await this.#replicateMutation({ ...pending, phase: 'writing', - authControllerToken: await this.#authorizeMutation( - pending.mutation, - pending.payload, - ), + authControllerToken: await this.#authorizeMutation(pending.mutation), receipts: [], }); return; @@ -546,7 +533,7 @@ export class MfaRecoveryController extends BaseController< } async #replicateMutation(pending: WritingPendingOperation): Promise { - const { mutation, payload, identifier } = pending; + const { mutation, identifier } = pending; const remainingEscrows = await this.#getRemainingEscrows(pending); if (remainingEscrows.length === 0) { await this.#clearPending(); @@ -559,7 +546,7 @@ export class MfaRecoveryController extends BaseController< const authControllerToken = claims && claims.exp > this.#now() ? pending.authControllerToken - : await this.#authorizeMutation(mutation, payload); + : await this.#authorizeMutation(mutation); const writing = { ...pending, authControllerToken }; const authorizedEscrows = mutation.operation === 'register' diff --git a/packages/mfa-recovery-controller/src/auth-controller-token.test.ts b/packages/mfa-recovery-controller/src/auth-controller-token.test.ts index 61def9373ae..eb8f35f4e9b 100644 --- a/packages/mfa-recovery-controller/src/auth-controller-token.test.ts +++ b/packages/mfa-recovery-controller/src/auth-controller-token.test.ts @@ -28,7 +28,7 @@ const VALID_CLAIMS = { }; describe('decodeAuthControllerToken', () => { - it('decodes all claims when optional step-up bindings are present', () => { + it('decodes approval claims', () => { expect( decodeAuthControllerToken(tokenWithPayload(JSON.stringify(VALID_CLAIMS))), ).toStrictEqual({ @@ -42,14 +42,14 @@ describe('decodeAuthControllerToken', () => { }); }); - it('decodes a token without optional aal or identifiers hash', () => { + it('decodes an approval without an identifiers hash', () => { expect( decodeAuthControllerToken( tokenWithPayload( JSON.stringify({ sub: 'profile-1', exp: 1, - ext: { request_hash: '0xrequest' }, + ext: { request_hash: '0xrequest', aal: 1 }, }), ), ), @@ -58,7 +58,7 @@ describe('decodeAuthControllerToken', () => { exp: 1, ext: { requestHash: '0xrequest', - aal: undefined, + aal: 1, identifiersHash: undefined, }, }); @@ -105,32 +105,23 @@ describe('decodeAuthControllerToken', () => { }); it.each([ - ['sub is missing', { exp: 1, ext: { request_hash: '0x' } }], - ['sub is not a string', { sub: 1, exp: 1, ext: { request_hash: '0x' } }], - [ - 'exp is not a number', - { sub: 'profile-1', exp: '1', ext: { request_hash: '0x' } }, - ], - ['request_hash is missing', { sub: 'profile-1', exp: 1, ext: {} }], - [ - 'aal is not a number', - { - sub: 'profile-1', - exp: 1, - ext: { request_hash: '0x', aal: '2' }, - }, - ], + ['sub is missing', { sub: undefined }], + ['sub is not a string', { sub: 1 }], + ['exp is not a number', { exp: '1' }], + ['request_hash is missing', { ext: { aal: 1 } }], + ['aal is missing', { ext: { request_hash: '0x' } }], + ['aal is not a number', { ext: { request_hash: '0x', aal: '2' } }], [ 'identifiers_hash is not a string', { - sub: 'profile-1', - exp: 1, - ext: { request_hash: '0x', identifiers_hash: 1 }, + ext: { request_hash: '0x', aal: 1, identifiers_hash: 1 }, }, ], ])('returns undefined when %s', (_name, claims) => { expect( - decodeAuthControllerToken(tokenWithPayload(JSON.stringify(claims))), + decodeAuthControllerToken( + tokenWithPayload(JSON.stringify({ ...VALID_CLAIMS, ...claims })), + ), ).toBeUndefined(); }); }); diff --git a/packages/mfa-recovery-controller/src/auth-controller-token.ts b/packages/mfa-recovery-controller/src/auth-controller-token.ts index 64adc1d5a90..206380d2d34 100644 --- a/packages/mfa-recovery-controller/src/auth-controller-token.ts +++ b/packages/mfa-recovery-controller/src/auth-controller-token.ts @@ -3,9 +3,7 @@ import { bytesToString, isObject } from '@metamask/utils'; import { fromBase64Url } from './crypto.js'; /** - * The claims the controller reads from an AuthController (Hydra) access token, - * in the token's shape with camelCase names. The Auth API token hook adds the - * step-up bindings under `ext`; a token that did not step up has none of them. + * Claims read from an Auth API approval, with camelCase field names. */ export type AuthControllerTokenClaims = { /** @@ -21,9 +19,9 @@ export type AuthControllerTokenClaims = { /** * Authentication assurance level. */ - aal?: number; + aal: number; /** - * Only present when the step-up was bound to a payload's identifiers. + * Hash of the Auth API's recovery identifiers. */ identifiersHash?: string; }; @@ -64,7 +62,7 @@ export function decodeAuthControllerToken( typeof sub !== 'string' || typeof exp !== 'number' || typeof requestHash !== 'string' || - (aal !== undefined && typeof aal !== 'number') || + typeof aal !== 'number' || (identifiersHash !== undefined && typeof identifiersHash !== 'string') ) { return undefined; diff --git a/packages/mfa-recovery-controller/src/crypto.test.ts b/packages/mfa-recovery-controller/src/crypto.test.ts index 7ec3ede3af6..dbdbd2d333f 100644 --- a/packages/mfa-recovery-controller/src/crypto.test.ts +++ b/packages/mfa-recovery-controller/src/crypto.test.ts @@ -3,8 +3,6 @@ import { describe, expect, it } from 'vitest'; import { canonicalize, - canonicalizeIdentifiers, - compareUtf8, decodeHex, decryptFromPublic, encryptToPublic, @@ -75,71 +73,31 @@ describe('crypto', () => { ); }); - it('sorts identifiers for ownership hashes', async () => { - const first: Identifier = { - type: 'passkey', - namespace: 'b.com', - value: '2', - verifier: null, - }; - const second: Identifier = { - type: 'passkey', - namespace: 'a.com', - value: '1', - verifier: null, - }; - expect(canonicalize(canonicalizeIdentifiers([first, second]))).toBe( - canonicalize(canonicalizeIdentifiers([second, first])), + it('matches the Auth API and escrow identifiers hash', async () => { + const identifiers: Identifier[] = [ + { + type: 'social', + namespace: 'https://accounts.google.com', + value: 'abc', + }, + { + type: 'passkey', + namespace: 'metamask.io', + value: 'AQID', + verifier: { + credentialId: 'AQID', + publicKey: { cose: 'AQID' }, + rpId: 'metamask.io', + origins: ['https://metamask.io', 'android:apk-key-hash:example'], + }, + }, + ]; + expect(await hash(identifiers)).toBe( + '0x7fbf077f78e6cc4c420eb9b1885ba911aca3ec9810422ab5f26f767f13622090', + ); + expect(await hash([...identifiers].reverse())).not.toBe( + await hash(identifiers), ); - }); - - describe('identifier sort order', () => { - // The escrow sorts by UTF-8 byte order (Rust `String` ordering). Any other - // order changes `identifiersHash` and the escrow rejects the mutation. - const identifier = (value: string): Identifier => ({ - type: 'passkey', - namespace: 'metamask.io', - value, - verifier: null, - }); - const sortedValues = (values: string[]): string[] => - ( - canonicalizeIdentifiers(values.map(identifier)) as { value: string }[] - ).map((item) => item.value); - - it('puts uppercase before lowercase, unlike locale order', () => { - // base64url credential ids are mixed case; localeCompare would put - // `a2V5LTE` first. - expect(sortedValues(['a2V5LTE', 'Zm9vLTI'])).toStrictEqual([ - 'Zm9vLTI', - 'a2V5LTE', - ]); - expect(sortedValues(['Zm9vLTI', 'a2V5LTE'])).toStrictEqual([ - 'Zm9vLTI', - 'a2V5LTE', - ]); - }); - - it('orders by UTF-8 bytes rather than UTF-16 code units', () => { - // U+FF21 is EF BC A1 in UTF-8 and U+1F600 is F0 9F 98 80, so U+FF21 - // sorts first. In UTF-16 U+1F600 (D83D DE00) would sort first. - expect(sortedValues(['\u{1F600}', '\uFF21'])).toStrictEqual([ - '\uFF21', - '\u{1F600}', - ]); - }); - - it('orders a shorter identifier before its extension', () => { - expect(sortedValues(['abc', 'ab'])).toStrictEqual(['ab', 'abc']); - }); - - it('compares UTF-8 bytes, treating a shared prefix as shorter', () => { - expect(compareUtf8('ab', 'abc')).toBeLessThan(0); - expect(compareUtf8('abc', 'ab')).toBeGreaterThan(0); - expect(compareUtf8('abc', 'abc')).toBe(0); - expect(compareUtf8('a', 'b')).toBeLessThan(0); - expect(compareUtf8('b', 'a')).toBeGreaterThan(0); - }); }); describe('wire-format vectors', () => { diff --git a/packages/mfa-recovery-controller/src/crypto.ts b/packages/mfa-recovery-controller/src/crypto.ts index 97289dab366..46d1c16065c 100644 --- a/packages/mfa-recovery-controller/src/crypto.ts +++ b/packages/mfa-recovery-controller/src/crypto.ts @@ -14,7 +14,7 @@ import { chacha20poly1305 } from '@noble/ciphers/chacha'; import { p256 } from '@noble/curves/nist'; import stableStringify from 'json-stable-stringify'; -import type { EcPublicJwk, Identifier } from './types.js'; +import type { EcPublicJwk } from './types.js'; const WRAP_INFO = new TextEncoder().encode('escrow-wrap-v1'); const CHACHA_KEY_LEN = 32; @@ -43,47 +43,6 @@ export async function hash(value: unknown): Promise { return bytesToHex(await sha256(stringToBytes(canonicalize(value)))); } -/** - * Orders two strings by their UTF-8 bytes. The escrow sorts identifiers the same - * way (Rust `String` ordering), so this must not be locale or UTF-16 aware. - * - * @param left - First string. - * @param right - Second string. - * @returns Negative, zero, or positive like `Array.prototype.sort` expects. - */ -export function compareUtf8(left: string, right: string): number { - const leftBytes = stringToBytes(left); - const rightBytes = stringToBytes(right); - const shared = Math.min(leftBytes.length, rightBytes.length); - for (let index = 0; index < shared; index += 1) { - const difference = leftBytes[index] - rightBytes[index]; - if (difference !== 0) { - return difference; - } - } - return leftBytes.length - rightBytes.length; -} - -/** - * Canonical identifier list bound into AuthController `identifiersHash`. - * - * @param identifiers - Identifier set. - * @returns Identifier records including verifier material, sorted by the UTF-8 - * bytes of their canonical JSON. - */ -export function canonicalizeIdentifiers(identifiers: Identifier[]): unknown { - return [...identifiers] - .map((identifier) => ({ - namespace: identifier.namespace, - type: identifier.type, - value: identifier.value, - verifier: identifier.verifier, - })) - .sort((left, right) => - compareUtf8(canonicalize(left), canonicalize(right)), - ); -} - /** * @returns A random 0x-prefixed id. */ 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 b2f58e87143..aa6811c1a9c 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 @@ -76,6 +76,7 @@ describe('CubistEscrowProvider', () => { it('uses the cubist escrow id and the pinned wrap key', () => { const { provider } = createProvider(); expect(provider.id).toBe('cubist'); + expect(provider.authAudience).toBe('gamma.signer.cubist.dev'); expect(provider.wrapPublicKey).toBe(wrapKey.publicKey); }); @@ -411,6 +412,7 @@ function createProvider( return { provider: new CubistEscrowProvider({ client, + authAudience: 'gamma.signer.cubist.dev', wrapPublicKey: wrapKey.publicKey, receiptPublicKey: receiptKey.publicKey, functionId: options.functionId, 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 50b6dd6c79f..1ed067af30a 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 @@ -22,7 +22,6 @@ import type { } from '../types.js'; const ESCROW_ID = 'cubist'; -const AUTH_AUDIENCE = 'cubist'; const DEFAULT_FUNCTION_ID = 'cubist_secret_escrow'; const DEFAULT_VERSION: Version = 'latest'; @@ -39,6 +38,10 @@ export type CubistEscrowProviderOptions = { * Authenticated CubeSigner client used to invoke the escrow C2F. */ client: CubeSignerClient; + /** + * Must match the escrow's AUTH_CONTROLLER_AUDIENCE configuration. + */ + authAudience: string; /** * C2F name or named-policy id. Defaults to `cubist_secret_escrow`. */ @@ -60,7 +63,7 @@ export type CubistEscrowProviderOptions = { export class CubistEscrowProvider implements RecoveryEscrowProvider { readonly id = ESCROW_ID; - readonly authAudience = AUTH_AUDIENCE; + readonly authAudience: string; readonly wrapPublicKey: string; @@ -76,6 +79,7 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { * @param options - CubeSigner client, pinned keys, and optional C2F id/version. */ constructor(options: CubistEscrowProviderOptions) { + this.authAudience = options.authAudience; this.wrapPublicKey = options.wrapPublicKey; this.#receiptPublicKey = options.receiptPublicKey; this.#client = options.client; diff --git a/packages/mfa-recovery-controller/src/identifier-auth.ts b/packages/mfa-recovery-controller/src/identifier-auth.ts index 1369f393eb0..d1bd8927924 100644 --- a/packages/mfa-recovery-controller/src/identifier-auth.ts +++ b/packages/mfa-recovery-controller/src/identifier-auth.ts @@ -37,7 +37,7 @@ export function countDistinctIdentifiers(identifiers: Identifier[]): number { * `escrow-challenge` once escrow OTP commands exist. */ export const IDENTIFIER_AUTH_MODES: Record = { - oidc: 'key-bound', + social: 'key-bound', passkey: 'key-bound', siwe: 'key-bound', }; diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts index 8755c79c22f..db6b709a146 100644 --- a/packages/mfa-recovery-controller/src/pending-operation-validation.ts +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -114,7 +114,7 @@ export async function assertValidPendingOperation( if ( token?.sub !== mutation.profileId || token.ext.requestHash !== mutation.requestHash || - (mutation.operation !== 'register' && (token.ext.aal ?? 0) < 2) || + token.ext.aal < (mutation.operation === 'register' ? 1 : 2) || ((mutation.operation === 'register' || mutation.operation === 'updateIdentifiers') && token.ext.identifiersHash === undefined) || @@ -195,8 +195,7 @@ function isIdentifier(value: unknown): value is Identifier { isRecord(value) && typeof value.type === 'string' && typeof value.namespace === 'string' && - typeof value.value === 'string' && - Object.prototype.hasOwnProperty.call(value, 'verifier') + typeof value.value === 'string' ); } diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index dd34e59e3bf..789448d4808 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -6,7 +6,7 @@ export type Identifier = { type: string; namespace: string; value: string; - verifier: unknown; + verifier?: unknown; }; export type PoPChallenge = { @@ -60,7 +60,7 @@ export type Mutation = { }; /** - * Hydra access token (compact JWT). Its `ext` claims bind it to one mutation: + * Auth API approval (compact JWT). Its `ext` claims bind it to one mutation: * `aal`, `request_hash`, and `identifiers_hash`. */ export type AuthControllerToken = string; @@ -237,11 +237,14 @@ export type RecoveredSecret = { */ export type RecoveryAuthProvider = { getAuthenticatedProfileId: () => Promise; + /** + * Returns the Auth API's /api/v2/recovery/approvals approval directly. + * `requireTwoFactor` requests recent AAL2 authentication. + */ authorizeRecoveryRequest: (params: { requestHash: string; audiences: string[]; requireTwoFactor?: boolean; - identifiers?: Identifier[]; }) => Promise; }; diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index d7bfb04c53c..cdfb518c7d8 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -2,7 +2,6 @@ import { bytesToBase64, bytesToHex, stringToBytes } from '@metamask/utils'; import { decodeAuthControllerToken } from '../src/auth-controller-token.js'; import { - canonicalizeIdentifiers, decodeHex, decryptFromPublic, encryptToPublic, @@ -56,7 +55,7 @@ const CHALLENGE_TTL_SECS = 5 * 60; /** * Builds an unsigned compact JWT carrying the claims the controller reads from - * an AuthController (Hydra) access token. + * an Auth API approval. * * @param claims - Token claims. * @param claims.sub - The profile id. @@ -70,7 +69,7 @@ export function createAuthControllerToken({ sub, exp, requestHash, - aal, + aal = 1, identifiersHash, }: { sub: string; @@ -100,6 +99,12 @@ export class StubAuthProvider implements RecoveryAuthProvider { now: () => number = unixNow; + identifiers: Identifier[]; + + constructor(identifiers: Identifier[] = []) { + this.identifiers = identifiers; + } + async getAuthenticatedProfileId(): Promise { return this.profileId; } @@ -107,20 +112,13 @@ export class StubAuthProvider implements RecoveryAuthProvider { async authorizeRecoveryRequest(params: { requestHash: string; requireTwoFactor?: boolean; - identifiers?: Identifier[]; }): Promise { return createAuthControllerToken({ sub: this.profileId, - exp: this.now() + 60 * 60, + exp: this.now() + 5 * 60, requestHash: params.requestHash, aal: params.requireTwoFactor ? 2 : 1, - ...(params.identifiers === undefined - ? {} - : { - identifiersHash: await hash( - canonicalizeIdentifiers(params.identifiers), - ), - }), + identifiersHash: await hash(this.identifiers), }); } } @@ -557,7 +555,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { ): Promise { if ( decodeAuthControllerToken(token)?.ext.identifiersHash !== - (await hash(canonicalizeIdentifiers(identifiers))) + (await hash(identifiers)) ) { throw new MfaRecoveryError( 'Identifier ownership not approved', From c48527d734f2a612ff90ac7a88057b6367d5bd60 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:19:24 +0800 Subject: [PATCH 49/55] feat: add Auth API recovery approval provider --- packages/mfa-recovery-controller/README.md | 6 +- .../src/auth-api-recovery-provider.test.ts | 84 +++++++++++++++++++ .../src/auth-api-recovery-provider.ts | 78 +++++++++++++++++ packages/mfa-recovery-controller/src/index.ts | 2 + 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts create mode 100644 packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md index 763fa700710..f9fbf9ef67f 100644 --- a/packages/mfa-recovery-controller/README.md +++ b/packages/mfa-recovery-controller/README.md @@ -7,8 +7,10 @@ Pending mutations are encrypted in controller state. `resume()` completes them; ## Auth API Integration -`RecoveryAuthProvider` posts `{ request_hash, audiences }` to -`/api/v2/recovery/approvals` with a Hydra bearer and returns `approval` directly. +`AuthApiRecoveryAuthProvider` implements `RecoveryAuthProvider`. Supply the Auth +API base URL, profile id, and a callback that provides a Hydra bearer with recent +MFA when requested. The provider posts `{ request_hash, audiences }` to +`/api/v2/recovery/approvals` and returns `approval` directly. Pass Auth's `identifiers` unchanged to `register` and `updateIdentifiers`. Set `CubistEscrowProviderOptions.authAudience` to the escrow's diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts new file mode 100644 index 00000000000..e7ad970fd59 --- /dev/null +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts @@ -0,0 +1,84 @@ +import { AuthApiRecoveryAuthProvider } from './auth-api-recovery-provider.js'; + +describe('AuthApiRecoveryAuthProvider', () => { + const getAccessToken = jest.fn, [boolean]>(); + const options = { + baseUrl: 'https://auth.example/', + profileId: 'profile-1', + getAccessToken, + }; + + beforeEach(() => { + getAccessToken.mockResolvedValue('hydra-token'); + }); + + it.each([false, true])( + 'returns the approval directly (requireTwoFactor: %s)', + async (requireTwoFactor) => { + const fetchMock = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify({ approval: 'signed-approval' })), + ); + const provider = new AuthApiRecoveryAuthProvider({ + ...options, + ...(requireTwoFactor + ? { fetch: globalThis.fetch.bind(globalThis) } + : {}), + }); + expect(await provider.getAuthenticatedProfileId()).toBe('profile-1'); + expect( + await provider.authorizeRecoveryRequest({ + requestHash: '0xrequest', + audiences: ['cubist-audience'], + ...(requireTwoFactor ? { requireTwoFactor } : {}), + }), + ).toBe('signed-approval'); + expect(getAccessToken).toHaveBeenCalledWith(requireTwoFactor); + expect(fetchMock.mock.contexts[0] === globalThis).toBe(true); + expect(fetchMock.mock.calls).toStrictEqual([ + [ + 'https://auth.example/api/v2/recovery/approvals', + { + method: 'POST', + headers: { + Authorization: 'Bearer hydra-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + request_hash: '0xrequest', + audiences: ['cubist-audience'], + }), + }, + ], + ]); + }, + ); + + it('surfaces an Auth API failure', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(null, { status: 403 })); + await expect( + new AuthApiRecoveryAuthProvider(options).authorizeRecoveryRequest({ + requestHash: '0xrequest', + audiences: ['cubist-audience'], + }), + ).rejects.toThrow('Authentication API returned 403'); + }); + + it.each([null, {}, { approval: 1 }, { approval: '' }])( + 'rejects a malformed approval response: %j', + async (response) => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(response))); + await expect( + new AuthApiRecoveryAuthProvider(options).authorizeRecoveryRequest({ + requestHash: '0xrequest', + audiences: ['cubist-audience'], + }), + ).rejects.toThrow('Invalid Auth approval'); + }, + ); +}); diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts new file mode 100644 index 00000000000..99f603a6782 --- /dev/null +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts @@ -0,0 +1,78 @@ +import { isObject } from '@metamask/utils'; + +import { MfaRecoveryError } from './errors.js'; +import type { AuthControllerToken, RecoveryAuthProvider } from './types.js'; + +export type AuthApiRecoveryAuthProviderOptions = { + /** Auth API origin, without /api/v2. */ + baseUrl: string; + /** Profile id for the authenticated session. */ + profileId: string; + /** Returns a Hydra bearer; true requires recent MFA. */ + getAccessToken: (requireTwoFactor: boolean) => Promise; + fetch?: typeof globalThis.fetch; +}; + +/** Requests Auth API approvals using an authenticated Hydra session. */ +export class AuthApiRecoveryAuthProvider implements RecoveryAuthProvider { + readonly #options: AuthApiRecoveryAuthProviderOptions; + + readonly #fetch: typeof globalThis.fetch; + + /** + * @param options - Auth API URL, profile, and Hydra token source. + */ + constructor(options: AuthApiRecoveryAuthProviderOptions) { + this.#options = options; + this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis); + } + + /** @returns The authenticated profile id. */ + async getAuthenticatedProfileId(): Promise { + return this.#options.profileId; + } + + /** + * @param params - Request hash, audiences, and MFA requirement. + * @returns The Auth-signed approval without a Hydra exchange. + */ + async authorizeRecoveryRequest( + params: Parameters[0], + ): Promise { + const token = await this.#options.getAccessToken( + params.requireTwoFactor === true, + ); + const response = await this.#fetch( + `${this.#options.baseUrl.replace(/\/$/u, '')}/api/v2/recovery/approvals`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + request_hash: params.requestHash, + audiences: params.audiences, + }), + }, + ); + if (!response.ok) { + throw new MfaRecoveryError( + `Authentication API returned ${response.status}`, + 'auth_api_error', + ); + } + const result: unknown = await response.json(); + if ( + !isObject(result) || + typeof result.approval !== 'string' || + !result.approval + ) { + throw new MfaRecoveryError( + 'Invalid Auth approval', + 'auth_api_response_invalid', + ); + } + return result.approval; + } +} diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts index b5c694433e7..519b09e6bf2 100644 --- a/packages/mfa-recovery-controller/src/index.ts +++ b/packages/mfa-recovery-controller/src/index.ts @@ -23,6 +23,8 @@ export type { } from './MfaRecoveryController-method-action-types.js'; export { CubistEscrowProvider } from './escrow-providers/cubist-escrow-provider.js'; export type { CubistEscrowProviderOptions } from './escrow-providers/cubist-escrow-provider.js'; +export { AuthApiRecoveryAuthProvider } from './auth-api-recovery-provider.js'; +export type { AuthApiRecoveryAuthProviderOptions } from './auth-api-recovery-provider.js'; export { IncompleteMutationError, MfaRecoveryError, From 094d048f17c55d5ef219018ce92b4ba77eaaa083 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:34:04 +0800 Subject: [PATCH 50/55] fix: sync recovery mutations with Auth-approved identifiers --- .../mfa-recovery-controller/ARCHITECTURE.md | 5 +- packages/mfa-recovery-controller/README.md | 6 +- .../src/MfaRecoveryController.test.ts | 140 ++++++++++++++++++ .../src/MfaRecoveryController.ts | 95 ++++++++---- .../src/auth-api-recovery-provider.test.ts | 60 +++++--- .../src/auth-api-recovery-provider.ts | 23 +-- packages/mfa-recovery-controller/src/index.ts | 1 + .../src/pending-operation-validation.ts | 5 +- packages/mfa-recovery-controller/src/types.ts | 9 +- .../mfa-recovery-controller/tests/stubs.ts | 20 ++- 10 files changed, 286 insertions(+), 78 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 62730e2b449..bea0f3939bc 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -163,8 +163,9 @@ Auth approvals: - The provider receives the mutation `requestHash` and each escrow's `authAudience`. Updates set `requireTwoFactor` for AAL2. - Approvals last five minutes. AAL2 requires MFA within the preceding five minutes. -- `register` and `updateIdentifiers` use Auth's `identifiers` unchanged. - Hashing sorts object keys, not arrays, and adds no fields. +- `register` and `updateIdentifiers` retry approval once with Auth's returned + identifiers if they differ. The rebuilt request is persisted as `authorizing`; + `writing` mutations are never rebuilt. Hashing sorts object keys, not arrays. - Escrows verify signatures. The controller decodes claims only for pending-state validation and expiry-based refresh. diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md index f9fbf9ef67f..6345888d289 100644 --- a/packages/mfa-recovery-controller/README.md +++ b/packages/mfa-recovery-controller/README.md @@ -10,9 +10,11 @@ Pending mutations are encrypted in controller state. `resume()` completes them; `AuthApiRecoveryAuthProvider` implements `RecoveryAuthProvider`. Supply the Auth API base URL, profile id, and a callback that provides a Hydra bearer with recent MFA when requested. The provider posts `{ request_hash, audiences }` to -`/api/v2/recovery/approvals` and returns `approval` directly. +`/api/v2/recovery/approvals` and returns `{ approval, identifiers }`. -Pass Auth's `identifiers` unchanged to `register` and `updateIdentifiers`. +Use Auth's latest `identifiers` for `register` and `updateIdentifiers`. If they +differ from the approval response, the controller rebuilds and retries once +before writing. A mutation already in `writing` is never rebuilt. Set `CubistEscrowProviderOptions.authAudience` to the escrow's `AUTH_CONTROLLER_AUDIENCE`. diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 41940773e41..7b8e2157514 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -25,6 +25,7 @@ import { MfaRecoveryController } from './MfaRecoveryController.js'; import type { Identifier, IdentifierSession, + PendingOperation, RecoveredSecret, RecoveryEscrowProvider, RegisterPayload, @@ -727,6 +728,145 @@ describe('MfaRecoveryController', () => { }); }); + describe('Auth identifiers', () => { + it.each(['register', 'updateIdentifiers'] as const)( + 'rebuilds %s once using Auth identifiers', + async (operation) => { + await withController(async ({ controller, authProvider, escrowA }) => { + if (operation === 'updateIdentifiers') { + await controller.register(SECRET, IDENTIFIERS); + } + authProvider.identifiers = [SOCIAL, PASSKEY]; + const authorize = jest.spyOn( + authProvider, + 'authorizeRecoveryRequest', + ); + const apply = jest.spyOn(escrowA, 'applyMutation'); + if (operation === 'register') { + await controller.register(SECRET, IDENTIFIERS); + } else { + await controller.updateIdentifiers( + PASSKEY, + IDENTIFIERS, + REGISTERED_EPOCH, + ); + } + expect(authorize).toHaveBeenCalledTimes(2); + expect(authorize.mock.calls[1][0].requestHash).not.toBe( + authorize.mock.calls[0][0].requestHash, + ); + expect(apply).toHaveBeenCalledTimes(1); + expect(apply.mock.calls[0][0].requestHash).toBe( + authorize.mock.calls[1][0].requestHash, + ); + expect(apply.mock.calls[0][3]).toMatchObject({ + identifiers: authProvider.identifiers, + }); + }); + }, + ); + + it('resumes the rebuilt authorizing request after an approval failure', async () => { + await withController( + async ({ controller, authProvider, escrowA, options }) => { + authProvider.identifiers = [SOCIAL, PASSKEY]; + const approve = + authProvider.authorizeRecoveryRequest.bind(authProvider); + const authorize = jest + .spyOn(authProvider, 'authorizeRecoveryRequest') + .mockImplementationOnce(approve) + .mockRejectedValueOnce(new Error('auth down')); + const apply = jest.spyOn(escrowA, 'applyMutation'); + await expect( + controller.register(SECRET, IDENTIFIERS), + ).rejects.toThrow('auth down'); + const pending = JSON.parse( + controller.state.pendingOperation as string, + ) as PendingOperation; + expect(pending.phase).toBe('authorizing'); + expect(pending.payload).toMatchObject({ + identifiers: authProvider.identifiers, + }); + expect(pending.mutation.requestHash).toBe( + authorize.mock.calls[1][0].requestHash, + ); + expect(apply).not.toHaveBeenCalled(); + const resumed = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: controller.state, + }); + await resumed.resume(); + expect(authorize).toHaveBeenCalledTimes(3); + expect(authorize.mock.calls[2][0].requestHash).toBe( + pending.mutation.requestHash, + ); + expect(await resumed.getPhase()).toBe('idle'); + }, + ); + }); + + it('stays abortable when identifiers change again on the retry', async () => { + await withController(async ({ controller, authProvider, escrowA }) => { + authProvider.identifiers = [SOCIAL, PASSKEY]; + const approve = + authProvider.authorizeRecoveryRequest.bind(authProvider); + const authorize = jest + .spyOn(authProvider, 'authorizeRecoveryRequest') + .mockImplementationOnce(approve) + .mockImplementationOnce(async (params) => { + authProvider.identifiers = IDENTIFIERS; + return await approve(params); + }); + const apply = jest.spyOn(escrowA, 'applyMutation'); + await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( + 'Invalid pending operation', + ); + expect(authorize).toHaveBeenCalledTimes(2); + expect(apply).not.toHaveBeenCalled(); + expect(await controller.getPhase()).toBe('authorizing'); + await controller.abort(); + expect(await controller.getPhase()).toBe('idle'); + }); + }); + + it('does not rebuild a writing mutation when refreshing its approval', async () => { + await withController( + async ({ controller, authProvider, escrowA, options }) => { + escrowA.failNextApplyCount = 1; + await expect( + controller.register(SECRET, IDENTIFIERS), + ).rejects.toBeInstanceOf(IncompleteMutationError); + const pending = JSON.parse( + controller.state.pendingOperation as string, + ) as WritingPendingOperation; + pending.authControllerToken = createAuthControllerToken({ + sub: pending.mutation.profileId, + exp: 0, + requestHash: pending.mutation.requestHash, + identifiersHash: await hash(IDENTIFIERS), + }); + const encoded = await passthroughEncryptor.encrypt(pending); + const resumed = new MfaRecoveryController({ + ...options, + messenger: getMessenger(getRootMessenger()), + state: { pendingOperation: encoded }, + }); + authProvider.identifiers = [SOCIAL, PASSKEY]; + const apply = jest.spyOn(escrowA, 'applyMutation'); + await expect(resumed.resume()).rejects.toThrow( + 'Invalid pending operation', + ); + expect(apply).not.toHaveBeenCalled(); + expect(resumed.state.pendingOperation).toBe(encoded); + authProvider.identifiers = IDENTIFIERS; + await resumed.resume(); + expect(await resumed.getPhase()).toBe('idle'); + }, + ); + }); + }); + describe('resume and abort', () => { it('resumes a persisted writing mutation after a replica failure', async () => { await withController(async ({ controller, escrowA }) => { diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 23c33d44f3c..1d25f0bf094 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -45,18 +45,20 @@ import { } from './pending-operation-validation.js'; import { assertAbortAllowed, getRecoveryPhase } from './state-machine.js'; import type { - AuthControllerToken, + AuthorizingPendingOperation, EcPublicJwk, Identifier, IdentifierSession, Mutation, MutationPayload, MutationReceipt, + PendingMutationPayload, PendingOperation, PendingOperationEncryptor, PendingRegisterPayload, PendingUpdateIdentifiersPayload, PendingUpdateRecoverySecretPayload, + RecoveryApproval, RecoveryAuthProvider, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, @@ -469,23 +471,19 @@ export class MfaRecoveryController extends BaseController< const profileId = await this.#authProvider.getAuthenticatedProfileId(); const audiences = this.#escrows.map((escrow) => escrow.id); const currentVersion = this.#resolveCurrentRecoveryVersion(epoch); - const payloadHash = await hash(payload); - - const mutationFields = { - id: randomId(), - profileId, - operation, - expectedVersion: currentVersion, - newVersion: currentVersion + 1, - payloadHash, - audiences, - }; - const mutation: Mutation = { - ...mutationFields, - requestHash: await hash(mutationFields), - }; + const mutation = await this.#buildMutation( + { + id: randomId(), + profileId, + operation, + expectedVersion: currentVersion, + newVersion: currentVersion + 1, + audiences, + }, + payload, + ); - const pending: PendingOperation = { + const pending: AuthorizingPendingOperation = { phase: 'authorizing', mutation, payload, @@ -493,17 +491,20 @@ export class MfaRecoveryController extends BaseController< }; await this.#persistPending(pending); - const authControllerToken = await this.#authorizeMutation(mutation); - await this.#replicateMutation({ - ...pending, - phase: 'writing', - authControllerToken, - receipts: [], - }); + const writing = await this.#prepareMutation(pending); + await this.#replicateMutation(writing); }); } - async #authorizeMutation(mutation: Mutation): Promise { + async #buildMutation( + fields: Omit, + payload: PendingMutationPayload, + ): Promise { + const mutationFields = { ...fields, payloadHash: await hash(payload) }; + return { ...mutationFields, requestHash: await hash(mutationFields) }; + } + + async #authorizeMutation(mutation: Mutation): Promise { return await this.#authProvider.authorizeRecoveryRequest({ requestHash: mutation.requestHash, audiences: this.#escrows.map((escrow) => escrow.authAudience), @@ -511,6 +512,39 @@ export class MfaRecoveryController extends BaseController< }); } + async #prepareMutation( + pending: AuthorizingPendingOperation, + ): Promise { + const authorization = await this.#authorizeMutation(pending.mutation); + const writing: WritingPendingOperation = { + ...pending, + phase: 'writing', + authControllerToken: authorization.approval, + receipts: [], + }; + if ( + !pendingPayloadHasIdentifiers(pending.payload) || + (await hash(authorization.identifiers)) === + (await hash(pending.payload.identifiers)) + ) { + return writing; + } + + const payload = { + ...pending.payload, + identifiers: authorization.identifiers, + }; + const { requestHash: _requestHash, ...fields } = pending.mutation; + const mutation = await this.#buildMutation(fields, payload); + await this.#persistPending({ ...pending, mutation, payload }); + return { + ...writing, + mutation, + payload, + authControllerToken: (await this.#authorizeMutation(mutation)).approval, + }; + } + async #repairPendingMutation(): Promise { const pending = await this.#loadPending(); if (!pending) { @@ -519,12 +553,8 @@ export class MfaRecoveryController extends BaseController< if (pending.phase === 'authorizing') { await this.#assertEscrowsAvailable(this.#escrows); - await this.#replicateMutation({ - ...pending, - phase: 'writing', - authControllerToken: await this.#authorizeMutation(pending.mutation), - receipts: [], - }); + const writing = await this.#prepareMutation(pending); + await this.#replicateMutation(writing); return; } @@ -546,8 +576,9 @@ export class MfaRecoveryController extends BaseController< const authControllerToken = claims && claims.exp > this.#now() ? pending.authControllerToken - : await this.#authorizeMutation(mutation); + : (await this.#authorizeMutation(mutation)).approval; const writing = { ...pending, authControllerToken }; + await this.#assertPending(writing); const authorizedEscrows = mutation.operation === 'register' ? null diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts index e7ad970fd59..1fa153720ee 100644 --- a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts @@ -7,19 +7,37 @@ describe('AuthApiRecoveryAuthProvider', () => { profileId: 'profile-1', getAccessToken, }; + const approval = { + approval: 'signed-approval', + identifiers: [ + { + type: 'social', + namespace: 'https://accounts.google.com', + value: 'hash', + }, + { + type: 'passkey', + namespace: 'metamask.io', + value: 'AQID', + verifier: { + publicKey: { cose: 'AQID' }, + rpId: 'metamask.io', + origins: ['https://metamask.io'], + }, + }, + ], + }; beforeEach(() => { getAccessToken.mockResolvedValue('hydra-token'); }); it.each([false, true])( - 'returns the approval directly (requireTwoFactor: %s)', + 'returns the approval and identifiers (requireTwoFactor: %s)', async (requireTwoFactor) => { const fetchMock = jest .spyOn(globalThis, 'fetch') - .mockResolvedValue( - new Response(JSON.stringify({ approval: 'signed-approval' })), - ); + .mockResolvedValue(new Response(JSON.stringify(approval))); const provider = new AuthApiRecoveryAuthProvider({ ...options, ...(requireTwoFactor @@ -33,7 +51,7 @@ describe('AuthApiRecoveryAuthProvider', () => { audiences: ['cubist-audience'], ...(requireTwoFactor ? { requireTwoFactor } : {}), }), - ).toBe('signed-approval'); + ).toStrictEqual(approval); expect(getAccessToken).toHaveBeenCalledWith(requireTwoFactor); expect(fetchMock.mock.contexts[0] === globalThis).toBe(true); expect(fetchMock.mock.calls).toStrictEqual([ @@ -67,18 +85,22 @@ describe('AuthApiRecoveryAuthProvider', () => { ).rejects.toThrow('Authentication API returned 403'); }); - it.each([null, {}, { approval: 1 }, { approval: '' }])( - 'rejects a malformed approval response: %j', - async (response) => { - jest - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(JSON.stringify(response))); - await expect( - new AuthApiRecoveryAuthProvider(options).authorizeRecoveryRequest({ - requestHash: '0xrequest', - audiences: ['cubist-audience'], - }), - ).rejects.toThrow('Invalid Auth approval'); - }, - ); + it.each([ + null, + {}, + { ...approval, approval: 1 }, + { ...approval, approval: '' }, + { ...approval, identifiers: null }, + { ...approval, identifiers: [{}] }, + ])('rejects a malformed approval response: %j', async (response) => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(response))); + await expect( + new AuthApiRecoveryAuthProvider(options).authorizeRecoveryRequest({ + requestHash: '0xrequest', + audiences: ['cubist-audience'], + }), + ).rejects.toThrow('Invalid Auth approval'); + }); }); diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts index 99f603a6782..7beb2bd95e8 100644 --- a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts @@ -1,7 +1,14 @@ -import { isObject } from '@metamask/utils'; +import { array, is, size, string, type } from '@metamask/superstruct'; import { MfaRecoveryError } from './errors.js'; -import type { AuthControllerToken, RecoveryAuthProvider } from './types.js'; +import type { RecoveryApproval, RecoveryAuthProvider } from './types.js'; + +const RecoveryApprovalStruct = type({ + approval: size(string(), 1, Infinity), + identifiers: array( + type({ type: string(), namespace: string(), value: string() }), + ), +}); export type AuthApiRecoveryAuthProviderOptions = { /** Auth API origin, without /api/v2. */ @@ -34,11 +41,11 @@ export class AuthApiRecoveryAuthProvider implements RecoveryAuthProvider { /** * @param params - Request hash, audiences, and MFA requirement. - * @returns The Auth-signed approval without a Hydra exchange. + * @returns The Auth-signed approval and current recovery identifiers. */ async authorizeRecoveryRequest( params: Parameters[0], - ): Promise { + ): Promise { const token = await this.#options.getAccessToken( params.requireTwoFactor === true, ); @@ -63,16 +70,12 @@ export class AuthApiRecoveryAuthProvider implements RecoveryAuthProvider { ); } const result: unknown = await response.json(); - if ( - !isObject(result) || - typeof result.approval !== 'string' || - !result.approval - ) { + if (!is(result, RecoveryApprovalStruct)) { throw new MfaRecoveryError( 'Invalid Auth approval', 'auth_api_response_invalid', ); } - return result.approval; + return { approval: result.approval, identifiers: result.identifiers }; } } diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts index 519b09e6bf2..7bd604667e0 100644 --- a/packages/mfa-recovery-controller/src/index.ts +++ b/packages/mfa-recovery-controller/src/index.ts @@ -52,6 +52,7 @@ export type { PendingRegisterPayload, PendingUpdateIdentifiersPayload, PendingUpdateRecoverySecretPayload, + RecoveryApproval, RecoveryAuthProvider, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts index db6b709a146..cb21e3a9beb 100644 --- a/packages/mfa-recovery-controller/src/pending-operation-validation.ts +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -115,9 +115,8 @@ export async function assertValidPendingOperation( token?.sub !== mutation.profileId || token.ext.requestHash !== mutation.requestHash || token.ext.aal < (mutation.operation === 'register' ? 1 : 2) || - ((mutation.operation === 'register' || - mutation.operation === 'updateIdentifiers') && - token.ext.identifiersHash === undefined) || + (pendingPayloadHasIdentifiers(payload) && + token.ext.identifiersHash !== (await hash(payload.identifiers))) || !isMutationReceiptArray(pending.receipts) ) { throwInvalidPendingOperation(); diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index 789448d4808..da9671b673b 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -65,6 +65,11 @@ export type Mutation = { */ export type AuthControllerToken = string; +export type RecoveryApproval = { + approval: AuthControllerToken; + identifiers: Identifier[]; +}; + export type MutationReceipt = { mutationId: string; requestHash: string; @@ -238,14 +243,14 @@ export type RecoveredSecret = { export type RecoveryAuthProvider = { getAuthenticatedProfileId: () => Promise; /** - * Returns the Auth API's /api/v2/recovery/approvals approval directly. + * Returns the Auth API's approval and current recovery identifiers. * `requireTwoFactor` requests recent AAL2 authentication. */ authorizeRecoveryRequest: (params: { requestHash: string; audiences: string[]; requireTwoFactor?: boolean; - }) => Promise; + }) => Promise; }; /** diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index cdfb518c7d8..0ecc93a1485 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -30,6 +30,7 @@ import type { PendingOperation, PendingOperationEncryptor, PoPChallenge, + RecoveryApproval, RecoveryAuthProvider, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, @@ -112,14 +113,17 @@ export class StubAuthProvider implements RecoveryAuthProvider { async authorizeRecoveryRequest(params: { requestHash: string; requireTwoFactor?: boolean; - }): Promise { - return createAuthControllerToken({ - sub: this.profileId, - exp: this.now() + 5 * 60, - requestHash: params.requestHash, - aal: params.requireTwoFactor ? 2 : 1, - identifiersHash: await hash(this.identifiers), - }); + }): Promise { + return { + approval: createAuthControllerToken({ + sub: this.profileId, + exp: this.now() + 5 * 60, + requestHash: params.requestHash, + aal: params.requireTwoFactor ? 2 : 1, + identifiersHash: await hash(this.identifiers), + }), + identifiers: this.identifiers, + }; } } From 88338db11c16f6d47c15a7dbccb976e75c74143a Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:20:46 +0800 Subject: [PATCH 51/55] test: align MFA recovery tests with Vitest --- .../src/MfaRecoveryController.test.ts | 65 +++++++------------ .../src/auth-api-recovery-provider.test.ts | 40 +++++++----- 2 files changed, 46 insertions(+), 59 deletions(-) diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 7b8e2157514..0ce46c299f9 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -710,7 +710,7 @@ describe('MfaRecoveryController', () => { authProvider.identifiers = [SOCIAL, PASSKEY]; await controller.updateIdentifiers( PASSKEY, - [SOCIAL, PASSKEY], + IDENTIFIERS, REGISTERED_EPOCH, ); const recovered = await recover(controller, SOCIAL); @@ -729,42 +729,25 @@ describe('MfaRecoveryController', () => { }); describe('Auth identifiers', () => { - it.each(['register', 'updateIdentifiers'] as const)( - 'rebuilds %s once using Auth identifiers', - async (operation) => { - await withController(async ({ controller, authProvider, escrowA }) => { - if (operation === 'updateIdentifiers') { - await controller.register(SECRET, IDENTIFIERS); - } - authProvider.identifiers = [SOCIAL, PASSKEY]; - const authorize = jest.spyOn( - authProvider, - 'authorizeRecoveryRequest', - ); - const apply = jest.spyOn(escrowA, 'applyMutation'); - if (operation === 'register') { - await controller.register(SECRET, IDENTIFIERS); - } else { - await controller.updateIdentifiers( - PASSKEY, - IDENTIFIERS, - REGISTERED_EPOCH, - ); - } - expect(authorize).toHaveBeenCalledTimes(2); - expect(authorize.mock.calls[1][0].requestHash).not.toBe( - authorize.mock.calls[0][0].requestHash, - ); - expect(apply).toHaveBeenCalledTimes(1); - expect(apply.mock.calls[0][0].requestHash).toBe( - authorize.mock.calls[1][0].requestHash, - ); - expect(apply.mock.calls[0][3]).toMatchObject({ - identifiers: authProvider.identifiers, - }); + it('rebuilds registration once using Auth identifiers', async () => { + await withController(async ({ controller, authProvider, escrowA }) => { + authProvider.identifiers = [SOCIAL, PASSKEY]; + const authorize = vi.spyOn(authProvider, 'authorizeRecoveryRequest'); + const apply = vi.spyOn(escrowA, 'applyMutation'); + await controller.register(SECRET, IDENTIFIERS); + expect(authorize).toHaveBeenCalledTimes(2); + expect(authorize.mock.calls[1][0].requestHash).not.toBe( + authorize.mock.calls[0][0].requestHash, + ); + expect(apply).toHaveBeenCalledTimes(1); + expect(apply.mock.calls[0][0].requestHash).toBe( + authorize.mock.calls[1][0].requestHash, + ); + expect(apply.mock.calls[0][3]).toMatchObject({ + identifiers: authProvider.identifiers, }); - }, - ); + }); + }); it('resumes the rebuilt authorizing request after an approval failure', async () => { await withController( @@ -772,11 +755,11 @@ describe('MfaRecoveryController', () => { authProvider.identifiers = [SOCIAL, PASSKEY]; const approve = authProvider.authorizeRecoveryRequest.bind(authProvider); - const authorize = jest + const authorize = vi .spyOn(authProvider, 'authorizeRecoveryRequest') .mockImplementationOnce(approve) .mockRejectedValueOnce(new Error('auth down')); - const apply = jest.spyOn(escrowA, 'applyMutation'); + const apply = vi.spyOn(escrowA, 'applyMutation'); await expect( controller.register(SECRET, IDENTIFIERS), ).rejects.toThrow('auth down'); @@ -811,14 +794,14 @@ describe('MfaRecoveryController', () => { authProvider.identifiers = [SOCIAL, PASSKEY]; const approve = authProvider.authorizeRecoveryRequest.bind(authProvider); - const authorize = jest + const authorize = vi .spyOn(authProvider, 'authorizeRecoveryRequest') .mockImplementationOnce(approve) .mockImplementationOnce(async (params) => { authProvider.identifiers = IDENTIFIERS; return await approve(params); }); - const apply = jest.spyOn(escrowA, 'applyMutation'); + const apply = vi.spyOn(escrowA, 'applyMutation'); await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( 'Invalid pending operation', ); @@ -853,7 +836,7 @@ describe('MfaRecoveryController', () => { state: { pendingOperation: encoded }, }); authProvider.identifiers = [SOCIAL, PASSKEY]; - const apply = jest.spyOn(escrowA, 'applyMutation'); + const apply = vi.spyOn(escrowA, 'applyMutation'); await expect(resumed.resume()).rejects.toThrow( 'Invalid pending operation', ); diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts index 1fa153720ee..caf2acb093e 100644 --- a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts @@ -1,7 +1,10 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + import { AuthApiRecoveryAuthProvider } from './auth-api-recovery-provider.js'; describe('AuthApiRecoveryAuthProvider', () => { - const getAccessToken = jest.fn, [boolean]>(); + const getAccessToken = + vi.fn<(requireTwoFactor: boolean) => Promise>(); const options = { baseUrl: 'https://auth.example/', profileId: 'profile-1', @@ -32,27 +35,25 @@ describe('AuthApiRecoveryAuthProvider', () => { getAccessToken.mockResolvedValue('hydra-token'); }); - it.each([false, true])( + it.each([ + [undefined, false], + [true, true], + ])( 'returns the approval and identifiers (requireTwoFactor: %s)', - async (requireTwoFactor) => { - const fetchMock = jest + async (requireTwoFactor, expectedTwoFactor) => { + const fetchMock = vi .spyOn(globalThis, 'fetch') .mockResolvedValue(new Response(JSON.stringify(approval))); - const provider = new AuthApiRecoveryAuthProvider({ - ...options, - ...(requireTwoFactor - ? { fetch: globalThis.fetch.bind(globalThis) } - : {}), - }); + const provider = new AuthApiRecoveryAuthProvider(options); expect(await provider.getAuthenticatedProfileId()).toBe('profile-1'); expect( await provider.authorizeRecoveryRequest({ requestHash: '0xrequest', audiences: ['cubist-audience'], - ...(requireTwoFactor ? { requireTwoFactor } : {}), + requireTwoFactor, }), ).toStrictEqual(approval); - expect(getAccessToken).toHaveBeenCalledWith(requireTwoFactor); + expect(getAccessToken).toHaveBeenCalledWith(expectedTwoFactor); expect(fetchMock.mock.contexts[0] === globalThis).toBe(true); expect(fetchMock.mock.calls).toStrictEqual([ [ @@ -74,11 +75,14 @@ describe('AuthApiRecoveryAuthProvider', () => { ); it('surfaces an Auth API failure', async () => { - jest - .spyOn(globalThis, 'fetch') + const fetchMock = vi + .fn() .mockResolvedValue(new Response(null, { status: 403 })); await expect( - new AuthApiRecoveryAuthProvider(options).authorizeRecoveryRequest({ + new AuthApiRecoveryAuthProvider({ + ...options, + fetch: fetchMock, + }).authorizeRecoveryRequest({ requestHash: '0xrequest', audiences: ['cubist-audience'], }), @@ -93,9 +97,9 @@ describe('AuthApiRecoveryAuthProvider', () => { { ...approval, identifiers: null }, { ...approval, identifiers: [{}] }, ])('rejects a malformed approval response: %j', async (response) => { - jest - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(JSON.stringify(response))); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(response)), + ); await expect( new AuthApiRecoveryAuthProvider(options).authorizeRecoveryRequest({ requestHash: '0xrequest', From 7862a222d735937c6edf4dfb310a15d2a29d86de Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:46:22 +0800 Subject: [PATCH 52/55] fix: require AAL2 approvals for recovery reads and writes --- .../mfa-recovery-controller/ARCHITECTURE.md | 87 ++------------ packages/mfa-recovery-controller/CHANGELOG.md | 1 + .../CUBIST_TEST_SEQUENCE.md | 113 +++++------------- packages/mfa-recovery-controller/README.md | 20 ++-- .../src/MfaRecoveryController.test.ts | 87 +++++++++++--- .../src/MfaRecoveryController.ts | 8 +- .../src/auth-api-recovery-provider.test.ts | 2 +- .../src/auth-api-recovery-provider.ts | 4 +- .../cubist-escrow-provider.test.ts | 5 +- .../cubist-escrow-provider.ts | 3 + .../src/pending-operation-validation.ts | 2 +- packages/mfa-recovery-controller/src/types.ts | 1 + .../mfa-recovery-controller/tests/stubs.ts | 14 ++- 13 files changed, 151 insertions(+), 196 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index bea0f3939bc..9cd3b01522a 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -107,10 +107,9 @@ 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 bind + `identifiers_hash`; receipts must be well-formed with at most one per escrow. ## Mutation sequence (`register` / updates) @@ -160,9 +159,9 @@ sequenceDiagram Auth approvals: -- The provider receives the mutation `requestHash` and each escrow's - `authAudience`. Updates set `requireTwoFactor` for AAL2. -- Approvals last five minutes. AAL2 requires MFA within the preceding five minutes. +- 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. - `register` and `updateIdentifiers` retry approval once with Auth's returned identifiers if they differ. The rebuilt request is persisted as `authorizing`; `writing` mutations are never rebuilt. Hashing sorts object keys, not arrays. @@ -199,7 +198,7 @@ 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`. @@ -218,53 +217,7 @@ the mutation id, request hash, escrow id, `version === newVersion`, `receiptKeyId` (SHA-256 of the pinned receipt key), and the ECDSA 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: Auth API approval - 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`) @@ -278,6 +231,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) @@ -291,9 +245,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 @@ -336,22 +292,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] -``` diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index d54dfc7524a..a1e8c7e967f 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -10,6 +10,7 @@ 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. - Exports `MfaRecoveryController`, `getDefaultMfaRecoveryControllerState`, and the controller's action, event, messenger, state, and options types. - Exports `CubistEscrowProvider` and `CubistEscrowProviderOptions` for storing recovery secrets with CubeSigner. - Exports `MfaRecoveryError`, `IncompleteMutationError`, and `MutationRejectedError`. diff --git a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md index 29e17ff35fb..bd9f3b023e9 100644 --- a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md +++ b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md @@ -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 @@ -83,7 +50,8 @@ sequenceDiagram ## Write: `register`, `updateRecoverySecret`, `updateIdentifiers` -All three mutations share one path. +All mutations require a request-bound AAL2 approval. `register` and +`updateIdentifiers` also bind the identifier set via `identifiers_hash`. ```mermaid sequenceDiagram @@ -98,7 +66,7 @@ sequenceDiagram C->>Auth: getAuthenticatedProfileId() Auth-->>C: profileId Note over C: Build the mutation (version epoch → epoch + 1)
and its requestHash - C->>Auth: authorizeRecoveryRequest(requestHash, ...) + C->>Auth: authorizeRecoveryRequest(requestHash, requireTwoFactor: true, ...) Auth-->>C: JWT bound to requestHash C->>Cubist: isAvailable @@ -113,11 +81,11 @@ sequenceDiagram 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 @@ -126,47 +94,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() → P-256 proof key pair - C->>Id: getKeyBoundIdentifierToken(identifier, proofPublicKey, requestHash) - Note over Id: binding = hash([proofPublicKey, requestHash])
SIWE: personal_sign a SIWE message, Nonce = binding
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,
hash([token, challenge.id, requestHash])) - Note over C: authorization = { kind: key-bound, token,
proof: { challengeId, requestHash, signature } } - C->>Cubist: applyMutation or getSecret(authorization, ...) - Note over Cubist: providerAssertion is valid for the identifier
and binds proofPublicKey + requestHash
identifier is registered, challenge id is its own
signature verifies under proofPublicKey - end -``` +1. The controller generates a P-256 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` @@ -175,6 +115,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 @@ -186,8 +127,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 } diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md index 6345888d289..95c794eabbd 100644 --- a/packages/mfa-recovery-controller/README.md +++ b/packages/mfa-recovery-controller/README.md @@ -7,16 +7,16 @@ Pending mutations are encrypted in controller state. `resume()` completes them; ## Auth API Integration -`AuthApiRecoveryAuthProvider` implements `RecoveryAuthProvider`. Supply the Auth -API base URL, profile id, and a callback that provides a Hydra bearer with recent -MFA when requested. The provider posts `{ request_hash, audiences }` to -`/api/v2/recovery/approvals` and returns `{ approval, identifiers }`. - -Use Auth's latest `identifiers` for `register` and `updateIdentifiers`. If they -differ from the approval response, the controller rebuilds and retries once -before writing. A mutation already in `writing` is never rebuilt. -Set `CubistEscrowProviderOptions.authAudience` to the escrow's -`AUTH_CONTROLLER_AUDIENCE`. +Configure `AuthApiRecoveryAuthProvider` with the Auth API base URL, profile id, +and a callback that supplies a Hydra bearer with recent MFA when requested. +It posts `{ request_hash, audiences }` to `/api/v2/mfa/approvals` and returns +`{ approval, identifiers }`. Writes and reads require a request-bound AAL2 +approval; updates and reads also require an identifier proof. + +Use Auth's latest `identifiers` for `register` and `updateIdentifiers`. A mismatch +with the approval response triggers one rebuild and retry before writing; +`writing` mutations are never rebuilt. Set `CubistEscrowProviderOptions.authAudience` +to the escrow's `AUTH_CONTROLLER_AUDIENCE`. ## Installation diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 0ce46c299f9..76387efe005 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -155,11 +155,21 @@ describe('MfaRecoveryController', () => { describe('register', () => { it('replicates the recovery secret to every escrow', async () => { - await withController(async ({ controller, escrowA, escrowB }) => { + await withController(async ({ controller, options }) => { + const [escrowA, escrowB] = options.escrows; + const authorize = vi.spyOn( + options.authProvider, + 'authorizeRecoveryRequest', + ); const verifyReceipt = vi.spyOn(escrowA, 'verifyReceipt'); const applyA = vi.spyOn(escrowA, 'applyMutation'); const applyB = vi.spyOn(escrowB, 'applyMutation'); await controller.register(SECRET, IDENTIFIERS); + expect(authorize).toHaveBeenCalledExactlyOnceWith({ + requestHash: applyA.mock.calls[0][0].requestHash, + audiences: [escrowA.authAudience, escrowB.authAudience], + requireTwoFactor: true, + }); expect(controller.state.pendingOperation).toBeNull(); expect(await controller.getPhase()).toBe('idle'); expect(verifyReceipt.mock.calls[0][2]).toBe('escrow-a'); @@ -179,6 +189,24 @@ describe('MfaRecoveryController', () => { }); }); + it('rejects an AAL1 approval before writing', async () => { + await withController(async ({ controller, authProvider, escrowA }) => { + const approve = + authProvider.authorizeRecoveryRequest.bind(authProvider); + vi.spyOn(authProvider, 'authorizeRecoveryRequest').mockImplementation( + (params) => approve({ ...params, requireTwoFactor: false }), + ); + const apply = vi.spyOn(escrowA, 'applyMutation'); + + await expect( + controller.register(SECRET, IDENTIFIERS), + ).rejects.toMatchObject({ + code: 'invalid_pending_operation', + }); + expect(apply).not.toHaveBeenCalled(); + }); + }); + it('publishes state changes through the stateChanged event', async () => { await withController(async ({ controller, rootMessenger }) => { const listener = vi.fn(); @@ -316,40 +344,62 @@ describe('MfaRecoveryController', () => { }); }); - it('does not request another token when reading with a session', async () => { + it('reuses the identifier token but requests an approval for each read', async () => { await withController(async ({ controller, options }) => { + const [escrowA, escrowB] = options.escrows; await controller.register(SECRET, IDENTIFIERS); + const authorize = vi.spyOn( + options.authProvider, + 'authorizeRecoveryRequest', + ); + const read = vi.spyOn(escrowA, 'getSecret'); const getToken = vi.spyOn( options.identifierAuthProvider, 'getKeyBoundIdentifierToken', ); const session = await controller.authenticateIdentifier(PASSKEY); + expect(await controller.getRecoverySecret(session)).toMatchObject({ + epoch: REGISTERED_EPOCH, + }); + expect(await controller.getRecoverySecret(session)).toMatchObject({ + epoch: REGISTERED_EPOCH, + }); expect(getToken).toHaveBeenCalledTimes(1); - - await controller.getRecoverySecret(session); - expect(getToken).toHaveBeenCalledTimes(1); + expect(authorize).toHaveBeenCalledTimes(2); + expect(authorize).toHaveBeenCalledWith({ + requestHash: session.token.requestHash, + audiences: [escrowA.authAudience, escrowB.authAudience], + requireTwoFactor: true, + }); + expect(read).toHaveBeenCalledTimes(2); + expect(read).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ token: session.token }), + session.requestId, + session.pkE, + ); }); }); + }); - it('allows a session to be reused for another read', async () => { - await withController(async ({ controller }) => { + describe('getRecoverySecret', () => { + it('does not read when approval fails', async () => { + await withController(async ({ controller, authProvider, escrowA }) => { await controller.register(SECRET, IDENTIFIERS); - const session = await controller.authenticateIdentifier(PASSKEY); + vi.spyOn( + authProvider, + 'authorizeRecoveryRequest', + ).mockRejectedValueOnce(new Error('MFA required')); + const read = vi.spyOn(escrowA, 'getSecret'); - const first = await controller.getRecoverySecret(session); - expect(first).toStrictEqual( - expect.objectContaining({ epoch: REGISTERED_EPOCH }), - ); - const second = await controller.getRecoverySecret(session); - expect(second).toStrictEqual( - expect.objectContaining({ epoch: REGISTERED_EPOCH }), + await expect(recover(controller, PASSKEY)).rejects.toThrow( + 'MFA required', ); + expect(read).not.toHaveBeenCalled(); }); }); - }); - describe('getRecoverySecret', () => { it('reads the highest version while a mutation is writing', async () => { await withController(async ({ controller, escrowA }) => { await controller.register(SECRET, IDENTIFIERS); @@ -827,6 +877,7 @@ describe('MfaRecoveryController', () => { sub: pending.mutation.profileId, exp: 0, requestHash: pending.mutation.requestHash, + aal: 2, identifiersHash: await hash(IDENTIFIERS), }); const encoded = await passthroughEncryptor.encrypt(pending); @@ -1251,6 +1302,7 @@ describe('MfaRecoveryController', () => { sub: 'profile-1', exp: unixNow() + 60, requestHash: mutation.requestHash, + aal: 2, identifiersHash: await hash(payload.identifiers), }), payload, @@ -1311,6 +1363,7 @@ describe('MfaRecoveryController', () => { sub: 'profile-1', exp: unixNow() + 60, requestHash: mutation.requestHash, + aal: 2, identifiersHash: await hash(payload.identifiers), }), payload, diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 1d25f0bf094..1ca01e2a6de 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -388,6 +388,11 @@ export class MfaRecoveryController extends BaseController< 'no_available_escrow', ); } + const { approval } = await this.#authProvider.authorizeRecoveryRequest({ + requestHash, + audiences: available.map((escrow) => escrow.authAudience), + requireTwoFactor: true, + }); const authorizedEscrows = await authorizeEscrowsWithToken({ escrows: available, credential: session, @@ -395,6 +400,7 @@ export class MfaRecoveryController extends BaseController< const results = await Promise.allSettled( authorizedEscrows.map(async ({ escrow, authorization }) => { const response = await escrow.getSecret( + approval, authorization, requestId, pkE, @@ -508,7 +514,7 @@ export class MfaRecoveryController extends BaseController< return await this.#authProvider.authorizeRecoveryRequest({ requestHash: mutation.requestHash, audiences: this.#escrows.map((escrow) => escrow.authAudience), - ...(mutation.operation === 'register' ? {} : { requireTwoFactor: true }), + requireTwoFactor: true, }); } diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts index caf2acb093e..87fc74d283d 100644 --- a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts @@ -57,7 +57,7 @@ describe('AuthApiRecoveryAuthProvider', () => { expect(fetchMock.mock.contexts[0] === globalThis).toBe(true); expect(fetchMock.mock.calls).toStrictEqual([ [ - 'https://auth.example/api/v2/recovery/approvals', + 'https://auth.example/api/v2/mfa/approvals', { method: 'POST', headers: { diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts index 7beb2bd95e8..2b56039a541 100644 --- a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts @@ -41,7 +41,7 @@ export class AuthApiRecoveryAuthProvider implements RecoveryAuthProvider { /** * @param params - Request hash, audiences, and MFA requirement. - * @returns The Auth-signed approval and current recovery identifiers. + * @returns The approval and current recovery identifiers. */ async authorizeRecoveryRequest( params: Parameters[0], @@ -50,7 +50,7 @@ export class AuthApiRecoveryAuthProvider implements RecoveryAuthProvider { params.requireTwoFactor === true, ); const response = await this.#fetch( - `${this.#options.baseUrl.replace(/\/$/u, '')}/api/v2/recovery/approvals`, + `${this.#options.baseUrl.replace(/\/$/u, '')}/api/v2/mfa/approvals`, { method: 'POST', headers: { 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 aa6811c1a9c..1e928db5149 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 @@ -214,11 +214,12 @@ describe('CubistEscrowProvider', () => { it('invokes getSecret and returns the wrapped secret', async () => { const { provider, invoke } = createProvider({ stdout: secretResponse }); expect( - await provider.getSecret(AUTHORIZATION, 'req-1', PK_E), + await provider.getSecret(TOKEN, AUTHORIZATION, 'req-1', PK_E), ).toStrictEqual(secretResponse); expect(invoke).toHaveBeenCalledWith('cubist_secret_escrow', 'latest', { request: { cmd: 'getSecret', + authControllerToken: TOKEN, identifierAuthorization: AUTHORIZATION, requestId: 'req-1', pkE: PK_E, @@ -239,7 +240,7 @@ describe('CubistEscrowProvider', () => { ])('rejects a secret response that is %s', async (_label, body) => { const { provider } = createProvider({ stdout: body }); await expect( - provider.getSecret(AUTHORIZATION, 'req-1', PK_E), + provider.getSecret(TOKEN, AUTHORIZATION, 'req-1', PK_E), ).rejects.toMatchObject({ code: 'escrow_response_invalid' }); }); }); 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 1ed067af30a..d42d9b61b1c 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 @@ -107,12 +107,14 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { } /** + * @param authControllerToken - Request-bound approval for this read. * @param authorization - Identifier proof for this read. * @param requestId - Client request id bound into the read hash. * @param pkE - Ephemeral wrap public key for the response. * @returns The wrapped recovery secret and its version. */ async getSecret( + authControllerToken: AuthControllerToken, authorization: IdentifierAuthorization, requestId: string, pkE: EcPublicJwk, @@ -123,6 +125,7 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { identifierAuthorization: authorization, requestId, pkE, + authControllerToken, }, parseGetSecretResponse, ); diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts index cb21e3a9beb..8065d17aa38 100644 --- a/packages/mfa-recovery-controller/src/pending-operation-validation.ts +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -114,7 +114,7 @@ export async function assertValidPendingOperation( if ( token?.sub !== mutation.profileId || token.ext.requestHash !== mutation.requestHash || - token.ext.aal < (mutation.operation === 'register' ? 1 : 2) || + token.ext.aal < 2 || (pendingPayloadHasIdentifiers(payload) && token.ext.identifiersHash !== (await hash(payload.identifiers))) || !isMutationReceiptArray(pending.receipts) diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index da9671b673b..2a66258db42 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -283,6 +283,7 @@ export type RecoveryEscrowProvider = { isAvailable: () => Promise; generateChallenge: () => Promise; getSecret: ( + authControllerToken: AuthControllerToken, authorization: IdentifierAuthorization, requestId: string, pkE: EcPublicJwk, diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index 0ecc93a1485..517b1e9eebe 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -240,6 +240,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { } async getSecret( + authControllerToken: AuthControllerToken, authorization: IdentifierAuthorization, requestId: string, pkE: EcPublicJwk, @@ -259,6 +260,15 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { authorization, requestHash, ); + const claims = decodeAuthControllerToken(authControllerToken); + if ( + claims?.sub !== profileId || + claims.ext.requestHash !== requestHash || + claims.ext.aal < 2 || + claims.exp <= this.now() + ) { + throw new MfaRecoveryError('Invalid read approval', 'invalid_read_auth'); + } const record = this.#records.get(profileId); if (record === undefined) { throw new MfaRecoveryError('No recovery record', 'not_registered'); @@ -380,8 +390,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { return await this.#signReceipt(mutation); } - const claims = decodeAuthControllerToken(authControllerToken); - if (record === undefined || (claims?.ext.aal ?? 0) < 2) { + if (record === undefined) { throw new MfaRecoveryError( 'Update not authorized', 'update_unauthorized', @@ -536,6 +545,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { requestHash !== mutation.requestHash || claims?.ext.requestHash !== mutation.requestHash || claims.sub !== mutation.profileId || + claims.ext.aal < 2 || claims.exp <= this.now() || !this.#hasExactAudiences(mutation.audiences) ) { From 293109ece6921872d17d50968b2b14dc5689edc4 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:05:24 +0800 Subject: [PATCH 53/55] feat: send approved identifiers with recovery mutations Pass Auth's identifier list to the escrow as approvedIdentifiers instead of rebuilding the mutation, and map Auth API 403s to authentication_required. --- .../mfa-recovery-controller/ARCHITECTURE.md | 14 +- packages/mfa-recovery-controller/CHANGELOG.md | 2 + .../CUBIST_TEST_SEQUENCE.md | 5 +- packages/mfa-recovery-controller/README.md | 9 +- .../src/MfaRecoveryController.test.ts | 143 ++++++++---------- .../src/MfaRecoveryController.ts | 45 ++---- .../src/auth-api-recovery-provider.test.ts | 12 +- .../src/auth-api-recovery-provider.ts | 3 +- .../cubist-escrow-provider.test.ts | 38 ++++- .../cubist-escrow-provider.ts | 5 + .../src/pending-operation-validation.ts | 5 +- packages/mfa-recovery-controller/src/types.ts | 6 + .../mfa-recovery-controller/tests/stubs.ts | 16 +- 13 files changed, 169 insertions(+), 134 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 73e023e3640..e5899cd1e8d 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -113,8 +113,9 @@ Anything that fails is rejected rather than acted on: - `register` has `expectedVersion` `0` and `identifier: null`; updates carry an identifier. - `writing` only: the approval binds `sub` to `profileId` and `ext.request_hash` - to `requestHash`, with `aal >= 2`. `register` / `updateIdentifiers` also bind - `identifiers_hash`; receipts must be well-formed with at most one per escrow. + to `requestHash`, with `aal >= 2`. `register` / `updateIdentifiers` also need + `identifiers_hash` to match `approvedIdentifiers`; receipts must be + well-formed with at most one per escrow. ## Mutation sequence (`register` / updates) @@ -167,9 +168,12 @@ 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. -- `register` and `updateIdentifiers` retry approval once with Auth's returned - identifiers if they differ. The rebuilt request is persisted as `authorizing`; - `writing` mutations are never rebuilt. Hashing sorts object keys, not arrays. +- 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. +- A 403 from Auth means MFA is missing or stale (`authentication_required`). - Escrows verify signatures. The controller decodes claims only for pending-state validation and expiry-based refresh. diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index 0bdb321521a..f6893918995 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -11,7 +11,9 @@ 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 ([#10883](https://github.com/MetaMask/core/pull/10883)) - Exports `MfaRecoveryController`, `getDefaultMfaRecoveryControllerState`, and the controller's action, event, messenger, state, and options types. + - Exports `AuthApiRecoveryAuthProvider` and `AuthApiRecoveryAuthProviderOptions` for requesting approvals from the Authentication API; a 403 response throws `authentication_required` ([#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. diff --git a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md index 987b2e2c401..57c2a324207 100644 --- a/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md +++ b/packages/mfa-recovery-controller/CUBIST_TEST_SEQUENCE.md @@ -51,7 +51,8 @@ sequenceDiagram ## Write: `register`, `updateRecoverySecret`, `updateIdentifiers` All mutations require a request-bound AAL2 approval. `register` and -`updateIdentifiers` also bind the identifier set via `identifiers_hash`. +`updateIdentifiers` also send the approval's identifiers as +`approvedIdentifiers`, bound by `identifiers_hash`. ```mermaid sequenceDiagram @@ -75,7 +76,7 @@ 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 diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md index 95c794eabbd..ea3762ac93b 100644 --- a/packages/mfa-recovery-controller/README.md +++ b/packages/mfa-recovery-controller/README.md @@ -13,10 +13,11 @@ It posts `{ request_hash, audiences }` to `/api/v2/mfa/approvals` and returns `{ approval, identifiers }`. Writes and reads require a request-bound AAL2 approval; updates and reads also require an identifier proof. -Use Auth's latest `identifiers` for `register` and `updateIdentifiers`. A mismatch -with the approval response triggers one rebuild and retry before writing; -`writing` mutations are never rebuilt. Set `CubistEscrowProviderOptions.authAudience` -to the escrow's `AUTH_CONTROLLER_AUDIENCE`. +`register` and `updateIdentifiers` send the approval's `identifiers` to each +escrow as `approvedIdentifiers`; the escrow rejects payload identifiers outside +that list. A 403 from Auth throws `authentication_required`: prompt for MFA +again. Set `CubistEscrowProviderOptions.authAudience` to the escrow's +`AUTH_CONTROLLER_AUDIENCE`. ## Installation diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 46b44abb99a..63f83e8bb2f 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -30,7 +30,6 @@ import { MfaRecoveryController } from './MfaRecoveryController.js'; import type { Identifier, IdentifierSession, - PendingOperation, RecoveredSecret, RecoveryEscrowProvider, RegisterPayload, @@ -50,6 +49,8 @@ const SOCIAL: Identifier = { value: 'hashed-email', }; +const OTHER_PASSKEY: Identifier = { ...PASSKEY, value: 'cred-2' }; + const IDENTIFIERS = [PASSKEY, SOCIAL]; const SECRET = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); @@ -795,91 +796,33 @@ describe('MfaRecoveryController', () => { }); describe('Auth identifiers', () => { - it('rebuilds registration once using Auth identifiers', async () => { + it('sends the caller identifiers with all Auth-approved identifiers', async () => { await withController(async ({ controller, authProvider, escrowA }) => { - authProvider.identifiers = [SOCIAL, PASSKEY]; + authProvider.identifiers = [SOCIAL, OTHER_PASSKEY, PASSKEY]; const authorize = vi.spyOn(authProvider, 'authorizeRecoveryRequest'); const apply = vi.spyOn(escrowA, 'applyMutation'); await controller.register(SECRET, IDENTIFIERS); - expect(authorize).toHaveBeenCalledTimes(2); - expect(authorize.mock.calls[1][0].requestHash).not.toBe( - authorize.mock.calls[0][0].requestHash, - ); - expect(apply).toHaveBeenCalledTimes(1); - expect(apply.mock.calls[0][0].requestHash).toBe( - authorize.mock.calls[1][0].requestHash, - ); + expect(authorize).toHaveBeenCalledTimes(1); expect(apply.mock.calls[0][3]).toMatchObject({ - identifiers: authProvider.identifiers, + identifiers: IDENTIFIERS, }); + expect(apply.mock.calls[0][4]).toStrictEqual(authProvider.identifiers); + const recovered = await recover(controller, SOCIAL); + expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); - it('resumes the rebuilt authorizing request after an approval failure', async () => { - await withController( - async ({ controller, authProvider, escrowA, options }) => { - authProvider.identifiers = [SOCIAL, PASSKEY]; - const approve = - authProvider.authorizeRecoveryRequest.bind(authProvider); - const authorize = vi - .spyOn(authProvider, 'authorizeRecoveryRequest') - .mockImplementationOnce(approve) - .mockRejectedValueOnce(new Error('auth down')); - const apply = vi.spyOn(escrowA, 'applyMutation'); - await expect( - controller.register(SECRET, IDENTIFIERS), - ).rejects.toThrow('auth down'); - const pending = JSON.parse( - controller.state.pendingOperation as string, - ) as PendingOperation; - expect(pending.phase).toBe('authorizing'); - expect(pending.payload).toMatchObject({ - identifiers: authProvider.identifiers, - }); - expect(pending.mutation.requestHash).toBe( - authorize.mock.calls[1][0].requestHash, - ); - expect(apply).not.toHaveBeenCalled(); - const resumed = new MfaRecoveryController({ - ...options, - messenger: getMessenger(getRootMessenger()), - state: controller.state, - }); - await resumed.resume(); - expect(authorize).toHaveBeenCalledTimes(3); - expect(authorize.mock.calls[2][0].requestHash).toBe( - pending.mutation.requestHash, - ); - expect(await resumed.getPhase()).toBe('idle'); - }, - ); - }); - - it('stays abortable when identifiers change again on the retry', async () => { - await withController(async ({ controller, authProvider, escrowA }) => { - authProvider.identifiers = [SOCIAL, PASSKEY]; - const approve = - authProvider.authorizeRecoveryRequest.bind(authProvider); - const authorize = vi - .spyOn(authProvider, 'authorizeRecoveryRequest') - .mockImplementationOnce(approve) - .mockImplementationOnce(async (params) => { - authProvider.identifiers = IDENTIFIERS; - return await approve(params); - }); - const apply = vi.spyOn(escrowA, 'applyMutation'); - await expect(controller.register(SECRET, IDENTIFIERS)).rejects.toThrow( - 'Invalid pending operation', - ); - expect(authorize).toHaveBeenCalledTimes(2); - expect(apply).not.toHaveBeenCalled(); - expect(await controller.getPhase()).toBe('authorizing'); - await controller.abort(); + it('rejects identifiers that Auth did not approve', async () => { + await withController(async ({ controller, authProvider }) => { + authProvider.identifiers = [SOCIAL, OTHER_PASSKEY]; + await expect( + controller.register(SECRET, IDENTIFIERS), + ).rejects.toBeInstanceOf(MutationRejectedError); expect(await controller.getPhase()).toBe('idle'); }); }); - it('does not rebuild a writing mutation when refreshing its approval', async () => { + it('refreshes approved identifiers with an expired approval', async () => { await withController( async ({ controller, authProvider, escrowA, options }) => { escrowA.failNextApplyCount = 1; @@ -896,21 +839,19 @@ describe('MfaRecoveryController', () => { aal: 2, identifiersHash: await hash(IDENTIFIERS), }); - const encoded = await passthroughEncryptor.encrypt(pending); const resumed = new MfaRecoveryController({ ...options, messenger: getMessenger(getRootMessenger()), - state: { pendingOperation: encoded }, + state: { + pendingOperation: await passthroughEncryptor.encrypt(pending), + }, }); - authProvider.identifiers = [SOCIAL, PASSKEY]; + authProvider.identifiers = [...IDENTIFIERS, OTHER_PASSKEY]; const apply = vi.spyOn(escrowA, 'applyMutation'); - await expect(resumed.resume()).rejects.toThrow( - 'Invalid pending operation', - ); - expect(apply).not.toHaveBeenCalled(); - expect(resumed.state.pendingOperation).toBe(encoded); - authProvider.identifiers = IDENTIFIERS; await resumed.resume(); + expect(apply.mock.calls[0][4]).toStrictEqual( + authProvider.identifiers, + ); expect(await resumed.getPhase()).toBe('idle'); }, ); @@ -1202,6 +1143,7 @@ describe('MfaRecoveryController', () => { requestHash: mutation.requestHash, aal: 2, }), + approvedIdentifiers: IDENTIFIERS, payload, identifier: PASSKEY, receipts, @@ -1267,6 +1209,7 @@ describe('MfaRecoveryController', () => { exp: unixNow() + 60, requestHash: '0x', }), + approvedIdentifiers: IDENTIFIERS, payload: { recoverySecret: bytesToHex(SECRET), identifiers: IDENTIFIERS, @@ -1321,6 +1264,7 @@ describe('MfaRecoveryController', () => { aal: 2, identifiersHash: await hash(payload.identifiers), }), + approvedIdentifiers: IDENTIFIERS, payload, identifier: null, receipts: [ @@ -1382,6 +1326,7 @@ describe('MfaRecoveryController', () => { aal: 2, identifiersHash: await hash(payload.identifiers), }), + approvedIdentifiers: IDENTIFIERS, payload, identifier: null, receipts: [ @@ -1698,6 +1643,39 @@ describe('MfaRecoveryController', () => { }); }, }, + { + name: 'malformed approved identifiers', + mutate: async (pending: Record): Promise => { + pending.approvedIdentifiers = [{}]; + }, + }, + { + name: 'approved identifiers that do not match the approval', + mutate: async (pending: Record): Promise => { + const mutation = pending.mutation as Record; + const payload = { identifiers: IDENTIFIERS }; + const fields = { + id: mutation.id, + profileId: mutation.profileId, + operation: 'updateIdentifiers', + expectedVersion: mutation.expectedVersion, + newVersion: mutation.newVersion, + payloadHash: await hash(payload), + audiences: mutation.audiences, + }; + const requestHash = await hash(fields); + pending.mutation = { ...fields, requestHash }; + pending.payload = payload; + pending.authControllerToken = createAuthControllerToken({ + sub: 'profile-1', + exp: unixNow() + 60, + requestHash, + aal: 2, + identifiersHash: await hash(IDENTIFIERS), + }); + pending.approvedIdentifiers = [SOCIAL, PASSKEY]; + }, + }, { name: 'an invalid receipt array', mutate: async (pending: Record): Promise => { @@ -1867,6 +1845,7 @@ async function getValidWritingPending( requestHash: await hash(fields), aal: 2, }), + approvedIdentifiers: IDENTIFIERS, payload, identifier: PASSKEY, receipts: [], diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 4c726229782..29d2cef17a1 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -519,34 +519,16 @@ export class MfaRecoveryController extends BaseController< async #prepareMutation( pending: AuthorizingPendingOperation, ): Promise { - const authorization = await this.#authorizeMutation(pending.mutation); - const writing: WritingPendingOperation = { + const { approval, identifiers } = await this.#authorizeMutation( + pending.mutation, + ); + return { ...pending, phase: 'writing', - authControllerToken: authorization.approval, + authControllerToken: approval, + approvedIdentifiers: identifiers, receipts: [], }; - if ( - !pendingPayloadHasIdentifiers(pending.payload) || - (await hash(authorization.identifiers)) === - (await hash(pending.payload.identifiers)) - ) { - return writing; - } - - const payload = { - ...pending.payload, - identifiers: authorization.identifiers, - }; - const { requestHash: _requestHash, ...fields } = pending.mutation; - const mutation = await this.#buildMutation(fields, payload); - await this.#persistPending({ ...pending, mutation, payload }); - return { - ...writing, - mutation, - payload, - authControllerToken: (await this.#authorizeMutation(mutation)).approval, - }; } async #repairPendingMutation(): Promise { @@ -577,11 +559,15 @@ export class MfaRecoveryController extends BaseController< await this.#assertEscrowsAvailable(remainingEscrows); const claims = decodeAuthControllerToken(pending.authControllerToken); - const authControllerToken = - claims && claims.exp > this.#now() - ? pending.authControllerToken - : (await this.#authorizeMutation(mutation)).approval; - const writing = { ...pending, authControllerToken }; + let writing = pending; + if (!claims || claims.exp <= this.#now()) { + const { approval, identifiers } = await this.#authorizeMutation(mutation); + writing = { + ...pending, + authControllerToken: approval, + approvedIdentifiers: identifiers, + }; + } await this.#assertPending(writing); const authorizedEscrows = mutation.operation === 'register' @@ -614,6 +600,7 @@ export class MfaRecoveryController extends BaseController< writing.authControllerToken, authorizedEscrows?.[index]?.authorization ?? null, payloads[index], + writing.approvedIdentifiers, ), ), ); diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts index 87fc74d283d..4054441f502 100644 --- a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts @@ -74,10 +74,13 @@ describe('AuthApiRecoveryAuthProvider', () => { }, ); - it('surfaces an Auth API failure', async () => { + it.each([ + [403, 'authentication_required'], + [500, 'auth_api_error'], + ])('surfaces an Auth API %i as %s', async (status, code) => { const fetchMock = vi .fn() - .mockResolvedValue(new Response(null, { status: 403 })); + .mockResolvedValue(new Response(null, { status })); await expect( new AuthApiRecoveryAuthProvider({ ...options, @@ -86,7 +89,10 @@ describe('AuthApiRecoveryAuthProvider', () => { requestHash: '0xrequest', audiences: ['cubist-audience'], }), - ).rejects.toThrow('Authentication API returned 403'); + ).rejects.toMatchObject({ + message: `Authentication API returned ${status}`, + code, + }); }); it.each([ diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts index 2b56039a541..a1dbd31d085 100644 --- a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts @@ -66,7 +66,8 @@ export class AuthApiRecoveryAuthProvider implements RecoveryAuthProvider { if (!response.ok) { throw new MfaRecoveryError( `Authentication API returned ${response.status}`, - 'auth_api_error', + // 403 means the session's MFA is missing or stale; prompt it again. + response.status === 403 ? 'authentication_required' : 'auth_api_error', ); } const result: unknown = await response.json(); 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 3bd93c6370f..6f7fc9352d2 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 @@ -13,6 +13,7 @@ import { import { MfaRecoveryError, MutationRejectedError } from '../errors.js'; import type { AuthControllerToken, + Identifier, IdentifierAuthorization, Mutation, MutationPayload, @@ -55,6 +56,14 @@ const WIRE_PAYLOAD = { }, }; +const APPROVED: Identifier[] = [ + { + type: 'social', + namespace: 'https://accounts.google.com', + value: 'hashed-email', + }, +]; + const AUTHORIZATION: IdentifierAuthorization = { kind: 'key-bound', token: { @@ -168,7 +177,7 @@ describe('CubistEscrowProvider', () => { const receipt = await signedReceipt(); const { provider, invoke } = createProvider({ stdout: receipt }); expect( - await provider.applyMutation(MUTATION, TOKEN, null, PAYLOAD), + await provider.applyMutation(MUTATION, TOKEN, null, PAYLOAD, APPROVED), ).toStrictEqual(receipt); expect(invoke).toHaveBeenCalledWith('cubist_secret_escrow', 'latest', { request: { @@ -177,6 +186,7 @@ describe('CubistEscrowProvider', () => { authControllerToken: TOKEN, identifierAuthorization: null, payload: WIRE_PAYLOAD, + approvedIdentifiers: APPROVED, }, }); }); @@ -185,7 +195,13 @@ describe('CubistEscrowProvider', () => { const { provider, invoke } = createProvider({ stdout: await signedReceipt(), }); - await provider.applyMutation(MUTATION, TOKEN, AUTHORIZATION, PAYLOAD); + await provider.applyMutation( + MUTATION, + TOKEN, + AUTHORIZATION, + PAYLOAD, + APPROVED, + ); const [, , { request }] = invoke.mock.calls[0] as [ string, string, @@ -202,7 +218,13 @@ describe('CubistEscrowProvider', () => { it('rejects a response that is not a receipt', async () => { const { provider } = createProvider({ stdout: { nope: true } }); await expect( - provider.applyMutation(MUTATION, TOKEN, AUTHORIZATION, PAYLOAD), + provider.applyMutation( + MUTATION, + TOKEN, + AUTHORIZATION, + PAYLOAD, + APPROVED, + ), ).rejects.toMatchObject({ code: 'escrow_response_invalid' }); }); @@ -216,7 +238,13 @@ describe('CubistEscrowProvider', () => { const receipt = { ...(await signedReceipt()), ...patch }; const { provider } = createProvider({ stdout: receipt }); await expect( - provider.applyMutation(MUTATION, TOKEN, AUTHORIZATION, PAYLOAD), + provider.applyMutation( + MUTATION, + TOKEN, + AUTHORIZATION, + PAYLOAD, + APPROVED, + ), ).rejects.toMatchObject({ code: 'escrow_response_invalid' }); }); @@ -225,7 +253,7 @@ describe('CubistEscrowProvider', () => { policy: { response: 'Deny', reason: 'version mismatch' }, }); await expect( - provider.applyMutation(MUTATION, TOKEN, null, PAYLOAD), + provider.applyMutation(MUTATION, TOKEN, null, PAYLOAD, APPROVED), ).rejects.toBeInstanceOf(MutationRejectedError); }); }); 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 951339da503..97b34ef9319 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 @@ -21,6 +21,7 @@ import { isMutationReceipt } from '../escrow-utils.js'; import type { AuthControllerToken, GetRecoverySecretResponse, + Identifier, IdentifierAuthorization, Mutation, MutationPayload, @@ -144,6 +145,8 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { * @param authControllerToken - Request-bound AuthController token. * @param identifierAuthorization - Identifier proof. `null` for register. * @param payload - Per-escrow mutation payload. + * @param approvedIdentifiers - The approval's identifiers, which payload + * identifiers must be drawn from. * @returns The escrow's mutation receipt. */ async applyMutation( @@ -151,6 +154,7 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { authControllerToken: AuthControllerToken, identifierAuthorization: IdentifierAuthorization | null, payload: MutationPayload, + approvedIdentifiers: Identifier[], ): Promise { return await this.#invoke( 'applyMutation', @@ -159,6 +163,7 @@ export class CubistEscrowProvider implements RecoveryEscrowProvider { authControllerToken, identifierAuthorization, payload, + approvedIdentifiers, }, parseReceipt, true, // a policy Deny did not apply the mutation diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts index 8065d17aa38..245c1d29d64 100644 --- a/packages/mfa-recovery-controller/src/pending-operation-validation.ts +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -111,12 +111,15 @@ export async function assertValidPendingOperation( return; } const token = decodeAuthControllerToken(pending.authControllerToken); + const { approvedIdentifiers } = pending; if ( token?.sub !== mutation.profileId || token.ext.requestHash !== mutation.requestHash || token.ext.aal < 2 || + !Array.isArray(approvedIdentifiers) || + !approvedIdentifiers.every(isIdentifier) || (pendingPayloadHasIdentifiers(payload) && - token.ext.identifiersHash !== (await hash(payload.identifiers))) || + token.ext.identifiersHash !== (await hash(approvedIdentifiers))) || !isMutationReceiptArray(pending.receipts) ) { throwInvalidPendingOperation(); diff --git a/packages/mfa-recovery-controller/src/types.ts b/packages/mfa-recovery-controller/src/types.ts index b5e63bceae3..f1acc6e027b 100644 --- a/packages/mfa-recovery-controller/src/types.ts +++ b/packages/mfa-recovery-controller/src/types.ts @@ -211,6 +211,11 @@ export type AuthorizingPendingOperation = PendingOperationBase & { export type WritingPendingOperation = PendingOperationBase & { phase: 'writing'; authControllerToken: AuthControllerToken; + /** + * The approval's identifiers, in Auth API order, bound by its + * `identifiers_hash`. + */ + approvedIdentifiers: Identifier[]; receipts: MutationReceipt[]; }; @@ -305,6 +310,7 @@ export type RecoveryEscrowProvider = { authControllerToken: AuthControllerToken, identifierAuthorization: IdentifierAuthorization | null, payload: MutationPayload, + approvedIdentifiers: Identifier[], ) => Promise; /** * Verifies a receipt cryptographically using the build-pinned receipt key diff --git a/packages/mfa-recovery-controller/tests/stubs.ts b/packages/mfa-recovery-controller/tests/stubs.ts index b8e597f7ac9..b398a40e6e3 100644 --- a/packages/mfa-recovery-controller/tests/stubs.ts +++ b/packages/mfa-recovery-controller/tests/stubs.ts @@ -8,6 +8,7 @@ import { import { decodeAuthControllerToken } from '../src/auth-controller-token.js'; import { + canonicalize, decryptFromPublic, encryptToPublic, hash, @@ -296,6 +297,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { authControllerToken: AuthControllerToken, identifierAuthorization: IdentifierAuthorization | null, payload: MutationPayload, + approvedIdentifiers: Identifier[], ): Promise { if (this.failNextApplyCount > 0) { this.failNextApplyCount -= 1; @@ -309,6 +311,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { authControllerToken, identifierAuthorization, payload, + approvedIdentifiers, ); } catch (error) { if (!this.#applyCommitted && error instanceof MfaRecoveryError) { @@ -323,6 +326,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { authControllerToken: AuthControllerToken, identifierAuthorization: IdentifierAuthorization | null, payload: MutationPayload, + approvedIdentifiers: Identifier[], ): Promise { await this.#verifyMutationAuthorization(mutation, authControllerToken); @@ -366,6 +370,7 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { await this.#assertIdentifierOwnership( authControllerToken, registerPayload.identifiers, + approvedIdentifiers, ); this.#assertIdentifiersAvailable( mutation.profileId, @@ -426,7 +431,11 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { } else { const { identifiers } = payload as UpdateIdentifiersPayload; await this.#assertPayloadHash(mutation, payload); - await this.#assertIdentifierOwnership(authControllerToken, identifiers); + await this.#assertIdentifierOwnership( + authControllerToken, + identifiers, + approvedIdentifiers, + ); this.#assertIdentifiersAvailable(mutation.profileId, identifiers); this.#applyCommitted = true; this.#replaceIdentifierIndex( @@ -570,10 +579,13 @@ export class StubEscrowProvider implements RecoveryEscrowProvider { async #assertIdentifierOwnership( token: AuthControllerToken, identifiers: Identifier[], + approvedIdentifiers: Identifier[], ): Promise { + const approved = new Set(approvedIdentifiers.map(canonicalize)); if ( decodeAuthControllerToken(token)?.ext.identifiersHash !== - (await hash(identifiers)) + (await hash(approvedIdentifiers)) || + !identifiers.every((identifier) => approved.has(canonicalize(identifier))) ) { throw new MfaRecoveryError( 'Identifier ownership not approved', From ce0d6cef81582410d2ef13ec5950ac81480617b6 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:50:08 +0800 Subject: [PATCH 54/55] refactor: request recovery approvals through AuthenticationController --- .../mfa-recovery-controller/ARCHITECTURE.md | 3 +- packages/mfa-recovery-controller/CHANGELOG.md | 2 +- packages/mfa-recovery-controller/README.md | 15 +- packages/mfa-recovery-controller/package.json | 1 + .../src/MfaRecoveryController.test.ts | 197 ++++++------------ .../src/MfaRecoveryController.ts | 101 ++++----- .../src/auth-api-recovery-provider.test.ts | 194 +++++++++-------- .../src/auth-api-recovery-provider.ts | 87 +++----- packages/mfa-recovery-controller/src/index.ts | 5 +- .../tsconfig.build.json | 3 + .../mfa-recovery-controller/tsconfig.json | 3 + .../tsconfig.lint.json | 3 + yarn.lock | 1 + 13 files changed, 274 insertions(+), 341 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index e5899cd1e8d..749d0d28554 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -173,7 +173,8 @@ Auth approvals: `updateIdentifiers` send them to every escrow, which checks them against `identifiers_hash` and rejects payload identifiers outside them. Hashing sorts object keys, not arrays. -- A 403 from Auth means MFA is missing or stale (`authentication_required`). +- 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. diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index f6893918995..db96c18ec58 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Requires recent, request-bound AAL2 approvals for writes and reads. - Sends the approval's identifiers to escrows as `approvedIdentifiers` with `register` and `updateIdentifiers` mutations ([#10883](https://github.com/MetaMask/core/pull/10883)) - Exports `MfaRecoveryController`, `getDefaultMfaRecoveryControllerState`, and the controller's action, event, messenger, state, and options types. - - Exports `AuthApiRecoveryAuthProvider` and `AuthApiRecoveryAuthProviderOptions` for requesting approvals from the Authentication API; a 403 response throws `authentication_required` ([#10883](https://github.com/MetaMask/core/pull/10883)) + - Exports `AuthApiRecoveryAuthProvider`, `AuthApiRecoveryAuthProviderOptions` and `AuthApiRecoveryAuthProviderMessenger` for requesting approvals through `AuthenticationController:requestMfaApproval` ([#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. diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md index ea3762ac93b..889c06f34de 100644 --- a/packages/mfa-recovery-controller/README.md +++ b/packages/mfa-recovery-controller/README.md @@ -7,16 +7,17 @@ Pending mutations are encrypted in controller state. `resume()` completes them; ## Auth API Integration -Configure `AuthApiRecoveryAuthProvider` with the Auth API base URL, profile id, -and a callback that supplies a Hydra bearer with recent MFA when requested. -It posts `{ request_hash, audiences }` to `/api/v2/mfa/approvals` and returns -`{ approval, identifiers }`. Writes and reads require a request-bound AAL2 -approval; updates and reads also require an identifier proof. +Configure `AuthApiRecoveryAuthProvider` with a messenger that can call +`AuthenticationController:getSessionProfile` and +`AuthenticationController:requestMfaApproval`. It uses the canonical profile id +and requests approvals from `/api/v2/mfa/approvals`. Writes and reads require a +request-bound AAL2 approval; updates and reads also require an identifier proof. +Without a recent MFA verification, approvals reject with `StepUpRequiredError`: +verify, then call `resume()` or retry the read. `register` and `updateIdentifiers` send the approval's `identifiers` to each escrow as `approvedIdentifiers`; the escrow rejects payload identifiers outside -that list. A 403 from Auth throws `authentication_required`: prompt for MFA -again. Set `CubistEscrowProviderOptions.authAudience` to the escrow's +that list. Set `CubistEscrowProviderOptions.authAudience` to the escrow's `AUTH_CONTROLLER_AUDIENCE`. ## Installation diff --git a/packages/mfa-recovery-controller/package.json b/packages/mfa-recovery-controller/package.json index 2a0a073d956..68c31709efe 100644 --- a/packages/mfa-recovery-controller/package.json +++ b/packages/mfa-recovery-controller/package.json @@ -56,6 +56,7 @@ "@metamask/base-controller": "^10.1.0", "@metamask/cryptography": "^1.1.2", "@metamask/messenger": "^3.0.0", + "@metamask/profile-sync-controller": "^36.0.0", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^12.0.1", "json-stable-stringify": "^1.3.0" diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index 63f83e8bb2f..a1dccce6653 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -163,21 +163,11 @@ describe('MfaRecoveryController', () => { describe('register', () => { it('replicates the recovery secret to every escrow', async () => { - await withController(async ({ controller, options }) => { - const [escrowA, escrowB] = options.escrows; - const authorize = vi.spyOn( - options.authProvider, - 'authorizeRecoveryRequest', - ); + await withController(async ({ controller, escrowA, escrowB }) => { const verifyReceipt = vi.spyOn(escrowA, 'verifyReceipt'); const applyA = vi.spyOn(escrowA, 'applyMutation'); const applyB = vi.spyOn(escrowB, 'applyMutation'); await controller.register(SECRET, IDENTIFIERS); - expect(authorize).toHaveBeenCalledExactlyOnceWith({ - requestHash: applyA.mock.calls[0][0].requestHash, - audiences: [escrowA.authAudience, escrowB.authAudience], - requireTwoFactor: true, - }); expect(controller.state.pendingOperation).toBeNull(); expect(await controller.getPhase()).toBe('idle'); expect(verifyReceipt.mock.calls[0][2]).toBe('escrow-a'); @@ -197,23 +187,30 @@ describe('MfaRecoveryController', () => { }); }); - it('rejects an AAL1 approval before writing', async () => { - await withController(async ({ controller, authProvider, escrowA }) => { - const approve = - authProvider.authorizeRecoveryRequest.bind(authProvider); - vi.spyOn(authProvider, 'authorizeRecoveryRequest').mockImplementation( - (params) => approve({ ...params, requireTwoFactor: false }), - ); - const apply = vi.spyOn(escrowA, 'applyMutation'); + it.each([ + ['an AAL1 approval', { requireTwoFactor: false }, {}], + ['identifiers the approval does not bind', {}, { identifiers: [SOCIAL] }], + ])( + 'rejects %s before writing', + async (_name, paramsPatch, approvalPatch) => { + await withController(async ({ controller, authProvider, escrowA }) => { + const approve = + authProvider.authorizeRecoveryRequest.bind(authProvider); + vi.spyOn(authProvider, 'authorizeRecoveryRequest').mockImplementation( + async (params) => ({ + ...(await approve({ ...params, ...paramsPatch })), + ...approvalPatch, + }), + ); + const apply = vi.spyOn(escrowA, 'applyMutation'); - await expect( - controller.register(SECRET, IDENTIFIERS), - ).rejects.toMatchObject({ - code: 'invalid_pending_operation', + await expect( + controller.register(SECRET, IDENTIFIERS), + ).rejects.toMatchObject({ code: 'invalid_pending_operation' }); + expect(apply).not.toHaveBeenCalled(); }); - expect(apply).not.toHaveBeenCalled(); - }); - }); + }, + ); it('publishes state changes through the stateChanged event', async () => { await withController(async ({ controller, rootMessenger }) => { @@ -361,59 +358,51 @@ describe('MfaRecoveryController', () => { }); }); - it('reuses the identifier token but requests an approval for each read', async () => { + it('does not request another token when reading with a session', async () => { await withController(async ({ controller, options }) => { - const [escrowA, escrowB] = options.escrows; await controller.register(SECRET, IDENTIFIERS); - const authorize = vi.spyOn( - options.authProvider, - 'authorizeRecoveryRequest', - ); - const read = vi.spyOn(escrowA, 'getSecret'); const getToken = vi.spyOn( options.identifierAuthProvider, 'getKeyBoundIdentifierToken', ); const session = await controller.authenticateIdentifier(PASSKEY); - expect(await controller.getRecoverySecret(session)).toMatchObject({ - epoch: REGISTERED_EPOCH, - }); - expect(await controller.getRecoverySecret(session)).toMatchObject({ - epoch: REGISTERED_EPOCH, - }); expect(getToken).toHaveBeenCalledTimes(1); - expect(authorize).toHaveBeenCalledTimes(2); - expect(authorize).toHaveBeenCalledWith({ - requestHash: session.token.requestHash, - audiences: [escrowA.authAudience, escrowB.authAudience], - requireTwoFactor: true, - }); - expect(read).toHaveBeenCalledTimes(2); - expect(read).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ token: session.token }), - session.requestId, - session.pkE, + + await controller.getRecoverySecret(session); + expect(getToken).toHaveBeenCalledTimes(1); + }); + }); + + it('allows a session to be reused for another read', async () => { + await withController(async ({ controller }) => { + await controller.register(SECRET, IDENTIFIERS); + const session = await controller.authenticateIdentifier(PASSKEY); + + const first = await controller.getRecoverySecret(session); + expect(first).toStrictEqual( + expect.objectContaining({ epoch: REGISTERED_EPOCH }), + ); + const second = await controller.getRecoverySecret(session); + expect(second).toStrictEqual( + expect.objectContaining({ epoch: REGISTERED_EPOCH }), ); }); }); }); describe('getRecoverySecret', () => { - it('does not read when approval fails', async () => { - await withController(async ({ controller, authProvider, escrowA }) => { + it('requests a request-bound AAL2 approval for each read', async () => { + await withController(async ({ controller, authProvider }) => { await controller.register(SECRET, IDENTIFIERS); - vi.spyOn( - authProvider, - 'authorizeRecoveryRequest', - ).mockRejectedValueOnce(new Error('MFA required')); - const read = vi.spyOn(escrowA, 'getSecret'); - - await expect(recover(controller, PASSKEY)).rejects.toThrow( - 'MFA required', - ); - expect(read).not.toHaveBeenCalled(); + const authorize = vi.spyOn(authProvider, 'authorizeRecoveryRequest'); + const session = await controller.authenticateIdentifier(PASSKEY); + await controller.getRecoverySecret(session); + expect(authorize).toHaveBeenCalledExactlyOnceWith({ + requestHash: session.token.requestHash, + audiences: ['stub-audience', 'stub-audience'], + requireTwoFactor: true, + }); }); }); @@ -772,12 +761,11 @@ describe('MfaRecoveryController', () => { describe('updateIdentifiers', () => { it('replaces the identifier set', async () => { - await withController(async ({ controller, authProvider }) => { + await withController(async ({ controller }) => { await controller.register(SECRET, IDENTIFIERS); - authProvider.identifiers = [SOCIAL, PASSKEY]; await controller.updateIdentifiers( PASSKEY, - IDENTIFIERS, + [SOCIAL, PASSKEY], REGISTERED_EPOCH, ); const recovered = await recover(controller, SOCIAL); @@ -802,13 +790,15 @@ describe('MfaRecoveryController', () => { const authorize = vi.spyOn(authProvider, 'authorizeRecoveryRequest'); const apply = vi.spyOn(escrowA, 'applyMutation'); await controller.register(SECRET, IDENTIFIERS); - expect(authorize).toHaveBeenCalledTimes(1); + expect(authorize).toHaveBeenCalledExactlyOnceWith({ + requestHash: apply.mock.calls[0][0].requestHash, + audiences: ['stub-audience', 'stub-audience'], + requireTwoFactor: true, + }); expect(apply.mock.calls[0][3]).toMatchObject({ identifiers: IDENTIFIERS, }); expect(apply.mock.calls[0][4]).toStrictEqual(authProvider.identifiers); - const recovered = await recover(controller, SOCIAL); - expect(bytesToHex(recovered.recoverySecret)).toBe(bytesToHex(SECRET)); }); }); @@ -821,41 +811,6 @@ describe('MfaRecoveryController', () => { expect(await controller.getPhase()).toBe('idle'); }); }); - - it('refreshes approved identifiers with an expired approval', async () => { - await withController( - async ({ controller, authProvider, escrowA, options }) => { - escrowA.failNextApplyCount = 1; - await expect( - controller.register(SECRET, IDENTIFIERS), - ).rejects.toBeInstanceOf(IncompleteMutationError); - const pending = JSON.parse( - controller.state.pendingOperation as string, - ) as WritingPendingOperation; - pending.authControllerToken = createAuthControllerToken({ - sub: pending.mutation.profileId, - exp: 0, - requestHash: pending.mutation.requestHash, - aal: 2, - identifiersHash: await hash(IDENTIFIERS), - }); - const resumed = new MfaRecoveryController({ - ...options, - messenger: getMessenger(getRootMessenger()), - state: { - pendingOperation: await passthroughEncryptor.encrypt(pending), - }, - }); - authProvider.identifiers = [...IDENTIFIERS, OTHER_PASSKEY]; - const apply = vi.spyOn(escrowA, 'applyMutation'); - await resumed.resume(); - expect(apply.mock.calls[0][4]).toStrictEqual( - authProvider.identifiers, - ); - expect(await resumed.getPhase()).toBe('idle'); - }, - ); - }); }); describe('resume and abort', () => { @@ -1434,7 +1389,12 @@ describe('MfaRecoveryController', () => { messenger: getMessenger(getRootMessenger()), state: { pendingOperation: JSON.stringify(pending) }, }); + options.authProvider.identifiers = [...IDENTIFIERS, OTHER_PASSKEY]; + const apply = vi.spyOn(escrowA, 'applyMutation'); await resumed.resume(); + expect(apply.mock.calls[0][4]).toStrictEqual( + options.authProvider.identifiers, + ); expect(await resumed.getPhase()).toBe('idle'); }); }); @@ -1649,33 +1609,6 @@ describe('MfaRecoveryController', () => { pending.approvedIdentifiers = [{}]; }, }, - { - name: 'approved identifiers that do not match the approval', - mutate: async (pending: Record): Promise => { - const mutation = pending.mutation as Record; - const payload = { identifiers: IDENTIFIERS }; - const fields = { - id: mutation.id, - profileId: mutation.profileId, - operation: 'updateIdentifiers', - expectedVersion: mutation.expectedVersion, - newVersion: mutation.newVersion, - payloadHash: await hash(payload), - audiences: mutation.audiences, - }; - const requestHash = await hash(fields); - pending.mutation = { ...fields, requestHash }; - pending.payload = payload; - pending.authControllerToken = createAuthControllerToken({ - sub: 'profile-1', - exp: unixNow() + 60, - requestHash, - aal: 2, - identifiersHash: await hash(IDENTIFIERS), - }); - pending.approvedIdentifiers = [SOCIAL, PASSKEY]; - }, - }, { name: 'an invalid receipt array', mutate: async (pending: Record): Promise => { @@ -1778,7 +1711,7 @@ type WithControllerCallback = (payload: { controllerMessenger: MfaRecoveryControllerMessenger; escrowA: StubEscrowProvider; escrowB: StubEscrowProvider; - options: MfaRecoveryControllerOptions; + options: ReturnType; }) => Promise | ReturnValue; function getRootMessenger(): RootMessenger { diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts index 29d2cef17a1..7701c5e2903 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.ts @@ -44,19 +44,16 @@ import { } from './pending-operation-validation.js'; import { assertAbortAllowed, getRecoveryPhase } from './state-machine.js'; import type { - AuthorizingPendingOperation, Identifier, IdentifierSession, Mutation, MutationPayload, MutationReceipt, - PendingMutationPayload, PendingOperation, PendingOperationEncryptor, PendingRegisterPayload, PendingUpdateIdentifiersPayload, PendingUpdateRecoverySecretPayload, - RecoveryApproval, RecoveryAuthProvider, RecoveryEscrowProvider, RecoveryIdentifierAuthProvider, @@ -475,19 +472,23 @@ export class MfaRecoveryController extends BaseController< const profileId = await this.#authProvider.getAuthenticatedProfileId(); const audiences = this.#escrows.map((escrow) => escrow.id); const currentVersion = this.#resolveCurrentRecoveryVersion(epoch); - const mutation = await this.#buildMutation( - { - id: randomId(), - profileId, - operation, - expectedVersion: currentVersion, - newVersion: currentVersion + 1, - audiences, - }, - payload, - ); + const payloadHash = await hash(payload); + + const mutationFields = { + id: randomId(), + profileId, + operation, + expectedVersion: currentVersion, + newVersion: currentVersion + 1, + payloadHash, + audiences, + }; + const mutation: Mutation = { + ...mutationFields, + requestHash: await hash(mutationFields), + }; - const pending: AuthorizingPendingOperation = { + const pending: PendingOperation = { phase: 'authorizing', mutation, payload, @@ -495,40 +496,27 @@ export class MfaRecoveryController extends BaseController< }; await this.#persistPending(pending); - const writing = await this.#prepareMutation(pending); - await this.#replicateMutation(writing); - }); - } - - async #buildMutation( - fields: Omit, - payload: PendingMutationPayload, - ): Promise { - const mutationFields = { ...fields, payloadHash: await hash(payload) }; - return { ...mutationFields, requestHash: await hash(mutationFields) }; - } - - async #authorizeMutation(mutation: Mutation): Promise { - return await this.#authProvider.authorizeRecoveryRequest({ - requestHash: mutation.requestHash, - audiences: this.#escrows.map((escrow) => escrow.authAudience), - requireTwoFactor: true, + await this.#replicateMutation({ + ...pending, + phase: 'writing', + ...(await this.#authorizeMutation(mutation)), + receipts: [], + }); }); } - async #prepareMutation( - pending: AuthorizingPendingOperation, - ): Promise { - const { approval, identifiers } = await this.#authorizeMutation( - pending.mutation, - ); - return { - ...pending, - phase: 'writing', - authControllerToken: approval, - approvedIdentifiers: identifiers, - receipts: [], - }; + async #authorizeMutation( + mutation: Mutation, + ): Promise< + Pick + > { + const { approval, identifiers } = + await this.#authProvider.authorizeRecoveryRequest({ + requestHash: mutation.requestHash, + audiences: this.#escrows.map((escrow) => escrow.authAudience), + requireTwoFactor: true, + }); + return { authControllerToken: approval, approvedIdentifiers: identifiers }; } async #repairPendingMutation(): Promise { @@ -539,8 +527,12 @@ export class MfaRecoveryController extends BaseController< if (pending.phase === 'authorizing') { await this.#assertEscrowsAvailable(this.#escrows); - const writing = await this.#prepareMutation(pending); - await this.#replicateMutation(writing); + await this.#replicateMutation({ + ...pending, + phase: 'writing', + ...(await this.#authorizeMutation(pending.mutation)), + receipts: [], + }); return; } @@ -559,15 +551,10 @@ export class MfaRecoveryController extends BaseController< await this.#assertEscrowsAvailable(remainingEscrows); const claims = decodeAuthControllerToken(pending.authControllerToken); - let writing = pending; - if (!claims || claims.exp <= this.#now()) { - const { approval, identifiers } = await this.#authorizeMutation(mutation); - writing = { - ...pending, - authControllerToken: approval, - approvedIdentifiers: identifiers, - }; - } + const writing = + claims && claims.exp > this.#now() + ? pending + : { ...pending, ...(await this.#authorizeMutation(mutation)) }; await this.#assertPending(writing); const authorizedEscrows = mutation.operation === 'register' diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts index 4054441f502..e8fa79ebf26 100644 --- a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts @@ -1,38 +1,99 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { describe, expect, it, vi } from 'vitest'; +import type { AuthApiRecoveryAuthProviderMessenger } from './auth-api-recovery-provider.js'; import { AuthApiRecoveryAuthProvider } from './auth-api-recovery-provider.js'; -describe('AuthApiRecoveryAuthProvider', () => { - const getAccessToken = - vi.fn<(requireTwoFactor: boolean) => Promise>(); - const options = { - baseUrl: 'https://auth.example/', - profileId: 'profile-1', - getAccessToken, - }; - const approval = { - approval: 'signed-approval', - identifiers: [ - { - type: 'social', - namespace: 'https://accounts.google.com', - value: 'hash', - }, - { - type: 'passkey', - namespace: 'metamask.io', - value: 'AQID', - verifier: { - publicKey: { cose: 'AQID' }, - rpId: 'metamask.io', - origins: ['https://metamask.io'], - }, +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type Approval = { + approval: string; + expiresIn: number; + identifiers?: { type: string; namespace: string; value: string }[]; +}; + +const APPROVAL = { + approval: 'signed-approval', + expiresIn: 300, + identifiers: [ + { + type: 'social', + namespace: 'https://accounts.google.com', + value: 'hash', + }, + { + type: 'passkey', + namespace: 'metamask.io', + value: 'AQID', + verifier: { + publicKey: { cose: 'AQID' }, + rpId: 'metamask.io', + origins: ['https://metamask.io'], }, + }, + ], +}; + +/** + * Builds a provider whose messenger is backed by mock AuthenticationController + * actions. + * + * @param approval - The approval `requestMfaApproval` resolves with. + * @returns The provider and the `requestMfaApproval` mock. + */ +function setup(approval: Approval = APPROVAL): { + provider: AuthApiRecoveryAuthProvider; + requestMfaApproval: ReturnType; +} { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const messenger: AuthApiRecoveryAuthProviderMessenger = new Messenger({ + namespace: 'AuthApiRecoveryAuthProvider', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticationController:getSessionProfile', + 'AuthenticationController:requestMfaApproval', ], + events: [], + messenger, + }); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => ({ + identifierId: 'srp-identifier', + profileId: 'srp-profile', + canonicalProfileId: 'profile-1', + metaMetricsId: 'metametrics-id', + }), + ); + const requestMfaApproval = vi.fn().mockResolvedValue(approval); + rootMessenger.registerActionHandler( + 'AuthenticationController:requestMfaApproval', + requestMfaApproval, + ); + return { + provider: new AuthApiRecoveryAuthProvider({ messenger }), + requestMfaApproval, }; +} - beforeEach(() => { - getAccessToken.mockResolvedValue('hydra-token'); +describe('AuthApiRecoveryAuthProvider', () => { + it('returns the canonical profile id', async () => { + expect(await setup().provider.getAuthenticatedProfileId()).toBe( + 'profile-1', + ); }); it.each([ @@ -40,77 +101,34 @@ describe('AuthApiRecoveryAuthProvider', () => { [true, true], ])( 'returns the approval and identifiers (requireTwoFactor: %s)', - async (requireTwoFactor, expectedTwoFactor) => { - const fetchMock = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(JSON.stringify(approval))); - const provider = new AuthApiRecoveryAuthProvider(options); - expect(await provider.getAuthenticatedProfileId()).toBe('profile-1'); + async (requireTwoFactor, requireVerification) => { + const { provider, requestMfaApproval } = setup(); expect( await provider.authorizeRecoveryRequest({ requestHash: '0xrequest', audiences: ['cubist-audience'], requireTwoFactor, }), - ).toStrictEqual(approval); - expect(getAccessToken).toHaveBeenCalledWith(expectedTwoFactor); - expect(fetchMock.mock.contexts[0] === globalThis).toBe(true); - expect(fetchMock.mock.calls).toStrictEqual([ - [ - 'https://auth.example/api/v2/mfa/approvals', - { - method: 'POST', - headers: { - Authorization: 'Bearer hydra-token', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - request_hash: '0xrequest', - audiences: ['cubist-audience'], - }), - }, - ], - ]); - }, - ); - - it.each([ - [403, 'authentication_required'], - [500, 'auth_api_error'], - ])('surfaces an Auth API %i as %s', async (status, code) => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status })); - await expect( - new AuthApiRecoveryAuthProvider({ - ...options, - fetch: fetchMock, - }).authorizeRecoveryRequest({ + ).toStrictEqual({ + approval: APPROVAL.approval, + identifiers: APPROVAL.identifiers, + }); + expect(requestMfaApproval).toHaveBeenCalledWith({ requestHash: '0xrequest', audiences: ['cubist-audience'], - }), - ).rejects.toMatchObject({ - message: `Authentication API returned ${status}`, - code, - }); - }); + requireVerification, + reason: { operation: 'mfa_recovery' }, + }); + }, + ); - it.each([ - null, - {}, - { ...approval, approval: 1 }, - { ...approval, approval: '' }, - { ...approval, identifiers: null }, - { ...approval, identifiers: [{}] }, - ])('rejects a malformed approval response: %j', async (response) => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify(response)), - ); - await expect( - new AuthApiRecoveryAuthProvider(options).authorizeRecoveryRequest({ + it('returns no identifiers when Auth omits them', async () => { + const { provider } = setup({ approval: 'signed-approval', expiresIn: 300 }); + expect( + await provider.authorizeRecoveryRequest({ requestHash: '0xrequest', audiences: ['cubist-audience'], }), - ).rejects.toThrow('Invalid Auth approval'); + ).toStrictEqual({ approval: 'signed-approval', identifiers: [] }); }); }); diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts index a1dbd31d085..6caff5789dc 100644 --- a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts @@ -1,42 +1,43 @@ -import { array, is, size, string, type } from '@metamask/superstruct'; +import type { Messenger } from '@metamask/messenger'; +import type { + AuthenticationControllerGetSessionProfileAction, + AuthenticationControllerRequestMfaApprovalAction, +} from '@metamask/profile-sync-controller/auth'; -import { MfaRecoveryError } from './errors.js'; import type { RecoveryApproval, RecoveryAuthProvider } from './types.js'; -const RecoveryApprovalStruct = type({ - approval: size(string(), 1, Infinity), - identifiers: array( - type({ type: string(), namespace: string(), value: string() }), - ), -}); +export type AuthApiRecoveryAuthProviderMessenger = Messenger< + 'AuthApiRecoveryAuthProvider', + | AuthenticationControllerGetSessionProfileAction + | AuthenticationControllerRequestMfaApprovalAction +>; export type AuthApiRecoveryAuthProviderOptions = { - /** Auth API origin, without /api/v2. */ - baseUrl: string; - /** Profile id for the authenticated session. */ - profileId: string; - /** Returns a Hydra bearer; true requires recent MFA. */ - getAccessToken: (requireTwoFactor: boolean) => Promise; - fetch?: typeof globalThis.fetch; + messenger: AuthApiRecoveryAuthProviderMessenger; }; -/** Requests Auth API approvals using an authenticated Hydra session. */ +/** + * Requests Auth API approvals through `AuthenticationController`. A missing or + * stale MFA verification rejects with its `StepUpRequiredError`. + */ export class AuthApiRecoveryAuthProvider implements RecoveryAuthProvider { - readonly #options: AuthApiRecoveryAuthProviderOptions; - - readonly #fetch: typeof globalThis.fetch; + readonly #messenger: AuthApiRecoveryAuthProviderMessenger; /** - * @param options - Auth API URL, profile, and Hydra token source. + * @param options - Provider options. + * @param options.messenger - Messenger with the AuthenticationController + * actions. */ - constructor(options: AuthApiRecoveryAuthProviderOptions) { - this.#options = options; - this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis); + constructor({ messenger }: AuthApiRecoveryAuthProviderOptions) { + this.#messenger = messenger; } - /** @returns The authenticated profile id. */ + /** @returns The canonical profile id, which approvals use as `sub`. */ async getAuthenticatedProfileId(): Promise { - return this.#options.profileId; + const { canonicalProfileId } = await this.#messenger.call( + 'AuthenticationController:getSessionProfile', + ); + return canonicalProfileId; } /** @@ -46,37 +47,15 @@ export class AuthApiRecoveryAuthProvider implements RecoveryAuthProvider { async authorizeRecoveryRequest( params: Parameters[0], ): Promise { - const token = await this.#options.getAccessToken( - params.requireTwoFactor === true, - ); - const response = await this.#fetch( - `${this.#options.baseUrl.replace(/\/$/u, '')}/api/v2/mfa/approvals`, + const { approval, identifiers = [] } = await this.#messenger.call( + 'AuthenticationController:requestMfaApproval', { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - request_hash: params.requestHash, - audiences: params.audiences, - }), + requestHash: params.requestHash, + audiences: params.audiences, + requireVerification: params.requireTwoFactor === true, + reason: { operation: 'mfa_recovery' }, }, ); - if (!response.ok) { - throw new MfaRecoveryError( - `Authentication API returned ${response.status}`, - // 403 means the session's MFA is missing or stale; prompt it again. - response.status === 403 ? 'authentication_required' : 'auth_api_error', - ); - } - const result: unknown = await response.json(); - if (!is(result, RecoveryApprovalStruct)) { - throw new MfaRecoveryError( - 'Invalid Auth approval', - 'auth_api_response_invalid', - ); - } - return { approval: result.approval, identifiers: result.identifiers }; + return { approval, identifiers }; } } diff --git a/packages/mfa-recovery-controller/src/index.ts b/packages/mfa-recovery-controller/src/index.ts index 5c942b81dd0..daea6d7bac1 100644 --- a/packages/mfa-recovery-controller/src/index.ts +++ b/packages/mfa-recovery-controller/src/index.ts @@ -24,7 +24,10 @@ export type { export { CubistEscrowProvider } from './escrow-providers/cubist-escrow-provider.js'; export type { CubistEscrowProviderOptions } from './escrow-providers/cubist-escrow-provider.js'; export { AuthApiRecoveryAuthProvider } from './auth-api-recovery-provider.js'; -export type { AuthApiRecoveryAuthProviderOptions } from './auth-api-recovery-provider.js'; +export type { + AuthApiRecoveryAuthProviderMessenger, + AuthApiRecoveryAuthProviderOptions, +} from './auth-api-recovery-provider.js'; export { IncompleteMutationError, MfaRecoveryError, diff --git a/packages/mfa-recovery-controller/tsconfig.build.json b/packages/mfa-recovery-controller/tsconfig.build.json index cf795055652..b5bb89ae7be 100644 --- a/packages/mfa-recovery-controller/tsconfig.build.json +++ b/packages/mfa-recovery-controller/tsconfig.build.json @@ -14,6 +14,9 @@ { "path": "../messenger/tsconfig.build.json" }, + { + "path": "../profile-sync-controller/tsconfig.build.json" + }, { "path": "../utils/tsconfig.build.json" } diff --git a/packages/mfa-recovery-controller/tsconfig.json b/packages/mfa-recovery-controller/tsconfig.json index fdeaa07f238..6806c189e55 100644 --- a/packages/mfa-recovery-controller/tsconfig.json +++ b/packages/mfa-recovery-controller/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../messenger" }, + { + "path": "../profile-sync-controller" + }, { "path": "../utils" } diff --git a/packages/mfa-recovery-controller/tsconfig.lint.json b/packages/mfa-recovery-controller/tsconfig.lint.json index 829917a673f..9b68878626e 100644 --- a/packages/mfa-recovery-controller/tsconfig.lint.json +++ b/packages/mfa-recovery-controller/tsconfig.lint.json @@ -16,6 +16,9 @@ }, { "path": "../utils/tsconfig.lint.json" + }, + { + "path": "../profile-sync-controller/tsconfig.lint.json" } ] } diff --git a/yarn.lock b/yarn.lock index 24a5e4a8aa1..8f377f73751 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7298,6 +7298,7 @@ __metadata: "@metamask/base-controller": "npm:^10.1.0" "@metamask/cryptography": "npm:^1.1.2" "@metamask/messenger": "npm:^3.0.0" + "@metamask/profile-sync-controller": "npm:^36.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^12.0.1" "@typescript/native": "npm:typescript@^7.0.2" From 6099a343be2825843a38fd1364ba86b13bea403c Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:40:22 +0800 Subject: [PATCH 55/55] fix: reject unapproved identifiers before writing and refresh the canonical profile id Escrows report a subset mismatch as an error, not a Deny, which left the mutation in an unabortable writing phase. The cached canonical profile id can be stale after pairing elsewhere, so every approval's sub mismatched. Co-authored-by: Cursor --- .../mfa-recovery-controller/ARCHITECTURE.md | 7 +++-- packages/mfa-recovery-controller/CHANGELOG.md | 4 +-- packages/mfa-recovery-controller/README.md | 10 +++--- .../src/MfaRecoveryController.test.ts | 31 +++++++++++++------ .../src/auth-api-recovery-provider.test.ts | 13 +++----- .../src/auth-api-recovery-provider.ts | 16 ++++++---- .../src/pending-operation-validation.ts | 20 +++++++++++- 7 files changed, 68 insertions(+), 33 deletions(-) diff --git a/packages/mfa-recovery-controller/ARCHITECTURE.md b/packages/mfa-recovery-controller/ARCHITECTURE.md index 749d0d28554..6d81e27de31 100644 --- a/packages/mfa-recovery-controller/ARCHITECTURE.md +++ b/packages/mfa-recovery-controller/ARCHITECTURE.md @@ -114,8 +114,11 @@ Anything that fails is rejected rather than acted on: an identifier. - `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`; receipts must be - well-formed with at most one per escrow. + `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) diff --git a/packages/mfa-recovery-controller/CHANGELOG.md b/packages/mfa-recovery-controller/CHANGELOG.md index db96c18ec58..deff0452d99 100644 --- a/packages/mfa-recovery-controller/CHANGELOG.md +++ b/packages/mfa-recovery-controller/CHANGELOG.md @@ -11,9 +11,9 @@ 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 ([#10883](https://github.com/MetaMask/core/pull/10883)) + - 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` ([#10883](https://github.com/MetaMask/core/pull/10883)) + - 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. diff --git a/packages/mfa-recovery-controller/README.md b/packages/mfa-recovery-controller/README.md index 889c06f34de..d1b3e5dd761 100644 --- a/packages/mfa-recovery-controller/README.md +++ b/packages/mfa-recovery-controller/README.md @@ -8,16 +8,18 @@ Pending mutations are encrypted in controller state. `resume()` completes them; ## Auth API Integration Configure `AuthApiRecoveryAuthProvider` with a messenger that can call -`AuthenticationController:getSessionProfile` and -`AuthenticationController:requestMfaApproval`. It uses the canonical profile id -and requests approvals from `/api/v2/mfa/approvals`. Writes and reads require a +`AuthenticationController:refreshCanonicalProfileId` and +`AuthenticationController:requestMfaApproval`. It resolves the current canonical +profile id for each mutation and requests approvals from `/api/v2/mfa/approvals`. Writes and reads require a request-bound AAL2 approval; updates and reads also require an identifier proof. Without a recent MFA verification, approvals reject with `StepUpRequiredError`: verify, then call `resume()` or retry the read. `register` and `updateIdentifiers` send the approval's `identifiers` to each escrow as `approvedIdentifiers`; the escrow rejects payload identifiers outside -that list. Set `CubistEscrowProviderOptions.authAudience` to the escrow's +that list. Build payload identifiers from those entries unchanged (verifier +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`. ## Installation diff --git a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts index a1dccce6653..dfeff2f26e6 100644 --- a/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts +++ b/packages/mfa-recovery-controller/src/MfaRecoveryController.test.ts @@ -802,15 +802,28 @@ describe('MfaRecoveryController', () => { }); }); - it('rejects identifiers that Auth did not approve', async () => { - await withController(async ({ controller, authProvider }) => { - authProvider.identifiers = [SOCIAL, OTHER_PASSKEY]; - await expect( - controller.register(SECRET, IDENTIFIERS), - ).rejects.toBeInstanceOf(MutationRejectedError); - expect(await controller.getPhase()).toBe('idle'); - }); - }); + it.each([ + ['an identifier Auth did not approve', [SOCIAL, OTHER_PASSKEY]], + [ + 'a stale passkey verifier', + [{ ...PASSKEY, verifier: { publicKey: 'rotated' } }, SOCIAL], + ], + ])( + 'rejects %s before writing, leaving the mutation abortable', + async (_name, approved) => { + await withController(async ({ controller, authProvider, escrowA }) => { + authProvider.identifiers = approved; + const apply = vi.spyOn(escrowA, 'applyMutation'); + await expect( + controller.register(SECRET, IDENTIFIERS), + ).rejects.toMatchObject({ code: 'invalid_pending_operation' }); + expect(apply).not.toHaveBeenCalled(); + expect(await controller.getPhase()).toBe('authorizing'); + await controller.abort(); + expect(await controller.getPhase()).toBe('idle'); + }); + }, + ); }); describe('resume and abort', () => { diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts index e8fa79ebf26..f73e630214c 100644 --- a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.test.ts @@ -63,20 +63,15 @@ function setup(approval: Approval = APPROVAL): { }); rootMessenger.delegate({ actions: [ - 'AuthenticationController:getSessionProfile', + 'AuthenticationController:refreshCanonicalProfileId', 'AuthenticationController:requestMfaApproval', ], events: [], messenger, }); rootMessenger.registerActionHandler( - 'AuthenticationController:getSessionProfile', - async () => ({ - identifierId: 'srp-identifier', - profileId: 'srp-profile', - canonicalProfileId: 'profile-1', - metaMetricsId: 'metametrics-id', - }), + 'AuthenticationController:refreshCanonicalProfileId', + async () => 'profile-1', ); const requestMfaApproval = vi.fn().mockResolvedValue(approval); rootMessenger.registerActionHandler( @@ -90,7 +85,7 @@ function setup(approval: Approval = APPROVAL): { } describe('AuthApiRecoveryAuthProvider', () => { - it('returns the canonical profile id', async () => { + it('returns a freshly resolved canonical profile id', async () => { expect(await setup().provider.getAuthenticatedProfileId()).toBe( 'profile-1', ); diff --git a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts index 6caff5789dc..93b3a1f0310 100644 --- a/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts +++ b/packages/mfa-recovery-controller/src/auth-api-recovery-provider.ts @@ -1,6 +1,6 @@ import type { Messenger } from '@metamask/messenger'; import type { - AuthenticationControllerGetSessionProfileAction, + AuthenticationControllerRefreshCanonicalProfileIdAction, AuthenticationControllerRequestMfaApprovalAction, } from '@metamask/profile-sync-controller/auth'; @@ -8,7 +8,7 @@ import type { RecoveryApproval, RecoveryAuthProvider } from './types.js'; export type AuthApiRecoveryAuthProviderMessenger = Messenger< 'AuthApiRecoveryAuthProvider', - | AuthenticationControllerGetSessionProfileAction + | AuthenticationControllerRefreshCanonicalProfileIdAction | AuthenticationControllerRequestMfaApprovalAction >; @@ -32,12 +32,16 @@ export class AuthApiRecoveryAuthProvider implements RecoveryAuthProvider { this.#messenger = messenger; } - /** @returns The canonical profile id, which approvals use as `sub`. */ + /** + * Resolves the canonical profile id from the server, since the cached one + * can be stale after pairing on another device. + * + * @returns The canonical profile id, which approvals use as `sub`. + */ async getAuthenticatedProfileId(): Promise { - const { canonicalProfileId } = await this.#messenger.call( - 'AuthenticationController:getSessionProfile', + return await this.#messenger.call( + 'AuthenticationController:refreshCanonicalProfileId', ); - return canonicalProfileId; } /** diff --git a/packages/mfa-recovery-controller/src/pending-operation-validation.ts b/packages/mfa-recovery-controller/src/pending-operation-validation.ts index 245c1d29d64..f6cd76225cd 100644 --- a/packages/mfa-recovery-controller/src/pending-operation-validation.ts +++ b/packages/mfa-recovery-controller/src/pending-operation-validation.ts @@ -119,13 +119,31 @@ export async function assertValidPendingOperation( !Array.isArray(approvedIdentifiers) || !approvedIdentifiers.every(isIdentifier) || (pendingPayloadHasIdentifiers(payload) && - token.ext.identifiersHash !== (await hash(approvedIdentifiers))) || + (token.ext.identifiersHash !== (await hash(approvedIdentifiers)) || + !(await isApprovedSubset(payload.identifiers, approvedIdentifiers)))) || !isMutationReceiptArray(pending.receipts) ) { throwInvalidPendingOperation(); } } +/** + * Escrows reject payload identifiers that are not exactly (verifier included) + * in the approval's list, and the rejection leaves the write unabortable. + * + * @param identifiers - Payload identifiers. + * @param approvedIdentifiers - The approval's identifiers. + * @returns Whether every payload identifier is in the approval. + */ +async function isApprovedSubset( + identifiers: Identifier[], + approvedIdentifiers: Identifier[], +): Promise { + const approved = new Set(await Promise.all(approvedIdentifiers.map(hash))); + const hashes = await Promise.all(identifiers.map(hash)); + return hashes.every((identifierHash) => approved.has(identifierHash)); +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); }