diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index ed7fb45e371..efbd8787270 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -5566,6 +5566,9 @@ } }, "packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts": { + "typescript/no-floating-promises": { + "count": 1 + }, "typescript/no-unsafe-argument": { "count": 1 }, diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 7b83833c2c0..301aa87fa4a 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Expose `SeedlessOnboardingController:identifyIncompleteMetadataBackup` so clients can identify incomplete primary SRP metadata backups after unlocking ([#10568](https://github.com/MetaMask/core/pull/10568)) + ### Changed +- **BREAKING:** Grant `SeedlessOnboardingController` access to `AnalyticsController:trackEvent` and `KeyringController:exportSeedPhrase` ([#10568](https://github.com/MetaMask/core/pull/10568)) + - The `SeedlessOnboardingControllerMessenger` must allow and delegate these actions. - Bump `@noble/curves` from `^1.9.2` to `^1.9.7` ([#10720](https://github.com/MetaMask/core/pull/10720)) ## [11.0.1] diff --git a/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md b/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md new file mode 100644 index 00000000000..027dfd15aa9 --- /dev/null +++ b/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md @@ -0,0 +1,206 @@ +# Identify Social Login Users with incomplete/incorrect Metadata backup + +## Background Context + +We have found a production bug where new social login users can ran into the TOPRF init failure **silently** and it leaves the users with incomplete remote backup metadata. +In short, the `Primary SRP` was left out in the remote backup and it is only present in that device. +If users never export that SRP from the wallet, they have the risk of **the permanent wallet loss**, especially when users lost access to the device. + +For more information, please check [this document](<(https://docs.google.com/document/d/1Z2-hBnrYC4Q5d35_maG3uUyn297ODgmrikZDJljRqBE/edit?tab=t.0#heading=h.ic7lth3mlv9b)>). + +As the follow up remediation, we have two steps plan for the existing users in the production ~ + +1. Identify the users affected by this issue +2. Fix the incomplete remote metadata backup (Will be worked on [#10219](https://github.com/MetaMask/core/pull/10219)) + +This document defines the plan to identify the users affected by the Social Login users' TOPRF init failure bug. + +## Goal + +The goal of this document is to guide the development plan to correctly identify the affected users in the production. + +## Identification Flow + +We will do the identification at the users next unlock. Check the flow diagram below. + +```mermaid +flowchart TB + n3["Toprf:fetchAllSecretDataItems"] --> n4["check metadata Version"] + n4 --> n5["V1"] & n6["V2"] & n16["Empty Response"] + n5 --> n8["First Mnemonic Item"] + n6 --> n9["dataType === EncAccountDataType.PrimarySrp"] + n8 --> n7["Seedless:PrimarySecretItem"] + n9 --> n7 + n7 --> n10["Compare"] + n1["Keyring:getPrimaryHdKeyring"] --> n10 + n10 --> n11["Primary SRP Different"] + n11 --> n14["NO"] & n15["YES"] + n14 --> n12["Healthy User"] + n15 --> n13["User with Issue"] + n16 --> n13 + n13 --> n17["REPAIR REQUIRED"] + n12 --> n18["REPAIR NOT_REQUIRED"] + + n3@{ shape: rounded} + n4@{ shape: rect} + n5@{ shape: rect} + n6@{ shape: rect} + n16@{ shape: rect} + n8@{ shape: rect} + n9@{ shape: rect} + n7@{ shape: rounded} + n10@{ shape: rect} + n1@{ shape: rounded} + n11@{ shape: diam} + n14@{ shape: rect} + n15@{ shape: rect} + n12@{ shape: rect} + n13@{ shape: rect} + n17@{ shape: rounded} + n18@{ shape: rounded} +``` + +1. Fetch remote secret metadata from the metadata server via `Toprf:fetchAllSecretDataItems`. +2. Inspect the fetched remote metadata, we have three possible scenarios; + A. No Primary SRP available + B. Primary SRP available with V1 schema + C. Primary SRP available with V2 schema +3. We will compare the Primary SRP data (if any) with the local keyring state. +4. If remote Primary SRP is missing or not match with the local keyring state, we can confirm that user's remote metadata needs the repair. + +> We cannot assume that the V2 migrations has already run for all the users in the product.. Migrations won't be ran if `Primary SRP` isn't available in the remote backup. +> E.g. Step 2.B above. The migrations were designed to run asynchronously and errors aren't visible to the users either. + +### Different Remote Metadata Scenarios + +We can't automatically assume that users don't have the metadata issue just because the `Primary SRP` is available. +We have to inspect it manually and compare it with local keyring. + +#### Missing Primary SRP + +Simplest among three, users do not have any other SRP metadata in the remote backup. +Private Keys might be available but they aren't qualify for the Primary SRP selections. + +We can simply conclude this case as `METADATA REPAIR REQUIRED`. + +#### V1 Primary SRP + +For the legacy V1 schema type, `Primary SRP` is determined based on the backup creation timestamp in the client side. +The earliest SRP (Mnemonic) item is classified as `Primary SRP`. + +Take this as a sample case; + +- User created a Social Login wallet with Torpf init failure. The Primary SRP was not backup to remote. +- User imported new SRPs and they were added to the remote backup. + +```mermaid +flowchart TB + n1(["PrimarySRP"]) -.- n2["Could not Save to Metadata Server"] + n3["Seedless:addNewSecretData"] --> n5(["Legacy_ImportedSrp1"]) + n4["Seedless:addNewSecretData"] --> n6(["Legacy_ImportedSrp2"]) + n2 -.- n7["Metadata Store"] + n5 --> n7 + n6 --> n7 + + n2@{ shape: rect} + n3@{ shape: rounded} + n4@{ shape: rounded} + n7@{ shape: cyl} + style n1 stroke:#D50000 + style n2 stroke:#D50000 + linkStyle 0 stroke:#D50000,fill:none + linkStyle 3 stroke:#D50000,fill:none +``` + +- When we fetch the remote backup metadata for issue identification, it returns that these imported SRPs in the response. +- Client **incorrectly** labels the earliest SRP as the `Primary SRP`. + +```mermaid +flowchart TB + n2["Toprf:fetchAllSecretData"] --> n1["Metadata Store"] + n1 --> n3(["Legacy_ImportedSrp1"]) & n4(["Legacy_ImportedSrp1"]) + n3 --> n5["SeedlessOnboardingController"] + n4 --> n5 + n5 --> n6["In legacy shcema, first SRP is Primary"] + n6 --> n7["Controller incorrectly re-arrange"] + n7 --> n8(["Legacy_PrimarySrp1"]) & n9(["Legacy_ImportedSrp2"]) + + n2@{ shape: rounded} + n1@{ shape: cyl} + n5@{ shape: rounded} + n6@{ shape: rect} + n7@{ shape: rect} + linkStyle 5 stroke:#D50000,fill:none + linkStyle 6 stroke:#D50000,fill:none + linkStyle 7 stroke:#D50000,fill:none + linkStyle 8 stroke:#D50000 +``` + +If the user restores the social login wallet in another device, the user gets the incorrect/incomplete wallet. +In this case; `Legacy_PrimarySrp1` become `PrimarySrp`, which is not correct. + +#### V2 Primary SRP + +For the latest V2 schema, `PrimarySrp` type is attached explicitly to the backup item during the account creation time. +V2 schema is used by default for the new users. For the existing users, the schema migration runs when user adds new Secret Metadata Item (SRP or PrivateKey). + +Take the similar case as V1 Primary SRP, + +- users created an account with the same issue + +```mermaid +flowchart TB + n1(["PrimarySRP"]) -.- n2["Could not Save to Metadata Server"] + n3["Seedless:addNewSecretData"] --> n5(["Legacy_ImportedSrp1"]) + n4["Seedless:addNewSecretData"] --> n6(["Legacy_ImportedSrp2"]) + n2 -.- n7["Metadata Store"] + n5 --> n7 + n6 --> n7 + + n2@{ shape: rect} + n3@{ shape: rounded} + n4@{ shape: rounded} + n7@{ shape: cyl} + style n1 stroke:#D50000 + style n2 stroke:#D50000 + linkStyle 0 stroke:#D50000,fill:none + linkStyle 3 stroke:#D50000,fill:none +``` + +- after we introduced Schema Migration, let's assume it runs in the user's device. + +```mermaid +flowchart TB + n1["Seedless:runMigrations"] --> n2["MetadataStore"] + n2 --> n3(["Legacy_importedSrp1"]) & n4(["Legacy_importedSrp2"]) + n3 --> n5["Seedless:#migrateDataTypes"] + n4 --> n5 + n5 --> n6["Incorrectly Picked first Srp1 as PrimarySrp"] + n6 --> n7["Toprf:batchSetMetadata"] + n7 --> n8["MetadataStore"] + + n1@{ shape: rounded} + n2@{ shape: cyl} + n5@{ shape: rounded} + n6@{ shape: rect} + n7@{ shape: rounded} + n8@{ shape: cyl} + style n6 fill:transparent,stroke:#D50000 + style n7 stroke:#D50000 + style n8 stroke:#D50000 + linkStyle 5 stroke:#D50000,fill:none + linkStyle 6 stroke:#D50000,fill:none + linkStyle 7 stroke:#D50000,fill:none +``` + +Migration could **incorrectly** labels the **imported SRP** as `PrimarySrp` as above and stored it in the remote metadata server. +Same as the v1 case, the user will rehydrate with the incorrect/incomplete wallet. + +## Accepted Behavior + +- The `Identification flow` must run asynchronously and should not block **wallet operations** +- Network failures and cryptographic failures are not classified as affected accounts. +- Any errors during the identification steps, must not surface to the UI. +- Relevant logs and metrics should be collected for all events (both success and failures). +- The next repair step should be perform correctly based on the outcome of this flow. +- The flow **must not** include any writes, modifications to both local and remote server. diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index 0ed79355d42..1c5bda0a436 100644 --- a/packages/seedless-onboarding-controller/package.json +++ b/packages/seedless-onboarding-controller/package.json @@ -25,6 +25,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./utils": { + "types": "./dist/utils/index.d.ts", + "default": "./dist/utils/index.js" + }, "./package.json": "./package.json" }, "publishConfig": { @@ -50,6 +54,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/analytics-controller": "^4.0.0", "@metamask/auth-network-utils": "^0.3.0", "@metamask/base-controller": "^10.0.0", "@metamask/browser-passworder": "^6.0.0", diff --git a/packages/seedless-onboarding-controller/src/SecretMetadata.ts b/packages/seedless-onboarding-controller/src/SecretMetadata.ts index 9b474097e1a..fd9e6940ff3 100644 --- a/packages/seedless-onboarding-controller/src/SecretMetadata.ts +++ b/packages/seedless-onboarding-controller/src/SecretMetadata.ts @@ -12,7 +12,7 @@ import { SecretType, } from './constants.js'; import type { SecretDataType } from './types.js'; -import { getSecretTypeFromDataType } from './utils.js'; +import { getSecretTypeFromDataType } from './utils/index.js'; type ISecretMetadata = { data: DataType; diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts index 1281dd0c075..b0b3a48a215 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-method-action-types.ts @@ -171,6 +171,21 @@ export type SeedlessOnboardingControllerSubmitPasswordAction = { handler: SeedlessOnboardingController['submitPassword']; }; +/** + * Identify an incomplete or mismatched primary SRP backup. + * + * This method must only be called after the controller has been unlocked. + * + * @param password - The password used to export the local primary SRP. + * @returns A promise that resolves after identification and telemetry have + * completed. + */ +export type SeedlessOnboardingControllerIdentifyIncompleteMetadataBackupAction = + { + type: `SeedlessOnboardingController:identifyIncompleteMetadataBackup`; + handler: SeedlessOnboardingController['identifyIncompleteMetadataBackup']; + }; + /** * Set the controller to locked state, and deallocate the secrets (vault encryption key and salt). * @@ -375,6 +390,7 @@ export type SeedlessOnboardingControllerMethodActions = | SeedlessOnboardingControllerVerifyVaultPasswordAction | SeedlessOnboardingControllerGetSecretDataBackupStateAction | SeedlessOnboardingControllerSubmitPasswordAction + | SeedlessOnboardingControllerIdentifyIncompleteMetadataBackupAction | SeedlessOnboardingControllerSetLockedAction | SeedlessOnboardingControllerSyncLatestGlobalPasswordAction | SeedlessOnboardingControllerSubmitGlobalPasswordAction diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts new file mode 100644 index 00000000000..27b89e6c07f --- /dev/null +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts @@ -0,0 +1,131 @@ +import type { AnalyticsControllerTrackEventAction } from '@metamask/analytics-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import type * as encryptionUtils from '@metamask/browser-passworder'; +import type { + DefaultEncryptionResult, + EncryptionResultConstraint, + Encryptor, + KeyringControllerExportSeedPhraseAction, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; + +import { controllerName } from './constants.js'; +import type { Web3AuthNetwork } from './constants.js'; +import type { SeedlessOnboardingControllerMethodActions } from './SeedlessOnboardingController-method-action-types.js'; +import type { + RefreshJWTToken, + RevokeRefreshToken, + RenewRefreshToken, + SeedlessOnboardingControllerState, + ToprfKeyDeriver, +} from './types.js'; + +// Actions +export type SeedlessOnboardingControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + SeedlessOnboardingControllerState + >; + +export type SeedlessOnboardingControllerActions = + | SeedlessOnboardingControllerGetStateAction + | SeedlessOnboardingControllerMethodActions; + +type AllowedActions = + | AnalyticsControllerTrackEventAction + | KeyringControllerExportSeedPhraseAction; + +// Events +export type SeedlessOnboardingControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + SeedlessOnboardingControllerState + >; +export type SeedlessOnboardingControllerEvents = + SeedlessOnboardingControllerStateChangeEvent; + +type AllowedEvents = never; + +// Messenger +export type SeedlessOnboardingControllerMessenger = Messenger< + typeof controllerName, + SeedlessOnboardingControllerActions | AllowedActions, + SeedlessOnboardingControllerEvents | AllowedEvents +>; + +/** + * Seedless Onboarding Controller Options. + * + * @param messenger - The messenger to use for the Seedless Onboarding Controller. + * @param state - The initial state to set on the Seedless Onboarding Controller. + * @param encryptor - The encryptor to use for encrypting and decrypting the Seedless Onboarding vault. + */ +export type SeedlessOnboardingControllerOptions< + EncryptionKey = encryptionUtils.EncryptionKey, + SupportedKeyDerivationParams = encryptionUtils.KeyDerivationOptions, + EncryptionResult extends + EncryptionResultConstraint = + DefaultEncryptionResult, +> = { + messenger: SeedlessOnboardingControllerMessenger; + + /** + * Initial state to set on the Seedless Onboarding Controller. + */ + state?: Partial; + + /** + * Encryptor to use for encrypting and decrypting the Seedless Onboarding vault. + * + * @default browser-passworder @link https://github.com/MetaMask/browser-passworder + */ + encryptor: Encryptor< + EncryptionKey, + SupportedKeyDerivationParams, + EncryptionResult + >; + + /** + * A function to get a new JWT token using a refresh token. + */ + refreshJWTToken: RefreshJWTToken; + + /** + * A function to revoke a refresh token. + */ + revokeRefreshToken: RevokeRefreshToken; + + /** + * A function to renew a refresh token and get a new revoke token. + */ + renewRefreshToken: RenewRefreshToken; + + /** + * Optional key derivation interface for the TOPRF client. + * + * If provided, it will be used as an additional step during + * key derivation. This can be used, for example, to inject a slow key + * derivation step to protect against local brute force attacks on the + * password. + * + * @default browser-passworder @link https://github.com/MetaMask/browser-passworder + */ + toprfKeyDeriver?: ToprfKeyDeriver; + + /** + * Type of Web3Auth network to be used for the Seedless Onboarding flow. + * + * @default Web3AuthNetwork.Mainnet + */ + network?: Web3AuthNetwork; + + /** + * The TTL of the password outdated cache in milliseconds. + * + * @default PASSWORD_OUTDATED_CACHE_TTL_MS + */ + passwordOutdatedCacheTTL?: number; +}; diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index ca4124561e1..921c73f74eb 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -82,6 +82,7 @@ import type { SeedlessOnboardingControllerOptions, } from './SeedlessOnboardingController.js'; import type { SeedlessOnboardingControllerState } from './types.js'; +import { SeedlessPrimarySrpMismatchEventName } from './utils/analytics.js'; const authConnection = AuthConnection.Google; const socialLoginEmail = 'user-test@gmail.com'; @@ -3133,7 +3134,18 @@ describe('SeedlessOnboardingController', () => { vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, }), }, - async ({ baseMessenger }) => { + async ({ baseMessenger, toprfClient }) => { + jest + .spyOn(toprfClient, 'fetchAllSecretDataItems') + .mockImplementationOnce(() => { + // Mock the recover enc key for second time + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + // First call fails with token expired error + throw new TOPRFError( + TOPRFErrorCode.AuthTokenExpired, + 'Auth token expired', + ); + }); await baseMessenger.call( 'SeedlessOnboardingController:submitPassword', MOCK_PASSWORD, @@ -3671,6 +3683,72 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should identify a primary SRP mismatch after unlocking', async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const mockEncryptionKey = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const mockPasswordEncryptionKey = + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); + const mockAuthKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + const { encryptedMockVault: mockVault } = await createMockVault( + mockEncryptionKey, + mockPasswordEncryptionKey, + mockAuthKeyPair, + MOCK_PASSWORD, + ); + + await withController( + { + state: { + vault: mockVault, + }, + }, + async ({ baseMessenger, toprfClient }) => { + const exportSeedPhrase = jest + .fn() + .mockResolvedValue(MOCK_SEED_PHRASE); + const trackEvent = jest.fn(); + + baseMessenger.registerActionHandler( + 'KeyringController:exportSeedPhrase', + exportSeedPhrase, + ); + baseMessenger.registerActionHandler( + 'AnalyticsController:trackEvent', + trackEvent, + ); + const fetchAllSecretDataSpy = jest + .spyOn(toprfClient, 'fetchAllSecretDataItems') + .mockResolvedValue([ + { + data: new SecretMetadata(stringToBytes('remote primary srp'), { + timestamp: 1, + type: SecretType.Mnemonic, + }).toBytes(), + itemId: 'remote-primary-srp', + version: 'v1', + }, + ]); + + await baseMessenger.call( + 'SeedlessOnboardingController:submitPassword', + MOCK_PASSWORD, + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fetchAllSecretDataSpy).toHaveBeenCalledTimes(1); + expect(exportSeedPhrase).toHaveBeenCalledWith({ + password: MOCK_PASSWORD, + }); + expect(trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: SeedlessPrimarySrpMismatchEventName, + }), + ); + }, + ); + }); + it('should throw error if the vault is missing', async () => { await withController(async ({ baseMessenger }) => { await expect( @@ -5139,6 +5217,111 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should identify a primary SRP mismatch when explicitly requested', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + }), + }, + async ({ controller, toprfClient, baseMessenger }) => { + await mockCreateToprfKeyAndBackupSeedPhrase( + toprfClient, + controller, + baseMessenger, + RECOVERED_PASSWORD, + MOCK_SEED_PHRASE, + MOCK_KEYRING_ID, + ); + + await baseMessenger.call( + 'SeedlessOnboardingController:storeKeyringEncryptionKey', + MOCK_KEYRING_ENCRYPTION_KEY, + ); + + const mockToprfEncryptor = createMockToprfEncryptor(); + const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); + const authKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ + encKey, + authKeyPair, + pwEncKey, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + + const recoveredPwEncKey = + mockToprfEncryptor.derivePwEncKey(RECOVERED_PASSWORD); + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: recoveredPwEncKey, + }); + + const exportSeedPhrase = jest + .fn() + .mockResolvedValue(MOCK_SEED_PHRASE); + const trackEvent = jest.fn(); + baseMessenger.registerActionHandler( + 'KeyringController:exportSeedPhrase', + exportSeedPhrase, + ); + baseMessenger.registerActionHandler( + 'AnalyticsController:trackEvent', + trackEvent, + ); + jest + .spyOn(toprfClient, 'fetchAllSecretDataItems') + .mockResolvedValueOnce([ + { + data: new SecretMetadata(stringToBytes('remote primary srp'), { + timestamp: 1, + type: SecretType.Mnemonic, + }).toBytes(), + itemId: 'remote-primary-srp', + version: 'v1', + }, + ]); + + await baseMessenger.call('SeedlessOnboardingController:setLocked'); + await baseMessenger.call( + 'SeedlessOnboardingController:submitGlobalPassword', + { + globalPassword: GLOBAL_PASSWORD, + }, + ); + + await baseMessenger.call( + 'SeedlessOnboardingController:identifyIncompleteMetadataBackup', + GLOBAL_PASSWORD, + ); + + expect(exportSeedPhrase).toHaveBeenCalledWith({ + password: GLOBAL_PASSWORD, + }); + expect(trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: SeedlessPrimarySrpMismatchEventName, + }), + ); + }, + ); + }); + + it('should throw if identifyIncompleteMetadataBackup is called while locked', async () => { + await withController(async ({ baseMessenger }) => { + await expect( + baseMessenger.call( + 'SeedlessOnboardingController:identifyIncompleteMetadataBackup', + GLOBAL_PASSWORD, + ), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.ControllerLocked, + ); + }); + }); + it('should throw if key not set', async () => { await withController( { @@ -6808,11 +6991,6 @@ describe('SeedlessOnboardingController', () => { isNewUser: false, }); - await baseMessenger.call( - 'SeedlessOnboardingController:submitPassword', - MOCK_PASSWORD, - ); - await expect( baseMessenger.call( 'SeedlessOnboardingController:fetchAllSecretData', diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index ec28f2f53d6..ce7d7aeb199 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1,17 +1,11 @@ import { keccak256AndHexify } from '@metamask/auth-network-utils'; import { BaseController } from '@metamask/base-controller'; -import type { - ControllerGetStateAction, - ControllerStateChangeEvent, - StateMetadata, -} from '@metamask/base-controller'; import type * as encryptionUtils from '@metamask/browser-passworder'; import type { DefaultEncryptionResult, EncryptionResultConstraint, Encryptor, } from '@metamask/keyring-controller'; -import type { Messenger } from '@metamask/messenger'; import type { AuthenticateResult, ChangeEncryptionKeyResult, @@ -22,27 +16,15 @@ import type { } from '@metamask/toprf-secure-backup'; import { ToprfSecureBackup, - TOPRFErrorCode, - TOPRFError, EncAccountDataType, } from '@metamask/toprf-secure-backup'; -import { - base64ToBytes, - bytesToBase64, - isNullOrUndefined, -} from '@metamask/utils'; +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; import { gcm } from '@noble/ciphers/aes'; import { bytesToUtf8, utf8ToBytes } from '@noble/ciphers/utils'; import { managedNonce } from '@noble/ciphers/webcrypto'; import { secp256k1 } from '@noble/curves/secp256k1'; import { Mutex } from 'async-mutex'; -import { - assertIsPasswordOutdatedCacheValid, - assertIsSeedlessOnboardingUserAuthenticated, - assertIsValidPassword, - assertIsValidVaultData, -} from './assertions.js'; import type { AuthConnection } from './constants.js'; import { controllerName, @@ -53,14 +35,20 @@ import { Web3AuthNetwork, } from './constants.js'; import { - InvalidPrimarySecretDataTypeError, PasswordSyncError, RecoveryError, SeedlessOnboardingError, } from './errors.js'; import { projectLogger, createModuleLogger } from './logger.js'; import { SecretMetadata } from './SecretMetadata.js'; -import type { SeedlessOnboardingControllerMethodActions } from './SeedlessOnboardingController-method-action-types.js'; +import type { + SeedlessOnboardingControllerMessenger, + SeedlessOnboardingControllerOptions, +} from './SeedlessOnboardingController-types.js'; +import { + getInitialSeedlessOnboardingControllerStateWithDefaults, + seedlessOnboardingMetadata, +} from './state.js'; import type { MutuallyExclusiveCallback, SeedlessOnboardingControllerState, @@ -71,15 +59,38 @@ import type { RenewRefreshToken, VaultData, DeserializedVaultData, - ToprfKeyDeriver, } from './types.js'; import { + assertIsEncryptedKeyringEncryptionKeySet, + assertIsEncryptedSeedlessEncryptionKeySet, + assertIsPasswordOutdatedCacheValid, + assertIsSeedlessOnboardingUserAuthenticated, + assertIsValidPassword, compareAndGetLatestToken, decodeJWTToken, decodeNodeAuthToken, deserializeVaultData, + getDataTypeMigrationUpdates, + getNewSocialBackupsMetadata, + trackIncompleteMetadataBackupEvents, + isAuthTokenError, + isMaxKeyChainLengthError, + isTokenNearExpiry, + parseVaultData, + parseAndValidateSecretMetadataBackup, serializeVaultData, -} from './utils.js'; +} from './utils/index.js'; +import type { SecretBackupData } from './utils/index.js'; + +export type { + SeedlessOnboardingControllerActions, + SeedlessOnboardingControllerEvents, + SeedlessOnboardingControllerGetStateAction, + SeedlessOnboardingControllerMessenger, + SeedlessOnboardingControllerOptions, + SeedlessOnboardingControllerStateChangeEvent, +} from './SeedlessOnboardingController-types.js'; +export { getInitialSeedlessOnboardingControllerStateWithDefaults } from './state.js'; const log = createModuleLogger(projectLogger, controllerName); @@ -95,6 +106,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'verifyVaultPassword', 'getSecretDataBackupState', 'submitPassword', + 'identifyIncompleteMetadataBackup', 'setLocked', 'syncLatestGlobalPassword', 'submitGlobalPassword', @@ -113,280 +125,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'runMigrations', ] as const; -// Actions -export type SeedlessOnboardingControllerGetStateAction = - ControllerGetStateAction< - typeof controllerName, - SeedlessOnboardingControllerState - >; - -export type SeedlessOnboardingControllerActions = - | SeedlessOnboardingControllerGetStateAction - | SeedlessOnboardingControllerMethodActions; - -type AllowedActions = never; - -// Events -export type SeedlessOnboardingControllerStateChangeEvent = - ControllerStateChangeEvent< - typeof controllerName, - SeedlessOnboardingControllerState - >; -export type SeedlessOnboardingControllerEvents = - SeedlessOnboardingControllerStateChangeEvent; - -type AllowedEvents = never; - -// Messenger -export type SeedlessOnboardingControllerMessenger = Messenger< - typeof controllerName, - SeedlessOnboardingControllerActions | AllowedActions, - SeedlessOnboardingControllerEvents | AllowedEvents ->; - -/** - * Seedless Onboarding Controller Options. - * - * @param messenger - The messenger to use for this controller. - * @param state - The initial state to set on this controller. - * @param encryptor - The encryptor to use for encrypting and decrypting seedless onboarding vault. - */ -export type SeedlessOnboardingControllerOptions< - EncryptionKey = encryptionUtils.EncryptionKey, - SupportedKeyDerivationParams = encryptionUtils.KeyDerivationOptions, - EncryptionResult extends - EncryptionResultConstraint = - DefaultEncryptionResult, -> = { - messenger: SeedlessOnboardingControllerMessenger; - - /** - * Initial state to set on this controller. - */ - state?: Partial; - - /** - * Encryptor to use for encrypting and decrypting seedless onboarding vault. - * - * @default browser-passworder @link https://github.com/MetaMask/browser-passworder - */ - encryptor: Encryptor< - EncryptionKey, - SupportedKeyDerivationParams, - EncryptionResult - >; - - /** - * A function to get a new jwt token using refresh token. - */ - refreshJWTToken: RefreshJWTToken; - - /** - * A function to revoke the refresh token. - */ - revokeRefreshToken: RevokeRefreshToken; - - /** - * A function to renew the refresh token and get new revoke token. - */ - renewRefreshToken: RenewRefreshToken; - - /** - * Optional key derivation interface for the TOPRF client. - * - * If provided, it will be used as an additional step during - * key derivation. This can be used, for example, to inject a slow key - * derivation step to protect against local brute force attacks on the - * password. - * - * @default browser-passworder @link https://github.com/MetaMask/browser-passworder - */ - toprfKeyDeriver?: ToprfKeyDeriver; - - /** - * Type of Web3Auth network to be used for the Seedless Onboarding flow. - * - * @default Web3AuthNetwork.Mainnet - */ - network?: Web3AuthNetwork; - - /** - * The TTL of the password outdated cache in milliseconds. - * - * @default PASSWORD_OUTDATED_CACHE_TTL_MS - */ - passwordOutdatedCacheTTL?: number; -}; - -/** - * Get the initial state for the Seedless Onboarding Controller with defaults. - * - * @param overrides - The overrides for the initial state. - * @returns The initial state for the Seedless Onboarding Controller. - */ -export function getInitialSeedlessOnboardingControllerStateWithDefaults( - overrides?: Partial, -): SeedlessOnboardingControllerState { - const initialState = { - socialBackupsMetadata: [], - isSeedlessOnboardingUserAuthenticated: false, - migrationVersion: 0, - ...overrides, - }; - - // Ensure authenticated flag is set correctly. - try { - assertIsSeedlessOnboardingUserAuthenticated(initialState); - initialState.isSeedlessOnboardingUserAuthenticated = true; - } catch { - initialState.isSeedlessOnboardingUserAuthenticated = false; - } - return initialState; -} - -/** - * Seedless Onboarding Controller State Metadata. - * - * This allows us to choose if fields of the state should be persisted or not - * using the `persist` flag; and if they can be sent to Sentry or not, using - * the `anonymous` flag. - */ -const seedlessOnboardingMetadata: StateMetadata = - { - vault: { - includeInStateLogs: false, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - socialBackupsMetadata: { - includeInStateLogs: false, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - nodeAuthTokens: { - // We sanitize the `authToken` field from the `nodeAuthTokens` to avoid logging the actual token. - // The reason we include this in the state logs is to help with debugging in case of any issues. - includeInStateLogs: (nodeAuthTokens) => - !isNullOrUndefined(nodeAuthTokens), - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - authConnection: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: true, - usedInUi: true, - }, - authConnectionId: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: true, - usedInUi: false, - }, - groupedAuthConnectionId: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: true, - usedInUi: false, - }, - userId: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - socialLoginEmail: { - includeInStateLogs: false, - persist: true, - includeInDebugSnapshot: false, - usedInUi: true, - }, - vaultEncryptionKey: { - includeInStateLogs: false, - persist: false, - includeInDebugSnapshot: false, - usedInUi: false, - }, - vaultEncryptionSalt: { - includeInStateLogs: false, - persist: false, - includeInDebugSnapshot: false, - usedInUi: false, - }, - authPubKey: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - passwordOutdatedCache: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: true, - usedInUi: false, - }, - refreshToken: { - includeInStateLogs: false, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - revokeToken: { - includeInStateLogs: false, - persist: false, - includeInDebugSnapshot: false, - usedInUi: false, - }, - pendingToBeRevokedTokens: { - includeInStateLogs: false, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - // stays in vault - accessToken: { - includeInStateLogs: false, - persist: false, - includeInDebugSnapshot: false, - usedInUi: false, - }, - // stays outside of vault as this token is accessed by the metadata service - // before the vault is created or unlocked. - metadataAccessToken: { - includeInStateLogs: false, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - encryptedSeedlessEncryptionKey: { - includeInStateLogs: false, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - encryptedKeyringEncryptionKey: { - includeInStateLogs: false, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - isSeedlessOnboardingUserAuthenticated: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: true, - usedInUi: false, - }, - migrationVersion: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: true, - usedInUi: false, - }, - }; - export class SeedlessOnboardingController< EncryptionKey = encryptionUtils.EncryptionKey, SupportedKeyDerivationOptions = encryptionUtils.KeyDerivationOptions, @@ -835,50 +573,7 @@ export class SeedlessOnboardingController< throw error; } - let hasPrimarySrp = secretDatas.some( - (secret) => - secret.itemId && - secret.itemId !== 'PW_BACKUP' && - secret.dataType === EncAccountDataType.PrimarySrp, - ); - - const updates: { itemId: string; dataType: EncAccountDataType }[] = []; - - for (const secret of secretDatas) { - if (!secret.itemId || secret.itemId === 'PW_BACKUP') { - continue; - } - - // Skip items that are already migrated (v2 with dataType set) - // Check both storageVersion and dataType since this migration is specific to dataType - const isAlreadyMigrated = - secret.storageVersion === 'v2' && - secret.dataType !== undefined && - secret.dataType !== null; - if (isAlreadyMigrated) { - continue; - } - - let dataType: EncAccountDataType; - - if (SecretMetadata.matchesType(secret, SecretType.Mnemonic)) { - // Preserve existing PrimarySrp designation - if (secret.dataType === EncAccountDataType.PrimarySrp) { - dataType = EncAccountDataType.PrimarySrp; - } else if (hasPrimarySrp) { - dataType = EncAccountDataType.ImportedSrp; - } else { - dataType = EncAccountDataType.PrimarySrp; - hasPrimarySrp = true; - } - } else if (SecretMetadata.matchesType(secret, SecretType.PrivateKey)) { - dataType = EncAccountDataType.ImportedPrivateKey; - } else { - continue; - } - - updates.push({ itemId: secret.itemId, dataType }); - } + const updates = getDataTypeMigrationUpdates(secretDatas); if (updates.length === 1) { await this.toprfClient.updateSecretDataItem({ @@ -1143,9 +838,26 @@ export class SeedlessOnboardingController< } this.#setUnlocked(); + + this.#identifyIncompleteMetadataBackup(password); }); } + /** + * Identify an incomplete or mismatched primary SRP backup. + * + * This method must only be called after the controller has been unlocked. + * + * @param password - The password used to export the local primary SRP. + * @returns A promise that resolves after identification and telemetry have + * completed. + */ + async identifyIncompleteMetadataBackup(password: string): Promise { + this.#assertIsUnlocked(); + + await this.#identifyIncompleteMetadataBackup(password); + } + /** * Set the controller to locked state, and deallocate the secrets (vault encryption key and salt). * @@ -1283,10 +995,10 @@ export class SeedlessOnboardingController< decryptedVaultData.accessToken, ); } catch (error) { - if (this.#isAuthTokenError(error)) { + if (isAuthTokenError(error)) { throw error; } - if (this.#isMaxKeyChainLengthError(error)) { + if (isMaxKeyChainLengthError(error)) { throw new Error( SeedlessOnboardingControllerErrorMessage.MaxKeyChainLengthExceeded, ); @@ -1469,7 +1181,7 @@ export class SeedlessOnboardingController< authPubKey, }); } catch (error) { - if (this.#isAuthTokenError(error)) { + if (isAuthTokenError(error)) { throw error; } log('Error persisting local encryption key', error); @@ -1616,7 +1328,7 @@ export class SeedlessOnboardingController< return recoverEncKeyResult; } catch (error) { // throw token expired error for token refresh handler - if (this.#isAuthTokenError(error)) { + if (isAuthTokenError(error)) { throw error; } @@ -1637,7 +1349,7 @@ export class SeedlessOnboardingController< }); } catch (error) { log('Error fetching secret data', error); - if (this.#isAuthTokenError(error)) { + if (isAuthTokenError(error)) { throw error; } throw new SeedlessOnboardingError( @@ -1650,33 +1362,7 @@ export class SeedlessOnboardingController< // user must have at least one secret data if (secretDataItems?.length > 0) { - const results: SecretMetadata[] = secretDataItems.map((item) => - SecretMetadata.fromRawMetadata(item.data, { - itemId: item.itemId, - dataType: item.dataType, - createdAt: item.createdAt, - storageVersion: item.version, - }), - ); - - // Sort: PrimarySrp first, then by client timestamp (oldest first) - results.sort((a, b) => SecretMetadata.compare(a, b, 'asc')); - - const primaryIndex = results.findIndex( - (result) => - SecretMetadata.matchesType(result, SecretType.Mnemonic) && - (result.dataType === undefined || - result.dataType === null || - result.dataType === EncAccountDataType.PrimarySrp), - ); - if (primaryIndex === -1) { - throw InvalidPrimarySecretDataTypeError.fromSecretMetadata(results); - } - if (primaryIndex !== 0) { - const [primary] = results.splice(primaryIndex, 1); - results.unshift(primary); - } - return results; + return parseAndValidateSecretMetadataBackup(secretDataItems); } throw new Error(SeedlessOnboardingControllerErrorMessage.NoSecretDataFound); @@ -1827,7 +1513,7 @@ export class SeedlessOnboardingController< return secretMetadata; } catch (error) { - if (this.#isAuthTokenError(error)) { + if (isAuthTokenError(error)) { throw error; } log('Error encrypting and storing secret data backup', error); @@ -1942,7 +1628,7 @@ export class SeedlessOnboardingController< vaultEncryptionSalt = result.salt; } - const vaultData = this.#parseVaultData(decryptedVaultData); + const vaultData = parseVaultData(decryptedVaultData); return { vaultData, @@ -1969,10 +1655,8 @@ export class SeedlessOnboardingController< * @throws Rethrows any errors from the callback with additional logging */ async #withPersistedSecretMetadataBackupsState( - createSecretMetadataBackupCallback: () => Promise< - Omit & { data: Uint8Array } - >, - ): Promise & { data: Uint8Array }> { + createSecretMetadataBackupCallback: () => Promise, + ): Promise { try { const newBackup = await createSecretMetadataBackupCallback(); @@ -1995,43 +1679,12 @@ export class SeedlessOnboardingController< * @param secretData.type - The type of the secret data. */ #filterDupesAndUpdateSocialBackupsMetadata( - secretData: - | { - data: Uint8Array; - keyringId?: string; - type: SecretType; - } - | { - data: Uint8Array; - keyringId?: string; - type: SecretType; - }[], + secretData: SecretBackupData | SecretBackupData[], ): void { - const currentBackupsMetadata = this.state.socialBackupsMetadata; - - const newBackupsMetadata = Array.isArray(secretData) - ? secretData - : [secretData]; - const filteredNewBackupsMetadata: SocialBackupsMetadata[] = []; - - // filter out the backed up metadata that already exists in the state - // to prevent duplicates - newBackupsMetadata.forEach((item) => { - const { keyringId, data, type } = item; - const backupHash = keccak256AndHexify(data); - - const backupStateAlreadyExisted = currentBackupsMetadata.some( - (backup) => backup.hash === backupHash && backup.type === type, - ); - - if (!backupStateAlreadyExisted) { - filteredNewBackupsMetadata.push({ - keyringId, - hash: backupHash, - type, - }); - } - }); + const filteredNewBackupsMetadata = getNewSocialBackupsMetadata( + this.state.socialBackupsMetadata, + secretData, + ); if (filteredNewBackupsMetadata.length > 0) { this.update((state) => { @@ -2274,30 +1927,6 @@ export class SeedlessOnboardingController< return await withLock(this.#vaultOperationMutex, callback); } - /** - * Parse and deserialize the authentication data from the vault. - * - * @param data - The decrypted vault data. - * @returns The parsed authentication data. - * @throws If the vault data is not valid. - */ - #parseVaultData(data: unknown): VaultData { - if (typeof data !== 'string') { - throw new Error(SeedlessOnboardingControllerErrorMessage.VaultDataError); - } - - let parsedVaultData: unknown; - try { - parsedVaultData = JSON.parse(data); - } catch { - throw new Error(SeedlessOnboardingControllerErrorMessage.VaultDataError); - } - - assertIsValidVaultData(parsedVaultData); - - return parsedVaultData; - } - #assertIsUnlocked(): void { if (!this.#isUnlocked) { throw new Error( @@ -2702,41 +2331,6 @@ export class SeedlessOnboardingController< }); } - /** - * Check if the provided error is an auth token error. - * - * This method checks if the error is a TOPRF error with AuthTokenExpired code or InvalidAuthToken code. - * - * @param error - The error to check. - * @returns True if the error indicates auth token error, false otherwise. - */ - #isAuthTokenError(error: unknown): boolean { - if (error instanceof TOPRFError) { - return ( - error.code === TOPRFErrorCode.AuthTokenExpired || - error.code === TOPRFErrorCode.InvalidAuthToken - ); - } - - return false; - } - - /** - * Check if the provided error is a max key chain length error. - * - * This method checks if the error is a TOPRF error with MaxKeyChainLength code. - * - * @param error - The error to check. - * @returns True if the error indicates max key chain length has been exceeded, false otherwise. - */ - #isMaxKeyChainLengthError(error: unknown): boolean { - if (error instanceof TOPRFError) { - return error.code === TOPRFErrorCode.MaxKeyChainLengthExceeded; - } - - return false; - } - /** * Executes an operation with automatic token refresh on expiration. * @@ -2765,7 +2359,7 @@ export class SeedlessOnboardingController< return await operation(); } catch (error) { // Check if this is a token expiration error - if (this.#isAuthTokenError(error)) { + if (isAuthTokenError(error)) { log( `Token expired during ${operationName}, attempting to refresh tokens`, error, @@ -2866,33 +2460,26 @@ export class SeedlessOnboardingController< return true; // Consider unauthenticated user as having expired tokens } } -} -/** - * Determine whether a token should be proactively refreshed. - * - * When `iat` is provided: returns `true` when less than 10% of the token's - * lifetime remains (i.e. we are in the last 10% before expiry). - * When `iat` is omitted (e.g. node auth tokens): returns `true` when the token - * is already expired. - * - * @param exp - Token expiration time in seconds (Unix epoch). - * @param iat - Optional issued-at time in seconds (Unix epoch). Required for 10% threshold. - * @returns True if the token should be refreshed. - */ -function isTokenNearExpiry(exp: number, iat?: number): boolean { - const now = Date.now() / 1000; - if (iat === undefined) { - return now >= exp; - } - const lifetime = exp - iat; - // Guard against malformed tokens where iat >= exp (zero or negative lifetime). - // Fall back to exact-expiry check so bad tokens are always considered stale. - if (lifetime <= 0) { - return now >= exp; - } - const remaining = exp - now; - return remaining <= 0.1 * lifetime; + async #identifyIncompleteMetadataBackup(password: string): Promise { + const deserializedVaultData = + this.#cachedDecryptedVaultData as DeserializedVaultData; + + await trackIncompleteMetadataBackupEvents({ + fetchAllSecretDataFn: () => + this.toprfClient.fetchAllSecretDataItems({ + decKey: deserializedVaultData.toprfEncryptionKey, + authKeyPair: deserializedVaultData.toprfAuthKeyPair, + }), + getPrimaryKeyringSeedPhraseFn: () => + this.messenger.call('KeyringController:exportSeedPhrase', { + password, + }), + trackEvent: (event) => + this.messenger.call('AnalyticsController:trackEvent', event), + logFn: log, + }); + } } /** @@ -2916,35 +2503,3 @@ async function withLock( releaseLock(); } } - -/** - * Assert that the provided encrypted keyring encryption key is a valid non-empty string. - * - * @param encryptedKeyringEncryptionKey - The encrypted keyring encryption key to check. - * @throws If the encrypted keyring encryption key is not a valid string. - */ -function assertIsEncryptedKeyringEncryptionKeySet( - encryptedKeyringEncryptionKey: string | undefined, -): asserts encryptedKeyringEncryptionKey is string { - if (!encryptedKeyringEncryptionKey) { - throw new Error( - SeedlessOnboardingControllerErrorMessage.EncryptedKeyringEncryptionKeyNotSet, - ); - } -} - -/** - * Assert that the provided encrypted seedless encryption key is a valid non-empty string. - * - * @param encryptedSeedlessEncryptionKey - The encrypted seedless encryption key to check. - * @throws If the encrypted seedless encryption key is not a valid string. - */ -function assertIsEncryptedSeedlessEncryptionKeySet( - encryptedSeedlessEncryptionKey: string | undefined, -): asserts encryptedSeedlessEncryptionKey is string { - if (!encryptedSeedlessEncryptionKey) { - throw new Error( - SeedlessOnboardingControllerErrorMessage.EncryptedSeedlessEncryptionKeyNotSet, - ); - } -} diff --git a/packages/seedless-onboarding-controller/src/errors.ts b/packages/seedless-onboarding-controller/src/errors.ts index 3f4dbcf05a1..fa9fe52470b 100644 --- a/packages/seedless-onboarding-controller/src/errors.ts +++ b/packages/seedless-onboarding-controller/src/errors.ts @@ -7,7 +7,7 @@ import type { InvalidPrimarySecretDataTypeErrorData, RecoveryErrorData, } from './types.js'; -import { getInvalidPrimarySecretDataTypeErrorData } from './utils.js'; +import { getInvalidPrimarySecretDataTypeErrorData } from './utils/index.js'; /** * Get the error message from the TOPRF error code. diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index cf220239b06..867f2114822 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -22,6 +22,7 @@ export type { SeedlessOnboardingControllerVerifyVaultPasswordAction, SeedlessOnboardingControllerGetSecretDataBackupStateAction, SeedlessOnboardingControllerSubmitPasswordAction, + SeedlessOnboardingControllerIdentifyIncompleteMetadataBackupAction, SeedlessOnboardingControllerSetLockedAction, SeedlessOnboardingControllerSyncLatestGlobalPasswordAction, SeedlessOnboardingControllerSubmitGlobalPasswordAction, diff --git a/packages/seedless-onboarding-controller/src/state.ts b/packages/seedless-onboarding-controller/src/state.ts new file mode 100644 index 00000000000..32f238ea224 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/state.ts @@ -0,0 +1,174 @@ +import type { StateMetadata } from '@metamask/base-controller'; +import { isNullOrUndefined } from '@metamask/utils'; + +import type { SeedlessOnboardingControllerState } from './types.js'; +import { assertIsSeedlessOnboardingUserAuthenticated } from './utils/index.js'; + +/** + * Seedless Onboarding Controller State Metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +export const seedlessOnboardingMetadata: StateMetadata = + { + vault: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + socialBackupsMetadata: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + nodeAuthTokens: { + // We sanitize the `authToken` field from the `nodeAuthTokens` to avoid logging the actual token. + // The reason we include this in the state logs is to help with debugging in case of any issues. + includeInStateLogs: (nodeAuthTokens) => + !isNullOrUndefined(nodeAuthTokens), + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + authConnection: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + authConnectionId: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, + groupedAuthConnectionId: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, + userId: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + socialLoginEmail: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + vaultEncryptionKey: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + vaultEncryptionSalt: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + authPubKey: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + passwordOutdatedCache: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, + refreshToken: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + revokeToken: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + pendingToBeRevokedTokens: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + // stays in vault + accessToken: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + // stays outside of vault as this token is accessed by the metadata service + // before the vault is created or unlocked. + metadataAccessToken: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + encryptedSeedlessEncryptionKey: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + encryptedKeyringEncryptionKey: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + isSeedlessOnboardingUserAuthenticated: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, + migrationVersion: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, + }; + +/** + * Get the initial state for the Seedless Onboarding Controller with defaults. + * + * @param overrides - The overrides for the initial state. + * @returns The initial state for the Seedless Onboarding Controller. + */ +export function getInitialSeedlessOnboardingControllerStateWithDefaults( + overrides?: Partial, +): SeedlessOnboardingControllerState { + const initialState = { + socialBackupsMetadata: [], + isSeedlessOnboardingUserAuthenticated: false, + migrationVersion: 0, + ...overrides, + }; + + // Ensure authenticated flag is set correctly. + try { + assertIsSeedlessOnboardingUserAuthenticated(initialState); + initialState.isSeedlessOnboardingUserAuthenticated = true; + } catch { + initialState.isSeedlessOnboardingUserAuthenticated = false; + } + return initialState; +} diff --git a/packages/seedless-onboarding-controller/src/utils/analytics.test.ts b/packages/seedless-onboarding-controller/src/utils/analytics.test.ts new file mode 100644 index 00000000000..1bf3b95c674 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/utils/analytics.test.ts @@ -0,0 +1,395 @@ +import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; +import { stringToBytes } from '@metamask/utils'; + +import { + createFetchedSecretDataItem, + createSecretMetadataFromMock, + ImportedPrivateKey1, + ImportedPrivateKey2, + ImportedSRP1, + PrimarySRP2, + PrivateKey1, + SRP1, + SRP2, +} from '../../tests/__fixtures__/secret-metadata.js'; +import { SecretType } from '../constants.js'; +import type { IncompleteMetadataBackupEventProperties } from './analytics.js'; +import { + getIncompleteMetadataBackupEventProperties, + trackIncompleteMetadataBackupEvents, + SeedlessPrimarySrpMismatchEventName, + SeedlessPrimarySrpMissingEventName, +} from './analytics.js'; +import { parseSecretMetadata } from './secret-data-utils.js'; + +function expectTrackedEvent( + trackEvent: jest.Mock, + { + name, + properties, + }: { + name: string; + properties: IncompleteMetadataBackupEventProperties; + }, +): void { + expect(trackEvent).toHaveBeenCalledTimes(1); + expect(trackEvent).toHaveBeenCalledWith({ + name, + properties, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); +} + +describe('identifyIncompleteMetadataBackup', () => { + it('does not track an event when the legacy primary matches the local primary', async () => { + const fetchAllSecretDataFn = jest + .fn() + .mockResolvedValue([ + createFetchedSecretDataItem(PrivateKey1), + createFetchedSecretDataItem(SRP1), + createFetchedSecretDataItem(SRP2), + ]); + const getPrimaryKeyringSeedPhraseFn = jest + .fn() + .mockResolvedValue(stringToBytes(SRP1.data)); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await trackIncompleteMetadataBackupEvents({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn, + trackEvent, + logFn, + }); + + expect(getPrimaryKeyringSeedPhraseFn).toHaveBeenCalledTimes(1); + expect(trackEvent).not.toHaveBeenCalled(); + expect(logFn).not.toHaveBeenCalled(); + }); + + it('tracks a mismatch when the earliest legacy SRP is not the local primary', async () => { + const fetchAllSecretDataFn = jest + .fn() + .mockResolvedValue([ + createFetchedSecretDataItem(PrivateKey1), + createFetchedSecretDataItem(SRP1), + createFetchedSecretDataItem(SRP2), + ]); + const getPrimaryKeyringSeedPhraseFn = jest + .fn() + .mockResolvedValue(stringToBytes('actual primary srp')); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await trackIncompleteMetadataBackupEvents({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn, + trackEvent, + logFn, + }); + + expectTrackedEvent(trackEvent, { + name: SeedlessPrimarySrpMismatchEventName, + properties: { + metadata_schema_version: 'v1', + number_of_imported_wallets: 1, + number_of_imported_accounts: 1, + }, + }); + expect(logFn).not.toHaveBeenCalled(); + }); + + it('uses the explicitly tagged V2 primary when it matches the local primary', async () => { + const fetchAllSecretDataFn = jest + .fn() + .mockResolvedValue([ + createFetchedSecretDataItem(ImportedSRP1), + createFetchedSecretDataItem(PrimarySRP2), + ]); + const getPrimaryKeyringSeedPhraseFn = jest + .fn() + .mockResolvedValue(stringToBytes(PrimarySRP2.data)); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await trackIncompleteMetadataBackupEvents({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn, + trackEvent, + logFn, + }); + + expect(trackEvent).not.toHaveBeenCalled(); + expect(logFn).not.toHaveBeenCalled(); + }); + + it('tracks a mismatch when the explicitly tagged V2 primary differs', async () => { + const fetchAllSecretDataFn = jest + .fn() + .mockResolvedValue([ + createFetchedSecretDataItem(ImportedSRP1), + createFetchedSecretDataItem(PrimarySRP2), + ]); + const getPrimaryKeyringSeedPhraseFn = jest + .fn() + .mockResolvedValue(stringToBytes('actual primary srp')); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await trackIncompleteMetadataBackupEvents({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn, + trackEvent, + logFn, + }); + + expectTrackedEvent(trackEvent, { + name: SeedlessPrimarySrpMismatchEventName, + properties: { + metadata_schema_version: 'v2', + number_of_imported_wallets: 1, + number_of_imported_accounts: 0, + }, + }); + expect(logFn).not.toHaveBeenCalled(); + }); + + it.each([ + { + description: 'an empty response', + secretDataItems: [], + properties: { + metadata_schema_version: 'none', + number_of_imported_wallets: 0, + number_of_imported_accounts: 0, + }, + }, + { + description: 'metadata containing only imported SRPs', + secretDataItems: [createFetchedSecretDataItem(ImportedSRP1)], + properties: { + metadata_schema_version: 'v2' as const, + number_of_imported_wallets: 1, + number_of_imported_accounts: 0, + }, + }, + { + description: 'metadata containing only private keys', + secretDataItems: [createFetchedSecretDataItem(PrivateKey1)], + properties: { + metadata_schema_version: 'v1' as const, + number_of_imported_wallets: 0, + number_of_imported_accounts: 1, + }, + }, + ])( + 'tracks a missing-primary event for $description', + async ({ secretDataItems, properties }) => { + const fetchAllSecretDataFn = jest.fn().mockResolvedValue(secretDataItems); + const getPrimaryKeyringSeedPhraseFn = jest.fn(); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await trackIncompleteMetadataBackupEvents({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn, + trackEvent, + logFn, + }); + + expectTrackedEvent(trackEvent, { + name: SeedlessPrimarySrpMissingEventName, + properties: properties as IncompleteMetadataBackupEventProperties, + }); + expect(getPrimaryKeyringSeedPhraseFn).not.toHaveBeenCalled(); + expect(logFn).not.toHaveBeenCalled(); + }, + ); + + it('logs fetch failures without tracking an affected-user event', async () => { + const error = new Error('network failure'); + const fetchAllSecretDataFn = jest.fn().mockRejectedValue(error); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await trackIncompleteMetadataBackupEvents({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn: jest.fn(), + trackEvent, + logFn, + }); + + expect(logFn).toHaveBeenCalledWith( + 'Error identifying incomplete metadata backup', + error, + ); + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it('logs malformed metadata without tracking an affected-user event', async () => { + const fetchAllSecretDataFn = jest.fn().mockResolvedValue([ + { + data: stringToBytes('malformed metadata'), + version: 'v1', + } as FetchedSecretDataItem, + ]); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await trackIncompleteMetadataBackupEvents({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn: jest.fn(), + trackEvent, + logFn, + }); + + expect(logFn).toHaveBeenCalledWith( + 'Error identifying incomplete metadata backup', + expect.any(Error), + ); + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it('logs local keyring failures without tracking an affected-user event', async () => { + const error = new Error('keyring unavailable'); + const fetchAllSecretDataFn = jest + .fn() + .mockResolvedValue([createFetchedSecretDataItem(SRP1)]); + const getPrimaryKeyringSeedPhraseFn = jest.fn().mockRejectedValue(error); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await trackIncompleteMetadataBackupEvents({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn, + trackEvent, + logFn, + }); + + expect(logFn).toHaveBeenCalledWith( + 'Error identifying incomplete metadata backup', + error, + ); + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it('does not propagate analytics tracking failures', async () => { + const fetchAllSecretDataFn = jest.fn().mockResolvedValue([]); + const trackError = new Error('analytics unavailable'); + const trackEvent = jest.fn().mockRejectedValue(trackError); + const logFn = jest.fn(); + + await trackIncompleteMetadataBackupEvents({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn: jest.fn(), + trackEvent, + logFn, + }); + + expectTrackedEvent(trackEvent, { + name: SeedlessPrimarySrpMissingEventName, + properties: { + metadata_schema_version: 'none', + number_of_imported_wallets: 0, + number_of_imported_accounts: 0, + }, + }); + expect(logFn).toHaveBeenCalledWith( + 'Error tracking incomplete metadata backup event', + trackError, + ); + }); +}); + +describe('getIncompleteMetadataBackupEventProperties', () => { + it('excludes the oldest legacy mnemonic from the imported wallet count', () => { + const secretMetadata = parseSecretMetadata([ + createFetchedSecretDataItem(PrivateKey1), + createFetchedSecretDataItem(SRP2), + createFetchedSecretDataItem(SRP1), + ]); + + expect( + getIncompleteMetadataBackupEventProperties(secretMetadata), + ).toStrictEqual({ + metadata_schema_version: 'v1', + number_of_imported_wallets: 1, + number_of_imported_accounts: 1, + }); + }); + + it('counts v2 imported SRPs and private keys without the tagged primary', () => { + const secretMetadata = parseSecretMetadata([ + createFetchedSecretDataItem(ImportedPrivateKey1), + createFetchedSecretDataItem(ImportedSRP1), + createFetchedSecretDataItem(PrimarySRP2), + ]); + + expect( + getIncompleteMetadataBackupEventProperties(secretMetadata), + ).toStrictEqual({ + metadata_schema_version: 'v2', + number_of_imported_wallets: 1, + number_of_imported_accounts: 1, + }); + }); + + it('counts every mnemonic when none can be the primary secret', () => { + const secretMetadata = parseSecretMetadata([ + createFetchedSecretDataItem(ImportedSRP1), + createFetchedSecretDataItem(ImportedPrivateKey2), + ]); + + expect( + getIncompleteMetadataBackupEventProperties(secretMetadata), + ).toStrictEqual({ + metadata_schema_version: 'v2', + number_of_imported_wallets: 1, + number_of_imported_accounts: 1, + }); + }); + + it('reports v1 when stored schema versions are mixed', () => { + const secretMetadata = parseSecretMetadata([ + createFetchedSecretDataItem(SRP1), + createFetchedSecretDataItem(ImportedPrivateKey2), + ]); + + expect( + getIncompleteMetadataBackupEventProperties(secretMetadata), + ).toStrictEqual({ + metadata_schema_version: 'mixed', + number_of_imported_wallets: 0, + number_of_imported_accounts: 1, + }); + }); + + it('does not count a secret that is neither a mnemonic nor a private key', () => { + // Technically, this should never happen, TOPRF client sdk should filter this before reaching to controller. + // But just in case, we should handle it gracefully. + const unknownSecret = createSecretMetadataFromMock({ + data: 'password backup', + timestamp: 100, + type: 'PWD_BACKUP' as SecretType, + storageVersion: 'v1', + }); + + expect( + getIncompleteMetadataBackupEventProperties([unknownSecret]), + ).toStrictEqual({ + metadata_schema_version: 'v1', + number_of_imported_wallets: 0, + number_of_imported_accounts: 0, + }); + }); + + it('returns zero counts for an empty backup', () => { + expect(getIncompleteMetadataBackupEventProperties([])).toStrictEqual({ + metadata_schema_version: 'none', + number_of_imported_wallets: 0, + number_of_imported_accounts: 0, + }); + }); +}); diff --git a/packages/seedless-onboarding-controller/src/utils/analytics.ts b/packages/seedless-onboarding-controller/src/utils/analytics.ts new file mode 100644 index 00000000000..b41347ea6ff --- /dev/null +++ b/packages/seedless-onboarding-controller/src/utils/analytics.ts @@ -0,0 +1,180 @@ +import type { + AnalyticsContext, + AnalyticsTrackingEvent, +} from '@metamask/analytics-controller'; +import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; +import { areUint8ArraysEqual } from '@metamask/utils'; + +import { SecretType } from '../constants.js'; +import { InvalidPrimarySecretDataTypeError } from '../errors.js'; +import { SecretMetadata } from '../SecretMetadata.js'; +import { + isPrimarySecretCandidate, + parseSecretMetadata, + validateSecretMetadataBackup, +} from './secret-data-utils.js'; + +export const SeedlessPrimarySrpMissingEventName = + 'Seedless Onboarding Primary SRP Missing'; +export const SeedlessPrimarySrpMismatchEventName = + 'Seedless Onboarding Primary SRP Mismatch'; + +export type IncompleteMetadataBackupEventProperties = { + metadata_schema_version: 'v1' | 'v2' | 'mixed' | 'none'; + number_of_imported_wallets: number; + number_of_imported_accounts: number; +}; + +/** + * Build non-sensitive properties describing an incomplete metadata backup. + * + * The primary mnemonic is omitted. Imported wallets are the remaining + * mnemonics, including legacy mnemonics and `ImportedSrp` items. Imported + * accounts are private keys. The schema version is `v2` when every item is + * stored as v2. + * + * @param secretMetadata - Secret metadata returned by `parseSecretMetadata`. + * @returns Analytics properties for the incomplete metadata backup events. + */ +export function getIncompleteMetadataBackupEventProperties( + secretMetadata: SecretMetadata[], +): IncompleteMetadataBackupEventProperties { + const primaryIndex = secretMetadata.findIndex(isPrimarySecretCandidate); + let numberOfImportedWallets = 0; + let numberOfImportedAccounts = 0; + + secretMetadata.forEach((secret, index) => { + if (index === primaryIndex) { + return; + } + + if (SecretMetadata.matchesType(secret, SecretType.Mnemonic)) { + numberOfImportedWallets += 1; + } else if (SecretMetadata.matchesType(secret, SecretType.PrivateKey)) { + numberOfImportedAccounts += 1; + } + }); + + return { + metadata_schema_version: getMetadataSchemaVersion(secretMetadata), + number_of_imported_wallets: numberOfImportedWallets, + number_of_imported_accounts: numberOfImportedAccounts, + }; +} + +/** + * Report `v2` only when every stored item already uses the v2 schema. + * + * @param secretMetadata - Parsed secret metadata. + * @returns The metadata schema version to include in analytics. + */ +function getMetadataSchemaVersion( + secretMetadata: SecretMetadata[], +): + | IncompleteMetadataBackupEventProperties['metadata_schema_version'] + | 'mixed' + | 'none' { + if (secretMetadata.length === 0) { + // No secret metadata found. + return 'none'; + } + + if (secretMetadata.every((secret) => secret.storageVersion === 'v2')) { + return 'v2'; + } + + if (secretMetadata.every((secret) => secret.storageVersion === 'v1')) { + return 'v1'; + } + + return 'mixed'; +} + +/** + * Identify whether the local primary SRP is missing from or differs from the + * remote TOPRF metadata backup. + * + * Legacy metadata is ordered by the client-side timestamp and its first + * mnemonic is treated as the remote primary candidate. Newer metadata uses + * the explicit `PrimarySrp` data type. The candidate is compared with the + * primary SRP supplied by the keyring callback. + * + * This function only reads the supplied data and emits telemetry. It does not + * modify local or remote backup data. Fetch, parsing, keyring, and comparison + * failures are logged and are not classified as affected users. + * + * @param params - Functions used to read the remote metadata and local primary + * SRP, emit telemetry, and log inconclusive failures. + * @param params.fetchAllSecretDataFn - Fetches the decrypted remote secret + * metadata items. + * @param params.getPrimaryKeyringSeedPhraseFn - Returns the local primary SRP + * bytes from the keyring. + * @param params.trackEvent - Emits a non-sensitive analytics event. + * @param params.logFn - Records failures that prevent identification. + * @returns A promise that resolves after identification and best-effort + * telemetry have completed. + */ +export async function trackIncompleteMetadataBackupEvents({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn, + trackEvent, + logFn, +}: { + fetchAllSecretDataFn: () => Promise; + trackEvent: ( + event: AnalyticsTrackingEvent, + context?: AnalyticsContext, + ) => void | Promise; + getPrimaryKeyringSeedPhraseFn: () => Promise; + logFn: (message: string, error?: unknown) => void; +}): Promise { + const trackEventSafely = async ( + event: AnalyticsTrackingEvent, + ): Promise => { + try { + await trackEvent(event); + } catch (error) { + logFn('Error tracking incomplete metadata backup event', error); + } + }; + + let properties: IncompleteMetadataBackupEventProperties | undefined; + + try { + const secretDataItems = await fetchAllSecretDataFn(); + const secretMetadata = parseSecretMetadata(secretDataItems); + const eventProperties = + getIncompleteMetadataBackupEventProperties(secretMetadata); + properties = eventProperties; + + const [primarySecretMetadata] = + validateSecretMetadataBackup(secretMetadata); + const localPrimarySrp = await getPrimaryKeyringSeedPhraseFn(); + + if (areUint8ArraysEqual(primarySecretMetadata.data, localPrimarySrp)) { + return; + } + + await trackEventSafely({ + name: SeedlessPrimarySrpMismatchEventName, + properties: eventProperties, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + } catch (error) { + if (error instanceof InvalidPrimarySecretDataTypeError && properties) { + await trackEventSafely({ + name: SeedlessPrimarySrpMissingEventName, + properties, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + + return; + } + + logFn('Error identifying incomplete metadata backup', error); + } +} diff --git a/packages/seedless-onboarding-controller/src/assertions.test.ts b/packages/seedless-onboarding-controller/src/utils/assertions.test.ts similarity index 87% rename from packages/seedless-onboarding-controller/src/assertions.test.ts rename to packages/seedless-onboarding-controller/src/utils/assertions.test.ts index bec8735444a..f1ff6420f4e 100644 --- a/packages/seedless-onboarding-controller/src/assertions.test.ts +++ b/packages/seedless-onboarding-controller/src/utils/assertions.test.ts @@ -1,10 +1,12 @@ +import { SeedlessOnboardingControllerErrorMessage } from '../constants.js'; +import { VaultData } from '../types.js'; import { + assertIsEncryptedKeyringEncryptionKeySet, + assertIsEncryptedSeedlessEncryptionKeySet, assertIsPasswordOutdatedCacheValid, assertIsValidPassword, assertIsValidVaultData, } from './assertions.js'; -import { SeedlessOnboardingControllerErrorMessage } from './constants.js'; -import { VaultData } from './types.js'; describe('assertIsValidPassword', () => { it('should throw when password is not a string', () => { @@ -209,3 +211,25 @@ describe('assertIsPasswordOutdatedCacheValid', () => { ); }); }); + +describe('encrypted key assertions', () => { + it('accepts non-empty encrypted key values', () => { + expect(() => { + assertIsEncryptedKeyringEncryptionKeySet('encrypted-keyring-key'); + assertIsEncryptedSeedlessEncryptionKeySet('encrypted-seedless-key'); + }).not.toThrow(); + }); + + it('rejects missing encrypted key values', () => { + expect(() => { + assertIsEncryptedKeyringEncryptionKeySet(undefined); + }).toThrow( + SeedlessOnboardingControllerErrorMessage.EncryptedKeyringEncryptionKeyNotSet, + ); + expect(() => { + assertIsEncryptedSeedlessEncryptionKeySet(''); + }).toThrow( + SeedlessOnboardingControllerErrorMessage.EncryptedSeedlessEncryptionKeyNotSet, + ); + }); +}); diff --git a/packages/seedless-onboarding-controller/src/assertions.ts b/packages/seedless-onboarding-controller/src/utils/assertions.ts similarity index 74% rename from packages/seedless-onboarding-controller/src/assertions.ts rename to packages/seedless-onboarding-controller/src/utils/assertions.ts index cc7eddc4278..f4318252941 100644 --- a/packages/seedless-onboarding-controller/src/assertions.ts +++ b/packages/seedless-onboarding-controller/src/utils/assertions.ts @@ -1,5 +1,5 @@ -import { SeedlessOnboardingControllerErrorMessage } from './constants.js'; -import type { AuthenticatedUserDetails, VaultData } from './types.js'; +import { SeedlessOnboardingControllerErrorMessage } from '../constants.js'; +import type { AuthenticatedUserDetails, VaultData } from '../types.js'; /** * Assert that the provided password is a valid non-empty string. @@ -91,6 +91,40 @@ export function assertIsPasswordOutdatedCacheValid( } } +/** + * Assert that the provided encrypted keyring encryption key is a valid + * non-empty string. + * + * @param encryptedKeyringEncryptionKey - The encrypted keyring encryption key. + * @throws If the encrypted keyring encryption key is not a valid string. + */ +export function assertIsEncryptedKeyringEncryptionKeySet( + encryptedKeyringEncryptionKey: string | undefined, +): asserts encryptedKeyringEncryptionKey is string { + if (!encryptedKeyringEncryptionKey) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.EncryptedKeyringEncryptionKeyNotSet, + ); + } +} + +/** + * Assert that the provided encrypted seedless encryption key is a valid + * non-empty string. + * + * @param encryptedSeedlessEncryptionKey - The encrypted seedless encryption key. + * @throws If the encrypted seedless encryption key is not a valid string. + */ +export function assertIsEncryptedSeedlessEncryptionKeySet( + encryptedSeedlessEncryptionKey: string | undefined, +): asserts encryptedSeedlessEncryptionKey is string { + if (!encryptedSeedlessEncryptionKey) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.EncryptedSeedlessEncryptionKeyNotSet, + ); + } +} + /** * Check if the provided value is a valid vault data. * diff --git a/packages/seedless-onboarding-controller/src/utils/index.ts b/packages/seedless-onboarding-controller/src/utils/index.ts new file mode 100644 index 00000000000..accb1d28624 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/utils/index.ts @@ -0,0 +1,27 @@ +export { + assertIsEncryptedKeyringEncryptionKeySet, + assertIsEncryptedSeedlessEncryptionKeySet, + assertIsPasswordOutdatedCacheValid, + assertIsSeedlessOnboardingUserAuthenticated, + assertIsValidPassword, +} from './assertions.js'; +export { trackIncompleteMetadataBackupEvents } from './analytics.js'; +export type { SecretBackupData } from './secret-data-utils.js'; +export { + getDataTypeMigrationUpdates, + getNewSocialBackupsMetadata, + parseAndValidateSecretMetadataBackup, +} from './secret-data-utils.js'; +export { + compareAndGetLatestToken, + decodeJWTToken, + decodeNodeAuthToken, + deserializeVaultData, + getInvalidPrimarySecretDataTypeErrorData, + getSecretTypeFromDataType, + isAuthTokenError, + isMaxKeyChainLengthError, + isTokenNearExpiry, + parseVaultData, + serializeVaultData, +} from './utils.js'; diff --git a/packages/seedless-onboarding-controller/src/utils/secret-data-utils.test.ts b/packages/seedless-onboarding-controller/src/utils/secret-data-utils.test.ts new file mode 100644 index 00000000000..14fa8a305e3 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/utils/secret-data-utils.test.ts @@ -0,0 +1,179 @@ +import { keccak256AndHexify } from '@metamask/auth-network-utils'; +import { EncAccountDataType } from '@metamask/toprf-secure-backup'; +import { stringToBytes } from '@metamask/utils'; + +import { + createFetchedSecretDataItem, + createSecretMetadata, + createSecretMetadataFromMock, + ImportedSRP1, + PrimarySRP2, + PrivateKey1, + SRP1, + SRP2, +} from '../../tests/__fixtures__/secret-metadata.js'; +import { SecretType } from '../constants.js'; +import { InvalidPrimarySecretDataTypeError } from '../errors.js'; +import { + getDataTypeMigrationUpdates, + getNewSocialBackupsMetadata, + parseAndValidateSecretMetadataBackup, + parseSecretMetadata, +} from './secret-data-utils.js'; + +describe('secret data utilities', () => { + describe('parseSecretMetadata', () => { + it('sorts primary SRP items ahead of older secrets', () => { + const results = parseSecretMetadata([ + createFetchedSecretDataItem({ + ...PrivateKey1, + itemId: 'private-key', + }), + createFetchedSecretDataItem({ + ...ImportedSRP1, + timestamp: 1, + itemId: 'imported-srp', + }), + createFetchedSecretDataItem({ + ...PrimarySRP2, + timestamp: 300, + itemId: 'primary-srp', + }), + ]); + + expect(results.map((result) => result.itemId)).toStrictEqual([ + 'primary-srp', + 'imported-srp', + 'private-key', + ]); + }); + + it('keeps timestamp order when no primary mnemonic is tagged', () => { + const results = parseSecretMetadata([ + createFetchedSecretDataItem({ + ...ImportedSRP1, + itemId: 'imported-srp', + }), + ]); + + expect(results.map((result) => result.itemId)).toStrictEqual([ + 'imported-srp', + ]); + }); + }); + + describe('parseAndValidateSecretMetadataBackup', () => { + it('sorts legacy metadata by timestamp and promotes the first mnemonic', () => { + const results = parseAndValidateSecretMetadataBackup([ + createFetchedSecretDataItem(PrivateKey1), + createFetchedSecretDataItem(SRP2), + createFetchedSecretDataItem(SRP1), + ]); + + expect(results.map((result) => result.data)).toStrictEqual([ + stringToBytes(SRP1.data), + stringToBytes(PrivateKey1.data), + stringToBytes(SRP2.data), + ]); + }); + + it('keeps an explicitly tagged primary mnemonic first', () => { + const results = parseAndValidateSecretMetadataBackup([ + createFetchedSecretDataItem(ImportedSRP1), + createFetchedSecretDataItem(PrimarySRP2), + ]); + + expect(results[0]?.data).toStrictEqual(stringToBytes(PrimarySRP2.data)); + expect(results[0]?.dataType).toBe(EncAccountDataType.PrimarySrp); + }); + + it('throws when no mnemonic can be used as the primary secret', () => { + expect(() => + parseAndValidateSecretMetadataBackup([ + createFetchedSecretDataItem(ImportedSRP1), + ]), + ).toThrow( + new InvalidPrimarySecretDataTypeError([EncAccountDataType.ImportedSrp]), + ); + }); + }); + + describe('getDataTypeMigrationUpdates', () => { + it('classifies legacy items in their existing order', () => { + const updates = getDataTypeMigrationUpdates([ + createSecretMetadataFromMock(SRP1), + createSecretMetadataFromMock(SRP2), + createSecretMetadataFromMock(PrivateKey1), + ]); + + expect(updates).toStrictEqual([ + { itemId: SRP1.itemId, dataType: EncAccountDataType.PrimarySrp }, + { itemId: SRP2.itemId, dataType: EncAccountDataType.ImportedSrp }, + { + itemId: PrivateKey1.itemId, + dataType: EncAccountDataType.ImportedPrivateKey, + }, + ]); + }); + + it('preserves an existing primary and skips items outside the migration', () => { + const updates = getDataTypeMigrationUpdates([ + createSecretMetadata(SRP1.data, { + dataType: EncAccountDataType.PrimarySrp, + itemId: 'primary-srp', + storageVersion: 'v1', + }), + createSecretMetadata('already migrated srp', { + dataType: EncAccountDataType.ImportedSrp, + itemId: 'migrated-srp', + storageVersion: 'v2', + }), + createSecretMetadata('password backup', { + dataType: EncAccountDataType.PrimarySrp, + itemId: 'PW_BACKUP', + storageVersion: 'v1', + }), + createSecretMetadata('missing item id'), + ]); + + expect(updates).toStrictEqual([ + { itemId: 'primary-srp', dataType: EncAccountDataType.PrimarySrp }, + ]); + }); + }); + + describe('getNewSocialBackupsMetadata', () => { + it('returns only backups that are not already present by hash and type', () => { + const existingData = stringToBytes('existing'); + const newData = stringToBytes('new'); + const currentBackups = [ + { + hash: keccak256AndHexify(existingData), + type: SecretType.Mnemonic, + keyringId: 'existing-keyring', + }, + ]; + + const result = getNewSocialBackupsMetadata(currentBackups, [ + { + data: existingData, + type: SecretType.Mnemonic, + keyringId: 'duplicate-keyring', + }, + { + data: newData, + type: SecretType.Mnemonic, + keyringId: 'new-keyring', + }, + ]); + + expect(result).toStrictEqual([ + { + hash: keccak256AndHexify(newData), + type: SecretType.Mnemonic, + keyringId: 'new-keyring', + }, + ]); + }); + }); +}); diff --git a/packages/seedless-onboarding-controller/src/utils/secret-data-utils.ts b/packages/seedless-onboarding-controller/src/utils/secret-data-utils.ts new file mode 100644 index 00000000000..99f7fb853e1 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/utils/secret-data-utils.ts @@ -0,0 +1,194 @@ +import { keccak256AndHexify } from '@metamask/auth-network-utils'; +import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; +import { EncAccountDataType } from '@metamask/toprf-secure-backup'; + +import { SecretType } from '../constants.js'; +import { InvalidPrimarySecretDataTypeError } from '../errors.js'; +import { SecretMetadata } from '../SecretMetadata.js'; +import type { SocialBackupsMetadata } from '../types.js'; + +export type SecretDataTypeMigrationUpdate = { + itemId: string; + dataType: EncAccountDataType; +}; + +export type SecretBackupData = Omit & { + data: Uint8Array; +}; + +/** + * Parse and sort secret metadata fetched from the metadata store. + * + * Primary SRP items are ordered first, then remaining items by client + * timestamp (oldest first). This does not require a primary mnemonic. + * + * @param secretDataItems - The encrypted metadata items fetched from storage. + * @returns The parsed secret metadata in sort order. + */ +export function parseSecretMetadata( + secretDataItems: FetchedSecretDataItem[], +): SecretMetadata[] { + const results: SecretMetadata[] = secretDataItems.map((item) => + SecretMetadata.fromRawMetadata(item.data, { + itemId: item.itemId, + dataType: item.dataType, + createdAt: item.createdAt, + storageVersion: item.version, + }), + ); + + // Sort: PrimarySrp first, then by client timestamp (oldest first). + results.sort((a, b) => SecretMetadata.compare(a, b, 'asc')); + + return results; +} + +/** + * A mnemonic can be the primary secret when it is untagged or explicitly + * tagged as the primary SRP. + * + * @param secret - The secret metadata to check. + * @returns Whether the secret can be selected as the primary mnemonic. + */ +export function isPrimarySecretCandidate(secret: SecretMetadata): boolean { + return ( + SecretMetadata.matchesType(secret, SecretType.Mnemonic) && + (secret.dataType === undefined || + secret.dataType === null || + secret.dataType === EncAccountDataType.PrimarySrp) + ); +} + +/** + * Validate that parsed secret metadata contains a primary mnemonic, and move + * that mnemonic to the front. + * + * @param secretMetadata - Parsed secret metadata. + * @returns The same array with the primary mnemonic at index 0. + * @throws If no mnemonic can be used as the primary secret. + */ +export function validateSecretMetadataBackup( + secretMetadata: SecretMetadata[], +): SecretMetadata[] { + const primaryIndex = secretMetadata.findIndex(isPrimarySecretCandidate); + if (primaryIndex === -1) { + throw InvalidPrimarySecretDataTypeError.fromSecretMetadata(secretMetadata); + } + + if (primaryIndex !== 0) { + const [primary] = secretMetadata.splice(primaryIndex, 1); + secretMetadata.unshift(primary); + } + + return secretMetadata; +} + +/** + * Parse, sort, and validate a secret metadata backup. + * + * @param secretDataItems - The encrypted metadata items fetched from storage. + * @returns The parsed and ordered secret metadata. + * @throws If no mnemonic can be used as the primary secret. + */ +export function parseAndValidateSecretMetadataBackup( + secretDataItems: FetchedSecretDataItem[], +): SecretMetadata[] { + return validateSecretMetadataBackup(parseSecretMetadata(secretDataItems)); +} + +/** + * Determine the data-type updates required to migrate legacy secret metadata. + * + * The input is expected to already be ordered according to the legacy + * primary-secret selection rules. + * + * @param secretDatas - The ordered secret metadata to migrate. + * @returns The storage updates required for the migration. + */ +export function getDataTypeMigrationUpdates( + secretDatas: SecretMetadata[], +): SecretDataTypeMigrationUpdate[] { + let hasPrimarySrp = secretDatas.some( + (secret) => + secret.itemId && + secret.itemId !== 'PW_BACKUP' && + secret.dataType === EncAccountDataType.PrimarySrp, + ); + + const updates: SecretDataTypeMigrationUpdate[] = []; + + for (const secret of secretDatas) { + if (!secret.itemId || secret.itemId === 'PW_BACKUP') { + continue; + } + + // Skip items that are already migrated (v2 with dataType set). + const isAlreadyMigrated = + secret.storageVersion === 'v2' && + secret.dataType !== undefined && + secret.dataType !== null; + if (isAlreadyMigrated) { + continue; + } + + let dataType: EncAccountDataType; + + if (SecretMetadata.matchesType(secret, SecretType.Mnemonic)) { + // Preserve existing PrimarySrp designation. + if (secret.dataType === EncAccountDataType.PrimarySrp) { + dataType = EncAccountDataType.PrimarySrp; + } else if (hasPrimarySrp) { + dataType = EncAccountDataType.ImportedSrp; + } else { + dataType = EncAccountDataType.PrimarySrp; + hasPrimarySrp = true; + } + } else if (SecretMetadata.matchesType(secret, SecretType.PrivateKey)) { + dataType = EncAccountDataType.ImportedPrivateKey; + } else { + continue; + } + + updates.push({ itemId: secret.itemId, dataType }); + } + + return updates; +} + +/** + * Find new local backup metadata entries without mutating the current state. + * + * @param currentBackupsMetadata - Existing backup metadata in controller state. + * @param secretData - New secret data to index. + * @returns The backup metadata entries that should be appended. + */ +export function getNewSocialBackupsMetadata( + currentBackupsMetadata: SocialBackupsMetadata[], + secretData: SecretBackupData | SecretBackupData[], +): SocialBackupsMetadata[] { + const newBackupsMetadata = Array.isArray(secretData) + ? secretData + : [secretData]; + const filteredNewBackupsMetadata: SocialBackupsMetadata[] = []; + + // Keep the existing duplicate semantics: compare each item against the + // state that existed before this operation began. + newBackupsMetadata.forEach((item) => { + const { keyringId, data, type } = item; + const backupHash = keccak256AndHexify(data); + + const backupStateAlreadyExisted = currentBackupsMetadata.some( + (backup) => backup.hash === backupHash && backup.type === type, + ); + + if (!backupStateAlreadyExisted) { + filteredNewBackupsMetadata.push({ + keyringId, + hash: backupHash, + type, + }); + } + }); + + return filteredNewBackupsMetadata; +} diff --git a/packages/seedless-onboarding-controller/src/utils.test.ts b/packages/seedless-onboarding-controller/src/utils/utils.test.ts similarity index 71% rename from packages/seedless-onboarding-controller/src/utils.test.ts rename to packages/seedless-onboarding-controller/src/utils/utils.test.ts index d1a922d3b5d..f066a23e5f7 100644 --- a/packages/seedless-onboarding-controller/src/utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils/utils.test.ts @@ -1,17 +1,28 @@ -import { EncAccountDataType } from '@metamask/toprf-secure-backup'; +import { + EncAccountDataType, + TOPRFError, + TOPRFErrorCode, +} from '@metamask/toprf-secure-backup'; import { bytesToBase64 } from '@metamask/utils'; import { utf8ToBytes } from '@noble/ciphers/utils'; -import { createMockJWTToken } from '../tests/mocks/utils.js'; -import { SecretType } from './constants.js'; -import { SecretMetadata } from './SecretMetadata.js'; -import type { DecodedNodeAuthToken } from './types.js'; +import { createMockJWTToken } from '../../tests/mocks/utils.js'; +import { + SeedlessOnboardingControllerErrorMessage, + SecretType, +} from '../constants.js'; +import { SecretMetadata } from '../SecretMetadata.js'; +import type { DecodedNodeAuthToken } from '../types.js'; import { decodeNodeAuthToken, decodeJWTToken, compareAndGetLatestToken, getInvalidPrimarySecretDataTypeErrorData, getSecretTypeFromDataType, + isAuthTokenError, + isMaxKeyChainLengthError, + isTokenNearExpiry, + parseVaultData, } from './utils.js'; describe('utils', () => { @@ -143,7 +154,7 @@ describe('utils', () => { }).toThrow('Invalid JWT token format'); }); - it('should handle token with special characters in string fields', () => { + it('should handle JWT token with special characters in string fields', () => { const mockToken = createMockJWTToken({ aud: 'https://example.com/audience', iss: 'https://issuer.example.com', @@ -269,4 +280,95 @@ describe('utils', () => { ]); }); }); + + describe('isTokenNearExpiry', () => { + it('uses exact expiry when no issued-at timestamp is provided', () => { + expect(isTokenNearExpiry(100, undefined, 99)).toBe(false); + expect(isTokenNearExpiry(100, undefined, 100)).toBe(true); + }); + + it('returns true during the final ten percent of the token lifetime', () => { + expect(isTokenNearExpiry(100, 0, 89)).toBe(false); + expect(isTokenNearExpiry(100, 0, 90)).toBe(true); + }); + + it('falls back to exact expiry for malformed lifetimes', () => { + expect(isTokenNearExpiry(100, 100, 99)).toBe(false); + expect(isTokenNearExpiry(100, 100, 100)).toBe(true); + }); + }); + + describe('parseVaultData', () => { + const validVaultData = { + toprfEncryptionKey: 'toprf-encryption-key', + toprfPwEncryptionKey: 'toprf-password-encryption-key', + toprfAuthKeyPair: 'toprf-auth-key-pair', + revokeToken: 'revoke-token', + accessToken: 'access-token', + }; + + it('parses valid serialized vault data', () => { + expect(parseVaultData(JSON.stringify(validVaultData))).toStrictEqual( + validVaultData, + ); + }); + + it('throws VaultDataError for non-string or malformed data', () => { + expect(() => parseVaultData(undefined)).toThrow( + SeedlessOnboardingControllerErrorMessage.VaultDataError, + ); + expect(() => parseVaultData('not-json')).toThrow( + SeedlessOnboardingControllerErrorMessage.VaultDataError, + ); + }); + + it('throws InvalidVaultData for a parsed value with an invalid shape', () => { + expect(() => + parseVaultData(JSON.stringify({ ...validVaultData, accessToken: 1 })), + ).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidAccessToken); + }); + }); + + describe('TOPRF error predicates', () => { + it('identifies authentication token errors', () => { + expect( + isAuthTokenError( + new TOPRFError(TOPRFErrorCode.AuthTokenExpired, 'expired'), + ), + ).toBe(true); + expect( + isAuthTokenError( + new TOPRFError(TOPRFErrorCode.InvalidAuthToken, 'invalid'), + ), + ).toBe(true); + expect( + isAuthTokenError( + new TOPRFError( + TOPRFErrorCode.MaxKeyChainLengthExceeded, + 'max key chain length', + ), + ), + ).toBe(false); + expect(isAuthTokenError(new Error('not a TOPRF error'))).toBe(false); + }); + + it('identifies max key chain length errors', () => { + expect( + isMaxKeyChainLengthError( + new TOPRFError( + TOPRFErrorCode.MaxKeyChainLengthExceeded, + 'max key chain length', + ), + ), + ).toBe(true); + expect( + isMaxKeyChainLengthError( + new TOPRFError(TOPRFErrorCode.AuthTokenExpired, 'expired'), + ), + ).toBe(false); + expect(isMaxKeyChainLengthError(new Error('not a TOPRF error'))).toBe( + false, + ); + }); + }); }); diff --git a/packages/seedless-onboarding-controller/src/utils.ts b/packages/seedless-onboarding-controller/src/utils/utils.ts similarity index 61% rename from packages/seedless-onboarding-controller/src/utils.ts rename to packages/seedless-onboarding-controller/src/utils/utils.ts index 21a08b1f2da..967b0dcea26 100644 --- a/packages/seedless-onboarding-controller/src/utils.ts +++ b/packages/seedless-onboarding-controller/src/utils/utils.ts @@ -1,5 +1,9 @@ import type { KeyPair } from '@metamask/toprf-secure-backup'; -import { EncAccountDataType } from '@metamask/toprf-secure-backup'; +import { + EncAccountDataType, + TOPRFError, + TOPRFErrorCode, +} from '@metamask/toprf-secure-backup'; import { base64ToBytes, bigIntToHex, @@ -8,15 +12,19 @@ import { } from '@metamask/utils'; import { bytesToUtf8 } from '@noble/ciphers/utils'; -import { SecretType } from './constants.js'; -import type { SecretMetadata } from './SecretMetadata.js'; +import { + SeedlessOnboardingControllerErrorMessage, + SecretType, +} from '../constants.js'; +import type { SecretMetadata } from '../SecretMetadata.js'; import type { DecodedBaseJWTToken, DecodedNodeAuthToken, DeserializedVaultData, InvalidPrimarySecretDataTypeErrorData, VaultData, -} from './types.js'; +} from '../types.js'; +import { assertIsValidVaultData } from './assertions.js'; /** * Decode the node auth token from base64 to json object. @@ -25,7 +33,7 @@ import type { * @returns The decoded node auth token. */ export function decodeNodeAuthToken(token: string): DecodedNodeAuthToken { - return JSON.parse(bytesToUtf8(base64ToBytes(token))); + return JSON.parse(bytesToUtf8(base64ToBytes(token))) as DecodedNodeAuthToken; } /** @@ -45,8 +53,9 @@ export function decodeJWTToken(token: string): DecodedBaseJWTToken { const payload = parts[1]; // Add padding if needed for base64 decoding const paddedPayload = payload + '='.repeat((4 - (payload.length % 4)) % 4); - const decoded = JSON.parse(bytesToUtf8(base64ToBytes(paddedPayload))); - return decoded as DecodedBaseJWTToken; + return JSON.parse( + bytesToUtf8(base64ToBytes(paddedPayload)), + ) as DecodedBaseJWTToken; } /** @@ -88,6 +97,30 @@ export function deserializeVaultData(value: VaultData): DeserializedVaultData { }; } +/** + * Parse and validate decrypted vault data. + * + * @param data - The decrypted vault data. + * @returns The parsed vault data. + * @throws If the decrypted data is not valid JSON or is not valid vault data. + */ +export function parseVaultData(data: unknown): VaultData { + if (typeof data !== 'string') { + throw new Error(SeedlessOnboardingControllerErrorMessage.VaultDataError); + } + + let parsedVaultData: unknown; + try { + parsedVaultData = JSON.parse(data); + } catch { + throw new Error(SeedlessOnboardingControllerErrorMessage.VaultDataError); + } + + assertIsValidVaultData(parsedVaultData); + + return parsedVaultData; +} + /** * Serialize TOPRF authentication key pair. * @@ -110,7 +143,10 @@ export function serializeToprfAuthKeyPair(keyPair: KeyPair): string { * @returns The deserialized authentication key pair. */ export function deserializeAuthKeyPair(value: string): KeyPair { - const parsedKeyPair = JSON.parse(value); + const parsedKeyPair = JSON.parse(value) as { + sk: string; + pk: string; + }; return { sk: hexToBigInt(parsedKeyPair.sk), pk: base64ToBytes(parsedKeyPair.pk), @@ -151,6 +187,68 @@ export function compareAndGetLatestToken( return jwtToken2; } +/** + * Determine whether a token should be proactively refreshed. + * + * When `iat` is provided: returns `true` when less than 10% of the token's + * lifetime remains (i.e. we are in the last 10% before expiry). + * When `iat` is omitted: returns `true` when the token is already expired. + * + * @param exp - Expiration time in seconds. + * @param iat - Optional issued-at time in seconds. + * @param now - Current time in seconds. Defaults to the current time. + * @returns Whether the token should be refreshed. + */ +export function isTokenNearExpiry( + exp: number, + iat?: number, + now = Date.now() / 1000, +): boolean { + if (iat === undefined) { + return now >= exp; + } + const lifetime = exp - iat; + // Guard against malformed tokens where iat >= exp (zero or negative lifetime). + // Fall back to exact-expiry check so bad tokens are always considered stale. + if (lifetime <= 0) { + return now >= exp; + } + const remaining = exp - now; + return remaining <= 0.1 * lifetime; +} + +/** + * Check whether an error indicates that a TOPRF auth token is invalid or + * expired. + * + * @param error - The error to check. + * @returns Whether the error is an auth token error. + */ +export function isAuthTokenError(error: unknown): boolean { + if (error instanceof TOPRFError) { + return ( + error.code === TOPRFErrorCode.AuthTokenExpired || + error.code === TOPRFErrorCode.InvalidAuthToken + ); + } + + return false; +} + +/** + * Check whether an error indicates that the TOPRF key chain limit was reached. + * + * @param error - The error to check. + * @returns Whether the error is a max key chain length error. + */ +export function isMaxKeyChainLengthError(error: unknown): boolean { + if (error instanceof TOPRFError) { + return error.code === TOPRFErrorCode.MaxKeyChainLengthExceeded; + } + + return false; +} + /** * Derive SecretType from EncAccountDataType. * @@ -179,8 +277,8 @@ export function getSecretTypeFromDataType( /** * Build non-sensitive type labels for secret metadata items. * - * @param secrets - The secret metadata items in fetch order. - * @returns One `SecretType` or `EncAccountDataType` per item. + * @param secrets - The SecretMetadata items in fetch order. + * @returns One SecretType or EncAccountDataType per item. */ export function getInvalidPrimarySecretDataTypeErrorData( secrets: SecretMetadata[], diff --git a/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts b/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts index c0aa2e622b3..ad4a5d3aab3 100644 --- a/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts +++ b/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts @@ -63,6 +63,14 @@ export function createCustomSeedlessOnboardingMessenger(): { parent: baseMessenger, }); + baseMessenger.delegate({ + messenger, + actions: [ + 'KeyringController:exportSeedPhrase', + 'AnalyticsController:trackEvent', + ], + }); + return { baseMessenger, messenger, diff --git a/packages/seedless-onboarding-controller/tests/__fixtures__/secret-metadata.ts b/packages/seedless-onboarding-controller/tests/__fixtures__/secret-metadata.ts new file mode 100644 index 00000000000..f6723afdb4f --- /dev/null +++ b/packages/seedless-onboarding-controller/tests/__fixtures__/secret-metadata.ts @@ -0,0 +1,135 @@ +import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; +import { EncAccountDataType } from '@metamask/toprf-secure-backup'; +import { stringToBytes } from '@metamask/utils'; + +import { SecretType } from '../../src/constants.js'; +import { SecretMetadata } from '../../src/SecretMetadata.js'; + +export type FetchedSecretDataItemMock = { + data: string; + timestamp: number; + itemId?: string; + type?: SecretType; + dataType?: EncAccountDataType; + version?: 'v1' | 'v2'; + createdAt?: string; + storageVersion?: 'v1' | 'v2'; +}; + +export const SRP1 = { + data: 'first srp', + timestamp: 100, + itemId: 'srp-1', +} satisfies FetchedSecretDataItemMock; + +export const SRP2 = { + data: 'second srp', + timestamp: 200, + itemId: 'srp-2', +} satisfies FetchedSecretDataItemMock; + +export const PrivateKey1 = { + data: 'private key', + timestamp: 50, + type: SecretType.PrivateKey, + itemId: 'private-key-1', +} satisfies FetchedSecretDataItemMock; + +export const PrivateKey2 = { + data: 'another private key', + timestamp: 200, + type: SecretType.PrivateKey, + itemId: 'private-key-2', +} satisfies FetchedSecretDataItemMock; + +export const ImportedSRP1 = { + ...SRP1, + dataType: EncAccountDataType.ImportedSrp, +} satisfies FetchedSecretDataItemMock; + +export const PrimarySRP2 = { + ...SRP2, + dataType: EncAccountDataType.PrimarySrp, +} satisfies FetchedSecretDataItemMock; + +export const ImportedPrivateKey1 = { + ...PrivateKey1, + dataType: EncAccountDataType.ImportedPrivateKey, +} satisfies FetchedSecretDataItemMock; + +export const ImportedPrivateKey2 = { + ...PrivateKey2, + dataType: EncAccountDataType.ImportedPrivateKey, +} satisfies FetchedSecretDataItemMock; + +/** + * Build a fetched secret-data item from a shared mock. + * + * @param mock - The secret metadata fields to encode. + * @returns A fetched secret-data item whose payload matches the mock. + */ +export function createFetchedSecretDataItem( + mock: FetchedSecretDataItemMock, +): FetchedSecretDataItem { + const { + data, + timestamp, + itemId, + type = SecretType.Mnemonic, + dataType, + version, + createdAt, + } = mock; + const metadata = new SecretMetadata(stringToBytes(data), { + timestamp, + type, + ...(dataType === undefined ? {} : { dataType }), + }); + + return { + data: metadata.toBytes(), + itemId, + version: version ?? (dataType === undefined ? 'v1' : 'v2'), + dataType, + ...(createdAt === undefined ? {} : { createdAt }), + } as FetchedSecretDataItem; +} + +/** + * Build secret metadata for a one-off test secret. + * + * @param data - The secret value. + * @param options - Metadata fields that describe the secret. + * @returns Secret metadata for the supplied fields. + */ +export function createSecretMetadata( + data: string, + options: Omit = {}, +): SecretMetadata { + return createSecretMetadataFromMock({ data, timestamp: Date.now() }, options); +} + +/** + * Build secret metadata from a shared mock. + * + * @param mock - The secret metadata fields to store. + * @param overrides - Fields that differ from the shared mock. + * @returns Secret metadata for the combined fields. + */ +export function createSecretMetadataFromMock( + mock: FetchedSecretDataItemMock, + overrides: Partial = {}, +): SecretMetadata { + const { data, timestamp, type, itemId, dataType, storageVersion } = { + ...mock, + ...overrides, + }; + + return new SecretMetadata(stringToBytes(data), { + timestamp, + ...(type === undefined ? {} : { type }), + ...(itemId === undefined ? {} : { itemId }), + ...(dataType === undefined ? {} : { dataType }), + ...(storageVersion === undefined ? {} : { storageVersion }), + }); +} diff --git a/packages/seedless-onboarding-controller/tsconfig.build.json b/packages/seedless-onboarding-controller/tsconfig.build.json index 3f56f4d0e82..5d7535cf51d 100644 --- a/packages/seedless-onboarding-controller/tsconfig.build.json +++ b/packages/seedless-onboarding-controller/tsconfig.build.json @@ -5,6 +5,9 @@ "rootDir": "./src" }, "references": [ + { + "path": "../analytics-controller/tsconfig.build.json" + }, { "path": "../base-controller/tsconfig.build.json" }, diff --git a/packages/seedless-onboarding-controller/tsconfig.json b/packages/seedless-onboarding-controller/tsconfig.json index e0f724d28d0..b3eb962ea63 100644 --- a/packages/seedless-onboarding-controller/tsconfig.json +++ b/packages/seedless-onboarding-controller/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../messenger" }, + { + "path": "../analytics-controller" + }, { "path": "../utils" } diff --git a/packages/seedless-onboarding-controller/tsconfig.lint.json b/packages/seedless-onboarding-controller/tsconfig.lint.json index 6df6d198c7c..537089a3029 100644 --- a/packages/seedless-onboarding-controller/tsconfig.lint.json +++ b/packages/seedless-onboarding-controller/tsconfig.lint.json @@ -14,6 +14,9 @@ { "path": "../keyring-controller/tsconfig.lint.json" }, + { + "path": "../analytics-controller/tsconfig.lint.json" + }, { "path": "../utils/tsconfig.lint.json" } diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 7376e6d54eb..aef3d6a09f8 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Delegate `AnalyticsController:trackEvent` and `KeyringController:exportSeedPhrase` from the wallet root messenger to `SeedlessOnboardingController`. ([#10568](https://github.com/MetaMask/core/pull/10568)) - **BREAKING:** Grant `SubscriptionController` access to `GeolocationController:getGeolocationData` ([#10674](https://github.com/MetaMask/core/pull/10674)) - The wallet does not construct `GeolocationController`. Clients must register that action on the Wallet messenger. - Stop delegating `ApprovalController:addRequest` to `SubscriptionDelegationService`. Client that supply their own root messenger no longer need to allow that action for the delegation service. ([#10666](https://github.com/MetaMask/core/pull/10666)) diff --git a/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.test.ts b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.test.ts index 8d208eb649e..d1fa3701aaa 100644 --- a/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.test.ts +++ b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.test.ts @@ -40,6 +40,21 @@ function getSeedlessOnboardingOptions(): SeedlessOnboardingControllerInstanceOpt } describe('seedlessOnboardingController', () => { + it('delegates the actions used by the controller', () => { + const rootMessenger = getRootMessenger(); + const delegateSpy = jest.spyOn(rootMessenger, 'delegate'); + + const messenger = seedlessOnboardingController.getMessenger(rootMessenger); + + expect(delegateSpy).toHaveBeenCalledWith({ + messenger, + actions: [ + 'AnalyticsController:trackEvent', + 'KeyringController:exportSeedPhrase', + ], + }); + }); + it('initializes a SeedlessOnboardingController with default state', () => { const messenger = seedlessOnboardingController.getMessenger(getRootMessenger()); diff --git a/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts index 1f07d40ffc6..f5b5de8e53c 100644 --- a/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts +++ b/packages/wallet/src/initialization/instances/seedless-onboarding-controller/seedless-onboarding-controller.ts @@ -19,9 +19,20 @@ export const seedlessOnboardingController: InitializationConfiguration< state, messenger, }), - getMessenger: (parent) => - new Messenger({ + getMessenger: (parent) => { + const messenger: SeedlessOnboardingControllerMessenger = new Messenger({ namespace: 'SeedlessOnboardingController', parent, - }), + }); + + parent.delegate({ + messenger, + actions: [ + 'AnalyticsController:trackEvent', + 'KeyringController:exportSeedPhrase', + ], + }); + + return messenger; + }, }; diff --git a/yarn.lock b/yarn.lock index 964a0c9b7c5..02197844361 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8523,6 +8523,7 @@ __metadata: dependencies: "@lavamoat/allow-scripts": "npm:^5.1.0" "@lavamoat/preinstall-always-fail": "npm:^3.0.0" + "@metamask/analytics-controller": "npm:^4.0.0" "@metamask/auth-network-utils": "npm:^0.3.0" "@metamask/auto-changelog": "npm:^6.2.1" "@metamask/base-controller": "npm:^10.0.0"