From 266dbbd75dd6529fdd4905b2ed518f2ee676c86f Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 29 Sep 2026 20:07:11 +0800 Subject: [PATCH 01/22] docs: added docs to identify social login users with incomplete metadata backup --- .../identify-incomplete-metadata-users.md | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md 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..0f1622d5e01 --- /dev/null +++ b/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md @@ -0,0 +1,202 @@ +# 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["Yes"] & n15["No"] + 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. From 63388dae926e04652190b35d09406c0a1e701e50 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 29 Sep 2026 22:19:49 +0800 Subject: [PATCH 02/22] chore: refactor pure-functions and utils --- .yarnrc.yml | 11 +- .../identify-incomplete-metadata-users.md | 2 +- .../package.json | 4 + .../src/SecretMetadata.ts | 2 +- .../src/SeedlessOnboardingController.ts | 278 ++---------------- .../src/errors.ts | 2 +- .../src/{ => utils}/assertions.test.ts | 28 +- .../src/{ => utils}/assertions.ts | 38 ++- .../src/utils/index.ts | 26 ++ .../src/utils/secret-data-utils.test.ts | 216 ++++++++++++++ .../src/utils/secret-data-utils.ts | 155 ++++++++++ .../src/{ => utils}/utils.test.ts | 114 ++++++- .../src/{ => utils}/utils.ts | 106 ++++++- 13 files changed, 708 insertions(+), 274 deletions(-) rename packages/seedless-onboarding-controller/src/{ => utils}/assertions.test.ts (87%) rename packages/seedless-onboarding-controller/src/{ => utils}/assertions.ts (74%) create mode 100644 packages/seedless-onboarding-controller/src/utils/index.ts create mode 100644 packages/seedless-onboarding-controller/src/utils/secret-data-utils.test.ts create mode 100644 packages/seedless-onboarding-controller/src/utils/secret-data-utils.ts rename packages/seedless-onboarding-controller/src/{ => utils}/utils.test.ts (71%) rename packages/seedless-onboarding-controller/src/{ => utils}/utils.ts (64%) diff --git a/.yarnrc.yml b/.yarnrc.yml index 4c0e4f39a5a..fd908cd6487 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,6 +1,3 @@ -# Allowlist for Git repositories that can be used as dependencies. We set it to -# an empty array to disallow all Git dependencies, as we don't use any and they -# can be a security risk. approvedGitRepositories: [] compressionLevel: mixed @@ -17,17 +14,15 @@ logFilters: nodeLinker: node-modules -# Configure the NPM minimal age gate to 3 days, meaning packages must be at -# least 3 days old to be installed. -npmMinimalAgeGate: 4320 # 3 days (in minutes) +npmMinimalAgeGate: 4320 -# Override the minimal age gate, allowing certain packages to be installed -# regardless of their publish age. npmPreapprovedPackages: - "@metamask/*" - "@metamask-previews/*" - "@lavamoat/*" +npmRegistryServer: "https://registry.npmjs.org/" + plugins: - path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs spec: "https://raw.githubusercontent.com/LavaMoat/LavaMoat/main/packages/yarn-plugin-allow-scripts/bundles/@yarnpkg/plugin-allow-scripts.js" diff --git a/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md b/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md index 0f1622d5e01..cf9fd6fb13e 100644 --- a/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md +++ b/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md @@ -33,7 +33,7 @@ flowchart TB n7 --> n10["Compare"] n1["Keyring:getPrimaryHdKeyring"] --> n10 n10 --> n11["Primary SRP Different"] - n11 --> n14["Yes"] & n15["No"] + n11 --> n14["NO"] & n15["YES"] n14 --> n12["Healthy User"] n15 --> n13["User with Issue"] n16 --> n13 diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index 5616b87a66a..6f584e54da4 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": { 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.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index ec28f2f53d6..ea4928d245d 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -22,8 +22,6 @@ import type { } from '@metamask/toprf-secure-backup'; import { ToprfSecureBackup, - TOPRFErrorCode, - TOPRFError, EncAccountDataType, } from '@metamask/toprf-secure-backup'; import { @@ -37,12 +35,6 @@ 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,7 +45,6 @@ import { Web3AuthNetwork, } from './constants.js'; import { - InvalidPrimarySecretDataTypeError, PasswordSyncError, RecoveryError, SeedlessOnboardingError, @@ -74,12 +65,25 @@ import type { ToprfKeyDeriver, } from './types.js'; import { + assertIsEncryptedKeyringEncryptionKeySet, + assertIsEncryptedSeedlessEncryptionKeySet, + assertIsPasswordOutdatedCacheValid, + assertIsSeedlessOnboardingUserAuthenticated, + assertIsValidPassword, compareAndGetLatestToken, decodeJWTToken, decodeNodeAuthToken, deserializeVaultData, + getDataTypeMigrationUpdates, + getNewSocialBackupsMetadata, + isAuthTokenError, + isMaxKeyChainLengthError, + isTokenNearExpiry, + parseVaultData, + parseAndSortSecretMetadata, serializeVaultData, -} from './utils.js'; +} from './utils/index.js'; +import type { SecretBackupData } from './utils/index.js'; const log = createModuleLogger(projectLogger, controllerName); @@ -835,50 +839,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({ @@ -1283,10 +1244,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 +1430,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 +1577,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 +1598,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 +1611,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 parseAndSortSecretMetadata(secretDataItems); } throw new Error(SeedlessOnboardingControllerErrorMessage.NoSecretDataFound); @@ -1827,7 +1762,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 +1877,7 @@ export class SeedlessOnboardingController< vaultEncryptionSalt = result.salt; } - const vaultData = this.#parseVaultData(decryptedVaultData); + const vaultData = parseVaultData(decryptedVaultData); return { vaultData, @@ -1969,10 +1904,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 +1928,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 +2176,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 +2580,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 +2608,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, @@ -2868,33 +2711,6 @@ export class SeedlessOnboardingController< } } -/** - * 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; -} - /** * Lock the given mutex before executing the given function, * and release it after the function is resolved or after an @@ -2916,35 +2732,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/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..c4a10390de1 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/utils/index.ts @@ -0,0 +1,26 @@ +export { + assertIsEncryptedKeyringEncryptionKeySet, + assertIsEncryptedSeedlessEncryptionKeySet, + assertIsPasswordOutdatedCacheValid, + assertIsSeedlessOnboardingUserAuthenticated, + assertIsValidPassword, +} from './assertions.js'; +export type { SecretBackupData } from './secret-data-utils.js'; +export { + getDataTypeMigrationUpdates, + getNewSocialBackupsMetadata, + parseAndSortSecretMetadata, +} 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..97e3f7cc794 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/utils/secret-data-utils.test.ts @@ -0,0 +1,216 @@ +import { keccak256AndHexify } from '@metamask/auth-network-utils'; +import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; +import { EncAccountDataType } from '@metamask/toprf-secure-backup'; +import { stringToBytes } from '@metamask/utils'; + +import { + SeedlessOnboardingControllerErrorMessage, + SecretType, +} from '../constants.js'; +import { InvalidPrimarySecretDataTypeError } from '../errors.js'; +import { SecretMetadata } from '../SecretMetadata.js'; +import { + getDataTypeMigrationUpdates, + getNewSocialBackupsMetadata, + parseAndSortSecretMetadata, +} from './secret-data-utils.js'; + +function createSecretMetadata( + data: string, + options: { + dataType?: EncAccountDataType; + itemId?: string; + storageVersion?: 'v1' | 'v2'; + timestamp?: number; + type?: SecretType; + } = {}, +): SecretMetadata { + return new SecretMetadata(stringToBytes(data), options); +} + +function createFetchedSecretDataItem({ + data, + timestamp, + itemId, + type = SecretType.Mnemonic, + dataType, + version, + createdAt, +}: { + data: string; + timestamp: number; + itemId?: string; + type?: SecretType; + dataType?: EncAccountDataType; + version?: 'v1' | 'v2'; + createdAt?: string; +}): FetchedSecretDataItem { + const metadata = new SecretMetadata(stringToBytes(data), { + timestamp, + type, + ...(dataType === undefined ? {} : { dataType }), + }); + + return { + data: metadata.toBytes(), + itemId, + version: version ?? (dataType === undefined ? 'v1' : 'v2'), + dataType, + createdAt, + } as FetchedSecretDataItem; +} + +describe('secret data utilities', () => { + describe('parseAndSortSecretMetadata', () => { + it('sorts legacy metadata by timestamp and promotes the first mnemonic', () => { + const results = parseAndSortSecretMetadata([ + createFetchedSecretDataItem({ + data: 'private key', + timestamp: 50, + itemId: 'private-key', + type: SecretType.PrivateKey, + }), + createFetchedSecretDataItem({ + data: 'second srp', + timestamp: 200, + itemId: 'srp-2', + }), + createFetchedSecretDataItem({ + data: 'first srp', + timestamp: 100, + itemId: 'srp-1', + }), + ]); + + expect(results.map((result) => result.data)).toStrictEqual([ + stringToBytes('first srp'), + stringToBytes('private key'), + stringToBytes('second srp'), + ]); + }); + + it('keeps an explicitly tagged primary mnemonic first', () => { + const results = parseAndSortSecretMetadata([ + createFetchedSecretDataItem({ + data: 'imported srp', + timestamp: 1, + itemId: 'imported-srp', + dataType: EncAccountDataType.ImportedSrp, + }), + createFetchedSecretDataItem({ + data: 'primary srp', + timestamp: 2, + itemId: 'primary-srp', + dataType: EncAccountDataType.PrimarySrp, + }), + ]); + + expect(results[0]?.data).toStrictEqual(stringToBytes('primary srp')); + expect(results[0]?.dataType).toBe(EncAccountDataType.PrimarySrp); + }); + + it('throws when no mnemonic can be used as the primary secret', () => { + expect(() => + parseAndSortSecretMetadata([ + createFetchedSecretDataItem({ + data: 'imported srp', + timestamp: 1, + itemId: 'imported-srp', + dataType: EncAccountDataType.ImportedSrp, + }), + ]), + ).toThrow( + new InvalidPrimarySecretDataTypeError([EncAccountDataType.ImportedSrp]), + ); + }); + }); + + describe('getDataTypeMigrationUpdates', () => { + it('classifies legacy items in their existing order', () => { + const updates = getDataTypeMigrationUpdates([ + createSecretMetadata('first srp', { + itemId: 'srp-1', + timestamp: 100, + }), + createSecretMetadata('second srp', { + itemId: 'srp-2', + timestamp: 200, + }), + createSecretMetadata('private key', { + itemId: 'private-key', + timestamp: 300, + type: SecretType.PrivateKey, + }), + ]); + + expect(updates).toStrictEqual([ + { itemId: 'srp-1', dataType: EncAccountDataType.PrimarySrp }, + { itemId: 'srp-2', dataType: EncAccountDataType.ImportedSrp }, + { + itemId: 'private-key', + dataType: EncAccountDataType.ImportedPrivateKey, + }, + ]); + }); + + it('preserves an existing primary and skips items outside the migration', () => { + const updates = getDataTypeMigrationUpdates([ + createSecretMetadata('primary srp', { + 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..1264092b997 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/utils/secret-data-utils.ts @@ -0,0 +1,155 @@ +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, sort, and validate secret metadata fetched from the metadata store. + * + * @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 parseAndSortSecretMetadata( + 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')); + + 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; +} + +/** + * 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 64% rename from packages/seedless-onboarding-controller/src/utils.ts rename to packages/seedless-onboarding-controller/src/utils/utils.ts index 21a08b1f2da..a3f191161c2 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. @@ -88,6 +96,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. * @@ -151,6 +183,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 +273,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[], From 8fe9262d1f62e63c08671c7085e1d370c54cf1c4 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 29 Sep 2026 22:20:59 +0800 Subject: [PATCH 03/22] chore: revert yarnrc --- .yarnrc.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index fd908cd6487..4c0e4f39a5a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,3 +1,6 @@ +# Allowlist for Git repositories that can be used as dependencies. We set it to +# an empty array to disallow all Git dependencies, as we don't use any and they +# can be a security risk. approvedGitRepositories: [] compressionLevel: mixed @@ -14,15 +17,17 @@ logFilters: nodeLinker: node-modules -npmMinimalAgeGate: 4320 +# Configure the NPM minimal age gate to 3 days, meaning packages must be at +# least 3 days old to be installed. +npmMinimalAgeGate: 4320 # 3 days (in minutes) +# Override the minimal age gate, allowing certain packages to be installed +# regardless of their publish age. npmPreapprovedPackages: - "@metamask/*" - "@metamask-previews/*" - "@lavamoat/*" -npmRegistryServer: "https://registry.npmjs.org/" - plugins: - path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs spec: "https://raw.githubusercontent.com/LavaMoat/LavaMoat/main/packages/yarn-plugin-allow-scripts/bundles/@yarnpkg/plugin-allow-scripts.js" From 7363fb44f59ab8d5df750b127d24858f4b75002c Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 29 Sep 2026 23:26:57 +0800 Subject: [PATCH 04/22] chore: more refactors --- .../src/SeedlessOnboardingController-types.ts | 127 ++++++++ .../src/SeedlessOnboardingController.ts | 305 ++---------------- .../src/state.ts | 174 ++++++++++ 3 files changed, 320 insertions(+), 286 deletions(-) create mode 100644 packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts create mode 100644 packages/seedless-onboarding-controller/src/state.ts 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..21010cb275b --- /dev/null +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts @@ -0,0 +1,127 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} 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 { 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 = 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 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.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index ea4928d245d..d956dc3256d 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, @@ -24,11 +18,7 @@ import { ToprfSecureBackup, 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'; @@ -51,7 +41,14 @@ import { } 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, @@ -85,6 +82,16 @@ import { } 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); const MESSENGER_EXPOSED_METHODS = [ @@ -117,280 +124,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, 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; +} From b9da21cb9675cd8b721972b8e03efbb13b3dd701 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 30 Sep 2026 01:04:50 +0800 Subject: [PATCH 05/22] feat: add AnalyticsControllerTrackEventAction and KeyringControllerExportSeedPhraseAction to the SeedlessController allowedActions list --- packages/seedless-onboarding-controller/package.json | 1 + .../src/SeedlessOnboardingController-types.ts | 4 +++- packages/seedless-onboarding-controller/tsconfig.build.json | 3 +++ packages/seedless-onboarding-controller/tsconfig.json | 3 +++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index 6f584e54da4..ca507b4eaa1 100644 --- a/packages/seedless-onboarding-controller/package.json +++ b/packages/seedless-onboarding-controller/package.json @@ -54,6 +54,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/analytics-controller": "^3.2.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/SeedlessOnboardingController-types.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts index 21010cb275b..8573e8da2b7 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts @@ -3,10 +3,12 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import type * as encryptionUtils from '@metamask/browser-passworder'; +import type { AnalyticsControllerTrackEventAction } from '@metamask/analytics-controller'; import type { DefaultEncryptionResult, EncryptionResultConstraint, Encryptor, + KeyringControllerExportSeedPhraseAction, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; @@ -32,7 +34,7 @@ export type SeedlessOnboardingControllerActions = | SeedlessOnboardingControllerGetStateAction | SeedlessOnboardingControllerMethodActions; -type AllowedActions = never; +type AllowedActions = AnalyticsControllerTrackEventAction | KeyringControllerExportSeedPhraseAction; // Events export type SeedlessOnboardingControllerStateChangeEvent = diff --git a/packages/seedless-onboarding-controller/tsconfig.build.json b/packages/seedless-onboarding-controller/tsconfig.build.json index 3f56f4d0e82..8658f120d60 100644 --- a/packages/seedless-onboarding-controller/tsconfig.build.json +++ b/packages/seedless-onboarding-controller/tsconfig.build.json @@ -14,6 +14,9 @@ { "path": "../messenger/tsconfig.build.json" }, + { + "path": "../analytics-controller/tsconfig.build.json" + }, { "path": "../utils/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" } From 9f908f79b4697f817e687d6ff2b56f428b775bb1 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 30 Sep 2026 01:13:07 +0800 Subject: [PATCH 06/22] feat: added analytics tracker for the Incorrect/Incomplete metadata backup --- .../src/utils/analytics.test.ts | 313 ++++++++++++++++++ .../src/utils/analytics.ts | 96 ++++++ 2 files changed, 409 insertions(+) create mode 100644 packages/seedless-onboarding-controller/src/utils/analytics.test.ts create mode 100644 packages/seedless-onboarding-controller/src/utils/analytics.ts 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..8d7ba90036c --- /dev/null +++ b/packages/seedless-onboarding-controller/src/utils/analytics.test.ts @@ -0,0 +1,313 @@ +import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; +import { EncAccountDataType } from '@metamask/toprf-secure-backup'; +import { stringToBytes } from '@metamask/utils'; + +import { SecretType } from '../constants.js'; +import { SecretMetadata } from '../SecretMetadata.js'; +import { + identifyIncompleteMetadataBackup, + SeedlessPrimarySrpMismatchEventName, + SeedlessPrimarySrpMissingEventName, +} from './analytics.js'; + +function createFetchedSecretDataItemMock({ + data, + timestamp, + type = SecretType.Mnemonic, + itemId = `${type}-${timestamp}`, + dataType, + version, +}: { + data: string; + timestamp: number; + type?: SecretType; + itemId?: string; + dataType?: EncAccountDataType; + version?: 'v1' | 'v2'; +}): FetchedSecretDataItem { + const metadata = new SecretMetadata(stringToBytes(data), { + timestamp, + type, + ...(dataType === undefined ? {} : { dataType }), + }); + + return { + data: metadata.toBytes(), + itemId, + version: version ?? (dataType === undefined ? 'v1' : 'v2'), + dataType, + } as FetchedSecretDataItem; +} + +function expectEvent( + trackEvent: jest.Mock, + name: string, +): void { + expect(trackEvent).toHaveBeenCalledTimes(1); + expect(trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ name }), + ); +} + +describe('identifyIncompleteMetadataBackup', () => { + it('does not track an event when the legacy primary matches the local primary', async () => { + const fetchAllSecretDataFn = jest.fn().mockResolvedValue([ + createFetchedSecretDataItemMock({ + data: 'private key', + timestamp: 50, + type: SecretType.PrivateKey, + }), + createFetchedSecretDataItemMock({ + data: 'primary srp', + timestamp: 100, + }), + createFetchedSecretDataItemMock({ + data: 'imported srp', + timestamp: 200, + }), + ]); + const getPrimaryKeyringSeedPhraseFn = jest + .fn() + .mockResolvedValue(stringToBytes('primary srp')); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await identifyIncompleteMetadataBackup({ + 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([ + createFetchedSecretDataItemMock({ + data: 'imported srp', + timestamp: 100, + }), + createFetchedSecretDataItemMock({ + data: 'another imported srp', + timestamp: 200, + }), + ]); + const getPrimaryKeyringSeedPhraseFn = jest + .fn() + .mockResolvedValue(stringToBytes('actual primary srp')); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await identifyIncompleteMetadataBackup({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn, + trackEvent, + logFn, + }); + + expectEvent(trackEvent, SeedlessPrimarySrpMismatchEventName); + expect(logFn).not.toHaveBeenCalled(); + }); + + it('uses the explicitly tagged V2 primary when it matches the local primary', async () => { + const fetchAllSecretDataFn = jest.fn().mockResolvedValue([ + createFetchedSecretDataItemMock({ + data: 'imported srp', + timestamp: 100, + dataType: EncAccountDataType.ImportedSrp, + }), + createFetchedSecretDataItemMock({ + data: 'primary srp', + timestamp: 200, + dataType: EncAccountDataType.PrimarySrp, + }), + ]); + const getPrimaryKeyringSeedPhraseFn = jest + .fn() + .mockResolvedValue(stringToBytes('primary srp')); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await identifyIncompleteMetadataBackup({ + 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([ + createFetchedSecretDataItemMock({ + data: 'imported srp', + timestamp: 100, + dataType: EncAccountDataType.ImportedSrp, + }), + createFetchedSecretDataItemMock({ + data: 'incorrect primary srp', + timestamp: 200, + dataType: EncAccountDataType.PrimarySrp, + }), + ]); + const getPrimaryKeyringSeedPhraseFn = jest + .fn() + .mockResolvedValue(stringToBytes('actual primary srp')); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await identifyIncompleteMetadataBackup({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn, + trackEvent, + logFn, + }); + + expectEvent(trackEvent, SeedlessPrimarySrpMismatchEventName); + expect(logFn).not.toHaveBeenCalled(); + }); + + it.each([ + { + description: 'an empty response', + secretDataItems: [], + }, + { + description: 'metadata containing only imported SRPs', + secretDataItems: [ + createFetchedSecretDataItemMock({ + data: 'imported srp', + timestamp: 100, + dataType: EncAccountDataType.ImportedSrp, + }), + ], + }, + { + description: 'metadata containing only private keys', + secretDataItems: [ + createFetchedSecretDataItemMock({ + data: 'private key', + timestamp: 100, + type: SecretType.PrivateKey, + }), + ], + }, + ])('tracks a missing-primary event for $description', async ({ + secretDataItems, + }) => { + const fetchAllSecretDataFn = jest.fn().mockResolvedValue(secretDataItems); + const getPrimaryKeyringSeedPhraseFn = jest.fn(); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await identifyIncompleteMetadataBackup({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn, + trackEvent, + logFn, + }); + + expectEvent(trackEvent, SeedlessPrimarySrpMissingEventName); + 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 identifyIncompleteMetadataBackup({ + 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 identifyIncompleteMetadataBackup({ + 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([ + createFetchedSecretDataItemMock({ + data: 'primary srp', + timestamp: 100, + }), + ]); + const getPrimaryKeyringSeedPhraseFn = jest.fn().mockRejectedValue(error); + const trackEvent = jest.fn(); + const logFn = jest.fn(); + + await identifyIncompleteMetadataBackup({ + 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 expect( + identifyIncompleteMetadataBackup({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn: jest.fn(), + trackEvent, + logFn, + }), + ).resolves.toBeUndefined(); + + expect(trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ name: SeedlessPrimarySrpMissingEventName }), + ); + expect(logFn).toHaveBeenCalledWith( + 'Error tracking incomplete metadata backup event', + trackError, + ); + }); +}); 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..5eb0c9eda85 --- /dev/null +++ b/packages/seedless-onboarding-controller/src/utils/analytics.ts @@ -0,0 +1,96 @@ +import type { AnalyticsContext, AnalyticsTrackingEvent } from '@metamask/analytics-controller'; +import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; +import { areUint8ArraysEqual } from '@metamask/utils'; + +import { InvalidPrimarySecretDataTypeError } from '../errors.js'; +import { parseAndSortSecretMetadata } from './secret-data-utils.js'; + +export const SeedlessPrimarySrpMissingEventName = 'Seedless Onboarding Primary SRP Missing'; +export const SeedlessPrimarySrpMismatchEventName = 'Seedless Onboarding Primary SRP Mismatch'; + +/** + * 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 identifyIncompleteMetadataBackup({ + 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); + } + }; + + try { + const secretDataItems = await fetchAllSecretDataFn(); + const [primarySecretMetadata] = parseAndSortSecretMetadata(secretDataItems); + const localPrimarySrp = await getPrimaryKeyringSeedPhraseFn(); + + if (areUint8ArraysEqual(primarySecretMetadata.data, localPrimarySrp)) { + return; + } + + await trackEventSafely( + { + name: SeedlessPrimarySrpMismatchEventName, + properties: {}, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: false, + }, + ); + } catch (error) { + if (error instanceof InvalidPrimarySecretDataTypeError) { + await trackEventSafely( + { + name: SeedlessPrimarySrpMissingEventName, + properties: { + error: error.message, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + return; + } + + logFn('Error identifying incomplete metadata backup', error); + } +} From 4fb7bf0d85418bd6cea9fc4987427515a25d1ea4 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 30 Sep 2026 01:42:30 +0800 Subject: [PATCH 07/22] feat: run identifyIncompleteMetadataBackup check after Seedless unlock --- .../src/SeedlessOnboardingController.test.ts | 87 +++++++++++++++++-- .../src/SeedlessOnboardingController.ts | 16 ++++ .../src/utils/index.ts | 1 + .../tests/__fixtures__/mockMessenger.ts | 8 ++ 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index ca4124561e1..d498c6c59dc 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -32,6 +32,7 @@ import type { KeyPair, RecoverEncryptionKeyResult, ToprfSecureBackup, + FetchedSecretDataItem, } from '@metamask/toprf-secure-backup'; import { base64ToBytes, @@ -82,6 +83,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 +3135,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 +3684,73 @@ 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', + } as FetchedSecretDataItem, + ]); + + 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( @@ -6808,11 +6888,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 d956dc3256d..07c50697b2a 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -73,6 +73,7 @@ import { deserializeVaultData, getDataTypeMigrationUpdates, getNewSocialBackupsMetadata, + identifyIncompleteMetadataBackup, isAuthTokenError, isMaxKeyChainLengthError, isTokenNearExpiry, @@ -837,6 +838,21 @@ export class SeedlessOnboardingController< } this.#setUnlocked(); + + void identifyIncompleteMetadataBackup({ + 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, + }); }); } diff --git a/packages/seedless-onboarding-controller/src/utils/index.ts b/packages/seedless-onboarding-controller/src/utils/index.ts index c4a10390de1..a0a9dad67dd 100644 --- a/packages/seedless-onboarding-controller/src/utils/index.ts +++ b/packages/seedless-onboarding-controller/src/utils/index.ts @@ -5,6 +5,7 @@ export { assertIsSeedlessOnboardingUserAuthenticated, assertIsValidPassword, } from './assertions.js'; +export { identifyIncompleteMetadataBackup } from './analytics.js'; export type { SecretBackupData } from './secret-data-utils.js'; export { getDataTypeMigrationUpdates, 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, From bfee0385188916b84c8ffbe42a72fc35d876942b Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 30 Sep 2026 01:49:27 +0800 Subject: [PATCH 08/22] feat: update seedless-onboarding-controller init in wallet package --- .../seedless-onboarding-controller.test.ts | 15 +++++++++++++++ .../seedless-onboarding-controller.ts | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) 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; + }, }; From f575b7687d732f39e7735c2a2cc532832e0f448a Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 30 Sep 2026 01:54:04 +0800 Subject: [PATCH 09/22] feat: update CHANGELOGs --- packages/seedless-onboarding-controller/CHANGELOG.md | 5 +++++ packages/wallet/CHANGELOG.md | 1 + 2 files changed, 6 insertions(+) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 81ee7ebffd0..81d1223e24c 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Require `AnalyticsController:trackEvent` and `KeyringController:exportSeedPhrase` actions in `SeedlessOnboardingControllerMessenger`. ([#10568](https://github.com/MetaMask/core/pull/10568)) + - The `SeedlessOnboardingControllerMessenger` must allow and delegate these actions. + ## [11.0.1] ### Changed diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index ed452590f47..37a669b1530 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)) - Grant `SubscriptionDelegationService` access to the additional messenger actions required by `SubscriptionDelegationService:startSubscriptionWithDelegation` ([#10339](https://github.com/MetaMask/core/pull/10339)) - `ApprovalController:addRequest`, `MoneyAccountUpgradeController:forceUpgradeAccount`, `SubscriptionController:getState`, `SubscriptionController:getSubscriptions`, and `SubscriptionController:startSubscriptionWithCrypto` - Hosts that supply their own root messenger must allow these actions and register `MoneyAccountUpgradeController` before calling `SubscriptionDelegationService:startSubscriptionWithDelegation`. From 8a07674334924312eed13aa5179b864a3c0a383e Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 30 Sep 2026 02:10:51 +0800 Subject: [PATCH 10/22] fix: fixed lints --- README.md | 1 + .../identify-incomplete-metadata-users.md | 12 ++-- .../src/SeedlessOnboardingController-types.ts | 6 +- .../src/SeedlessOnboardingController.test.ts | 26 ++++---- .../src/SeedlessOnboardingController.ts | 5 +- .../src/utils/analytics.test.ts | 60 +++++++++---------- .../src/utils/analytics.ts | 45 +++++++------- .../src/utils/secret-data-utils.test.ts | 5 +- .../src/utils/utils.ts | 12 ++-- 9 files changed, 87 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 511d978ea24..592677a9963 100644 --- a/README.md +++ b/README.md @@ -648,6 +648,7 @@ linkStyle default opacity:0.5 sample_controllers --> network_controller; sample_controllers --> utils; sample_controllers --> controller_utils; + seedless_onboarding_controller --> analytics_controller; seedless_onboarding_controller --> base_controller; seedless_onboarding_controller --> keyring_controller; seedless_onboarding_controller --> messenger; diff --git a/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md b/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md index cf9fd6fb13e..027dfd15aa9 100644 --- a/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md +++ b/packages/seedless-onboarding-controller/docs/identify-incomplete-metadata-users.md @@ -6,9 +6,10 @@ We have found a production bug where new social login users can ran into the TOP 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)). +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)) @@ -68,24 +69,27 @@ flowchart TB 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. +> 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 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. @@ -135,8 +139,8 @@ flowchart TB 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). diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts index 8573e8da2b7..27b89e6c07f 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController-types.ts @@ -1,9 +1,9 @@ +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 { AnalyticsControllerTrackEventAction } from '@metamask/analytics-controller'; import type { DefaultEncryptionResult, EncryptionResultConstraint, @@ -34,7 +34,9 @@ export type SeedlessOnboardingControllerActions = | SeedlessOnboardingControllerGetStateAction | SeedlessOnboardingControllerMethodActions; -type AllowedActions = AnalyticsControllerTrackEventAction | KeyringControllerExportSeedPhraseAction; +type AllowedActions = + | AnalyticsControllerTrackEventAction + | KeyringControllerExportSeedPhraseAction; // Events export type SeedlessOnboardingControllerStateChangeEvent = diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index d498c6c59dc..07c057ca342 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -32,7 +32,6 @@ import type { KeyPair, RecoverEncryptionKeyResult, ToprfSecureBackup, - FetchedSecretDataItem, } from '@metamask/toprf-secure-backup'; import { base64ToBytes, @@ -3137,16 +3136,16 @@ describe('SeedlessOnboardingController', () => { }, 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', - ); - }); + .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, @@ -3686,8 +3685,7 @@ describe('SeedlessOnboardingController', () => { it('should identify a primary SRP mismatch after unlocking', async () => { const mockToprfEncryptor = createMockToprfEncryptor(); - const mockEncryptionKey = - mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const mockEncryptionKey = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); const mockPasswordEncryptionKey = mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); const mockAuthKeyPair = @@ -3729,7 +3727,7 @@ describe('SeedlessOnboardingController', () => { }).toBytes(), itemId: 'remote-primary-srp', version: 'v1', - } as FetchedSecretDataItem, + }, ]); await baseMessenger.call( diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 07c50697b2a..5097c95087f 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -59,7 +59,6 @@ import type { RenewRefreshToken, VaultData, DeserializedVaultData, - ToprfKeyDeriver, } from './types.js'; import { assertIsEncryptedKeyringEncryptionKeySet, @@ -839,7 +838,9 @@ export class SeedlessOnboardingController< this.#setUnlocked(); - void identifyIncompleteMetadataBackup({ + // Identify incomplete metadata backup after successful unlock + // Run this async to avoid blocking the main thread + identifyIncompleteMetadataBackup({ fetchAllSecretDataFn: () => this.toprfClient.fetchAllSecretDataItems({ decKey: deserializedVaultData.toprfEncryptionKey, diff --git a/packages/seedless-onboarding-controller/src/utils/analytics.test.ts b/packages/seedless-onboarding-controller/src/utils/analytics.test.ts index 8d7ba90036c..55445f421be 100644 --- a/packages/seedless-onboarding-controller/src/utils/analytics.test.ts +++ b/packages/seedless-onboarding-controller/src/utils/analytics.test.ts @@ -36,17 +36,12 @@ function createFetchedSecretDataItemMock({ itemId, version: version ?? (dataType === undefined ? 'v1' : 'v2'), dataType, - } as FetchedSecretDataItem; + }; } -function expectEvent( - trackEvent: jest.Mock, - name: string, -): void { +function expectEvent(trackEvent: jest.Mock, name: string): void { expect(trackEvent).toHaveBeenCalledTimes(1); - expect(trackEvent).toHaveBeenCalledWith( - expect.objectContaining({ name }), - ); + expect(trackEvent).toHaveBeenCalledWith(expect.objectContaining({ name })); } describe('identifyIncompleteMetadataBackup', () => { @@ -197,25 +192,26 @@ describe('identifyIncompleteMetadataBackup', () => { }), ], }, - ])('tracks a missing-primary event for $description', async ({ - secretDataItems, - }) => { - const fetchAllSecretDataFn = jest.fn().mockResolvedValue(secretDataItems); - const getPrimaryKeyringSeedPhraseFn = jest.fn(); - const trackEvent = jest.fn(); - const logFn = jest.fn(); + ])( + 'tracks a missing-primary event for $description', + async ({ secretDataItems }) => { + const fetchAllSecretDataFn = jest.fn().mockResolvedValue(secretDataItems); + const getPrimaryKeyringSeedPhraseFn = jest.fn(); + const trackEvent = jest.fn(); + const logFn = jest.fn(); - await identifyIncompleteMetadataBackup({ - fetchAllSecretDataFn, - getPrimaryKeyringSeedPhraseFn, - trackEvent, - logFn, - }); + await identifyIncompleteMetadataBackup({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn, + trackEvent, + logFn, + }); - expectEvent(trackEvent, SeedlessPrimarySrpMissingEventName); - expect(getPrimaryKeyringSeedPhraseFn).not.toHaveBeenCalled(); - expect(logFn).not.toHaveBeenCalled(); - }); + expectEvent(trackEvent, SeedlessPrimarySrpMissingEventName); + 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'); @@ -293,14 +289,12 @@ describe('identifyIncompleteMetadataBackup', () => { const trackEvent = jest.fn().mockRejectedValue(trackError); const logFn = jest.fn(); - await expect( - identifyIncompleteMetadataBackup({ - fetchAllSecretDataFn, - getPrimaryKeyringSeedPhraseFn: jest.fn(), - trackEvent, - logFn, - }), - ).resolves.toBeUndefined(); + await identifyIncompleteMetadataBackup({ + fetchAllSecretDataFn, + getPrimaryKeyringSeedPhraseFn: jest.fn(), + trackEvent, + logFn, + }); expect(trackEvent).toHaveBeenCalledWith( expect.objectContaining({ name: SeedlessPrimarySrpMissingEventName }), diff --git a/packages/seedless-onboarding-controller/src/utils/analytics.ts b/packages/seedless-onboarding-controller/src/utils/analytics.ts index 5eb0c9eda85..55d7c0079b5 100644 --- a/packages/seedless-onboarding-controller/src/utils/analytics.ts +++ b/packages/seedless-onboarding-controller/src/utils/analytics.ts @@ -1,12 +1,17 @@ -import type { AnalyticsContext, AnalyticsTrackingEvent } from '@metamask/analytics-controller'; +import type { + AnalyticsContext, + AnalyticsTrackingEvent, +} from '@metamask/analytics-controller'; import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; import { areUint8ArraysEqual } from '@metamask/utils'; import { InvalidPrimarySecretDataTypeError } from '../errors.js'; import { parseAndSortSecretMetadata } from './secret-data-utils.js'; -export const SeedlessPrimarySrpMissingEventName = 'Seedless Onboarding Primary SRP Missing'; -export const SeedlessPrimarySrpMismatchEventName = 'Seedless Onboarding Primary SRP Mismatch'; +export const SeedlessPrimarySrpMissingEventName = + 'Seedless Onboarding Primary SRP Missing'; +export const SeedlessPrimarySrpMismatchEventName = + 'Seedless Onboarding Primary SRP Mismatch'; /** * Identify whether the local primary SRP is missing from or differs from the @@ -65,28 +70,24 @@ export async function identifyIncompleteMetadataBackup({ return; } - await trackEventSafely( - { - name: SeedlessPrimarySrpMismatchEventName, - properties: {}, - sensitiveProperties: {}, - saveDataRecording: false, - hasProperties: false, - }, - ); + await trackEventSafely({ + name: SeedlessPrimarySrpMismatchEventName, + properties: {}, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: false, + }); } catch (error) { if (error instanceof InvalidPrimarySecretDataTypeError) { - await trackEventSafely( - { - name: SeedlessPrimarySrpMissingEventName, - properties: { - error: error.message, - }, - sensitiveProperties: {}, - saveDataRecording: false, - hasProperties: true, + await trackEventSafely({ + name: SeedlessPrimarySrpMissingEventName, + properties: { + error: error.message, }, - ); + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); return; } 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 index 97e3f7cc794..f295e464d7d 100644 --- a/packages/seedless-onboarding-controller/src/utils/secret-data-utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils/secret-data-utils.test.ts @@ -3,10 +3,7 @@ import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; import { EncAccountDataType } from '@metamask/toprf-secure-backup'; import { stringToBytes } from '@metamask/utils'; -import { - SeedlessOnboardingControllerErrorMessage, - SecretType, -} from '../constants.js'; +import { SecretType } from '../constants.js'; import { InvalidPrimarySecretDataTypeError } from '../errors.js'; import { SecretMetadata } from '../SecretMetadata.js'; import { diff --git a/packages/seedless-onboarding-controller/src/utils/utils.ts b/packages/seedless-onboarding-controller/src/utils/utils.ts index a3f191161c2..967b0dcea26 100644 --- a/packages/seedless-onboarding-controller/src/utils/utils.ts +++ b/packages/seedless-onboarding-controller/src/utils/utils.ts @@ -33,7 +33,7 @@ import { assertIsValidVaultData } from './assertions.js'; * @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; } /** @@ -53,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; } /** @@ -142,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), From 455ddceb4ada15e78feee1e051bb9ef9d3269816 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 30 Sep 2026 02:18:25 +0800 Subject: [PATCH 11/22] fix: fixed oxlint --- oxlint-suppressions.json | 5 ++++- packages/seedless-onboarding-controller/tsconfig.build.json | 6 +++--- packages/seedless-onboarding-controller/tsconfig.lint.json | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 077d811402d..44318440a73 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -5590,6 +5590,9 @@ } }, "packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts": { + "typescript/no-floating-promises": { + "count": 1 + }, "typescript/no-unsafe-argument": { "count": 1 }, @@ -7811,4 +7814,4 @@ "count": 1 } } -} +} \ No newline at end of file diff --git a/packages/seedless-onboarding-controller/tsconfig.build.json b/packages/seedless-onboarding-controller/tsconfig.build.json index 8658f120d60..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" }, @@ -14,9 +17,6 @@ { "path": "../messenger/tsconfig.build.json" }, - { - "path": "../analytics-controller/tsconfig.build.json" - }, { "path": "../utils/tsconfig.build.json" } 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" } From 725e215f50fb063bf883515e15130b57b5837215 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 30 Sep 2026 02:19:26 +0800 Subject: [PATCH 12/22] fix: fixed CHANGELOG --- packages/wallet/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 973382d18b9..581775ceeb6 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,11 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Delegate `AnalyticsController:trackEvent` and `KeyringController:exportSeedPhrase` from the wallet root messenger to `SeedlessOnboardingController`. ([#10568](https://github.com/MetaMask/core/pull/10568)) + ## [15.1.0] ### Changed -- **BREAKING:** Delegate `AnalyticsController:trackEvent` and `KeyringController:exportSeedPhrase` from the wallet root messenger to `SeedlessOnboardingController`. ([#10568](https://github.com/MetaMask/core/pull/10568)) - Grant `SubscriptionDelegationService` access to the additional messenger actions required by `SubscriptionDelegationService:startSubscriptionWithDelegation` ([#10339](https://github.com/MetaMask/core/pull/10339)) - `ApprovalController:addRequest`, `MoneyAccountUpgradeController:forceUpgradeAccount`, `SubscriptionController:getState`, `SubscriptionController:getSubscriptions`, and `SubscriptionController:startSubscriptionWithCrypto` - Hosts that supply their own root messenger must allow these actions and register `MoneyAccountUpgradeController` before calling `SubscriptionDelegationService:startSubscriptionWithDelegation`. From 635748c9ff3769c55ef5e9cb73a1ab52e3b10007 Mon Sep 17 00:00:00 2001 From: lwin Date: Wed, 30 Sep 2026 02:23:21 +0800 Subject: [PATCH 13/22] fix: fix lint AGAIN --- oxlint-suppressions.json | 2 +- yarn.lock | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 44318440a73..5b23e2db8ed 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -7814,4 +7814,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 0f12cfce7a5..9cee17ce155 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8743,6 +8743,7 @@ __metadata: dependencies: "@lavamoat/allow-scripts": "npm:^5.1.0" "@lavamoat/preinstall-always-fail": "npm:^3.0.0" + "@metamask/analytics-controller": "npm:^3.2.0" "@metamask/auth-network-utils": "npm:^0.3.0" "@metamask/auto-changelog": "npm:^6.2.1" "@metamask/base-controller": "npm:^10.0.0" From dc77a1e058c3f61c5da5b28dc57a041d752d0fb5 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 6 Oct 2026 01:22:44 +0800 Subject: [PATCH 14/22] feat: analytics properties --- .../src/SeedlessOnboardingController.ts | 4 +- .../src/utils/analytics.test.ts | 316 ++++++++++++------ .../src/utils/analytics.ts | 94 +++++- .../src/utils/index.ts | 2 +- .../src/utils/secret-data-utils.test.ts | 166 ++++----- .../src/utils/secret-data-utils.ts | 67 +++- .../tests/__fixtures__/secret-metadata.ts | 132 ++++++++ yarn.lock | 16 +- 8 files changed, 561 insertions(+), 236 deletions(-) create mode 100644 packages/seedless-onboarding-controller/tests/__fixtures__/secret-metadata.ts diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 5097c95087f..3f6ee793ea3 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -77,7 +77,7 @@ import { isMaxKeyChainLengthError, isTokenNearExpiry, parseVaultData, - parseAndSortSecretMetadata, + parseAndValidateSecretMetadataBackup, serializeVaultData, } from './utils/index.js'; import type { SecretBackupData } from './utils/index.js'; @@ -1361,7 +1361,7 @@ export class SeedlessOnboardingController< // user must have at least one secret data if (secretDataItems?.length > 0) { - return parseAndSortSecretMetadata(secretDataItems); + return parseAndValidateSecretMetadataBackup(secretDataItems); } throw new Error(SeedlessOnboardingControllerErrorMessage.NoSecretDataFound); diff --git a/packages/seedless-onboarding-controller/src/utils/analytics.test.ts b/packages/seedless-onboarding-controller/src/utils/analytics.test.ts index 55445f421be..d2ac51fd127 100644 --- a/packages/seedless-onboarding-controller/src/utils/analytics.test.ts +++ b/packages/seedless-onboarding-controller/src/utils/analytics.test.ts @@ -1,69 +1,61 @@ import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; -import { EncAccountDataType } 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 { SecretMetadata } from '../SecretMetadata.js'; +import type { IncompleteMetadataBackupEventProperties } from './analytics.js'; import { + getIncompleteMetadataBackupEventProperties, identifyIncompleteMetadataBackup, SeedlessPrimarySrpMismatchEventName, SeedlessPrimarySrpMissingEventName, } from './analytics.js'; +import { parseSecretMetadata } from './secret-data-utils.js'; -function createFetchedSecretDataItemMock({ - data, - timestamp, - type = SecretType.Mnemonic, - itemId = `${type}-${timestamp}`, - dataType, - version, -}: { - data: string; - timestamp: number; - type?: SecretType; - itemId?: string; - dataType?: EncAccountDataType; - version?: 'v1' | 'v2'; -}): FetchedSecretDataItem { - const metadata = new SecretMetadata(stringToBytes(data), { - timestamp, - type, - ...(dataType === undefined ? {} : { dataType }), - }); - - return { - data: metadata.toBytes(), - itemId, - version: version ?? (dataType === undefined ? 'v1' : 'v2'), - dataType, - }; -} - -function expectEvent(trackEvent: jest.Mock, name: string): void { +function expectTrackedEvent( + trackEvent: jest.Mock, + { + name, + properties, + hasProperties, + }: { + name: string; + properties: IncompleteMetadataBackupEventProperties; + hasProperties: boolean; + }, +): void { expect(trackEvent).toHaveBeenCalledTimes(1); - expect(trackEvent).toHaveBeenCalledWith(expect.objectContaining({ name })); + expect(trackEvent).toHaveBeenCalledWith({ + name, + properties, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties, + }); } describe('identifyIncompleteMetadataBackup', () => { it('does not track an event when the legacy primary matches the local primary', async () => { - const fetchAllSecretDataFn = jest.fn().mockResolvedValue([ - createFetchedSecretDataItemMock({ - data: 'private key', - timestamp: 50, - type: SecretType.PrivateKey, - }), - createFetchedSecretDataItemMock({ - data: 'primary srp', - timestamp: 100, - }), - createFetchedSecretDataItemMock({ - data: 'imported srp', - timestamp: 200, - }), - ]); + const fetchAllSecretDataFn = jest + .fn() + .mockResolvedValue([ + createFetchedSecretDataItem(PrivateKey1), + createFetchedSecretDataItem(SRP1), + createFetchedSecretDataItem(SRP2), + ]); const getPrimaryKeyringSeedPhraseFn = jest .fn() - .mockResolvedValue(stringToBytes('primary srp')); + .mockResolvedValue(stringToBytes(SRP1.data)); const trackEvent = jest.fn(); const logFn = jest.fn(); @@ -80,16 +72,13 @@ describe('identifyIncompleteMetadataBackup', () => { }); it('tracks a mismatch when the earliest legacy SRP is not the local primary', async () => { - const fetchAllSecretDataFn = jest.fn().mockResolvedValue([ - createFetchedSecretDataItemMock({ - data: 'imported srp', - timestamp: 100, - }), - createFetchedSecretDataItemMock({ - data: 'another imported srp', - timestamp: 200, - }), - ]); + const fetchAllSecretDataFn = jest + .fn() + .mockResolvedValue([ + createFetchedSecretDataItem(PrivateKey1), + createFetchedSecretDataItem(SRP1), + createFetchedSecretDataItem(SRP2), + ]); const getPrimaryKeyringSeedPhraseFn = jest .fn() .mockResolvedValue(stringToBytes('actual primary srp')); @@ -103,26 +92,28 @@ describe('identifyIncompleteMetadataBackup', () => { logFn, }); - expectEvent(trackEvent, SeedlessPrimarySrpMismatchEventName); + expectTrackedEvent(trackEvent, { + name: SeedlessPrimarySrpMismatchEventName, + properties: { + metadata_schema_version: 'v1', + number_of_imported_wallets: 1, + number_of_imported_accounts: 1, + }, + hasProperties: false, + }); expect(logFn).not.toHaveBeenCalled(); }); it('uses the explicitly tagged V2 primary when it matches the local primary', async () => { - const fetchAllSecretDataFn = jest.fn().mockResolvedValue([ - createFetchedSecretDataItemMock({ - data: 'imported srp', - timestamp: 100, - dataType: EncAccountDataType.ImportedSrp, - }), - createFetchedSecretDataItemMock({ - data: 'primary srp', - timestamp: 200, - dataType: EncAccountDataType.PrimarySrp, - }), - ]); + const fetchAllSecretDataFn = jest + .fn() + .mockResolvedValue([ + createFetchedSecretDataItem(ImportedSRP1), + createFetchedSecretDataItem(PrimarySRP2), + ]); const getPrimaryKeyringSeedPhraseFn = jest .fn() - .mockResolvedValue(stringToBytes('primary srp')); + .mockResolvedValue(stringToBytes(PrimarySRP2.data)); const trackEvent = jest.fn(); const logFn = jest.fn(); @@ -138,18 +129,12 @@ describe('identifyIncompleteMetadataBackup', () => { }); it('tracks a mismatch when the explicitly tagged V2 primary differs', async () => { - const fetchAllSecretDataFn = jest.fn().mockResolvedValue([ - createFetchedSecretDataItemMock({ - data: 'imported srp', - timestamp: 100, - dataType: EncAccountDataType.ImportedSrp, - }), - createFetchedSecretDataItemMock({ - data: 'incorrect primary srp', - timestamp: 200, - dataType: EncAccountDataType.PrimarySrp, - }), - ]); + const fetchAllSecretDataFn = jest + .fn() + .mockResolvedValue([ + createFetchedSecretDataItem(ImportedSRP1), + createFetchedSecretDataItem(PrimarySRP2), + ]); const getPrimaryKeyringSeedPhraseFn = jest .fn() .mockResolvedValue(stringToBytes('actual primary srp')); @@ -163,7 +148,15 @@ describe('identifyIncompleteMetadataBackup', () => { logFn, }); - expectEvent(trackEvent, SeedlessPrimarySrpMismatchEventName); + expectTrackedEvent(trackEvent, { + name: SeedlessPrimarySrpMismatchEventName, + properties: { + metadata_schema_version: 'v2', + number_of_imported_wallets: 1, + number_of_imported_accounts: 0, + }, + hasProperties: false, + }); expect(logFn).not.toHaveBeenCalled(); }); @@ -171,30 +164,33 @@ describe('identifyIncompleteMetadataBackup', () => { { 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: [ - createFetchedSecretDataItemMock({ - data: 'imported srp', - timestamp: 100, - dataType: EncAccountDataType.ImportedSrp, - }), - ], + 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: [ - createFetchedSecretDataItemMock({ - data: 'private key', - timestamp: 100, - type: SecretType.PrivateKey, - }), - ], + 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 }) => { + async ({ secretDataItems, properties }) => { const fetchAllSecretDataFn = jest.fn().mockResolvedValue(secretDataItems); const getPrimaryKeyringSeedPhraseFn = jest.fn(); const trackEvent = jest.fn(); @@ -207,7 +203,11 @@ describe('identifyIncompleteMetadataBackup', () => { logFn, }); - expectEvent(trackEvent, SeedlessPrimarySrpMissingEventName); + expectTrackedEvent(trackEvent, { + name: SeedlessPrimarySrpMissingEventName, + properties: properties as IncompleteMetadataBackupEventProperties, + hasProperties: true, + }); expect(getPrimaryKeyringSeedPhraseFn).not.toHaveBeenCalled(); expect(logFn).not.toHaveBeenCalled(); }, @@ -259,12 +259,9 @@ describe('identifyIncompleteMetadataBackup', () => { it('logs local keyring failures without tracking an affected-user event', async () => { const error = new Error('keyring unavailable'); - const fetchAllSecretDataFn = jest.fn().mockResolvedValue([ - createFetchedSecretDataItemMock({ - data: 'primary srp', - timestamp: 100, - }), - ]); + const fetchAllSecretDataFn = jest + .fn() + .mockResolvedValue([createFetchedSecretDataItem(SRP1)]); const getPrimaryKeyringSeedPhraseFn = jest.fn().mockRejectedValue(error); const trackEvent = jest.fn(); const logFn = jest.fn(); @@ -296,12 +293,109 @@ describe('identifyIncompleteMetadataBackup', () => { logFn, }); - expect(trackEvent).toHaveBeenCalledWith( - expect.objectContaining({ name: SeedlessPrimarySrpMissingEventName }), - ); + expectTrackedEvent(trackEvent, { + name: SeedlessPrimarySrpMissingEventName, + properties: { + metadata_schema_version: 'none', + number_of_imported_wallets: 0, + number_of_imported_accounts: 0, + }, + hasProperties: true, + }); 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 index 55d7c0079b5..cb282578486 100644 --- a/packages/seedless-onboarding-controller/src/utils/analytics.ts +++ b/packages/seedless-onboarding-controller/src/utils/analytics.ts @@ -5,14 +5,88 @@ import type { import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; import { areUint8ArraysEqual } from '@metamask/utils'; +import { SecretType } from '../constants.js'; import { InvalidPrimarySecretDataTypeError } from '../errors.js'; -import { parseAndSortSecretMetadata } from './secret-data-utils.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. @@ -61,9 +135,17 @@ export async function identifyIncompleteMetadataBackup({ } }; + let properties: IncompleteMetadataBackupEventProperties | undefined; + try { const secretDataItems = await fetchAllSecretDataFn(); - const [primarySecretMetadata] = parseAndSortSecretMetadata(secretDataItems); + const secretMetadata = parseSecretMetadata(secretDataItems); + const eventProperties = + getIncompleteMetadataBackupEventProperties(secretMetadata); + properties = eventProperties; + + const [primarySecretMetadata] = + validateSecretMetadataBackup(secretMetadata); const localPrimarySrp = await getPrimaryKeyringSeedPhraseFn(); if (areUint8ArraysEqual(primarySecretMetadata.data, localPrimarySrp)) { @@ -72,18 +154,16 @@ export async function identifyIncompleteMetadataBackup({ await trackEventSafely({ name: SeedlessPrimarySrpMismatchEventName, - properties: {}, + properties: eventProperties, sensitiveProperties: {}, saveDataRecording: false, hasProperties: false, }); } catch (error) { - if (error instanceof InvalidPrimarySecretDataTypeError) { + if (error instanceof InvalidPrimarySecretDataTypeError && properties) { await trackEventSafely({ name: SeedlessPrimarySrpMissingEventName, - properties: { - error: error.message, - }, + properties, sensitiveProperties: {}, saveDataRecording: false, hasProperties: true, diff --git a/packages/seedless-onboarding-controller/src/utils/index.ts b/packages/seedless-onboarding-controller/src/utils/index.ts index a0a9dad67dd..8cb351e485d 100644 --- a/packages/seedless-onboarding-controller/src/utils/index.ts +++ b/packages/seedless-onboarding-controller/src/utils/index.ts @@ -10,7 +10,7 @@ export type { SecretBackupData } from './secret-data-utils.js'; export { getDataTypeMigrationUpdates, getNewSocialBackupsMetadata, - parseAndSortSecretMetadata, + parseAndValidateSecretMetadataBackup, } from './secret-data-utils.js'; export { compareAndGetLatestToken, 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 index f295e464d7d..14fa8a305e3 100644 --- a/packages/seedless-onboarding-controller/src/utils/secret-data-utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils/secret-data-utils.test.ts @@ -1,120 +1,96 @@ import { keccak256AndHexify } from '@metamask/auth-network-utils'; -import type { FetchedSecretDataItem } from '@metamask/toprf-secure-backup'; 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 { SecretMetadata } from '../SecretMetadata.js'; import { getDataTypeMigrationUpdates, getNewSocialBackupsMetadata, - parseAndSortSecretMetadata, + parseAndValidateSecretMetadataBackup, + parseSecretMetadata, } from './secret-data-utils.js'; -function createSecretMetadata( - data: string, - options: { - dataType?: EncAccountDataType; - itemId?: string; - storageVersion?: 'v1' | 'v2'; - timestamp?: number; - type?: SecretType; - } = {}, -): SecretMetadata { - return new SecretMetadata(stringToBytes(data), options); -} - -function createFetchedSecretDataItem({ - data, - timestamp, - itemId, - type = SecretType.Mnemonic, - dataType, - version, - createdAt, -}: { - data: string; - timestamp: number; - itemId?: string; - type?: SecretType; - dataType?: EncAccountDataType; - version?: 'v1' | 'v2'; - createdAt?: string; -}): FetchedSecretDataItem { - const metadata = new SecretMetadata(stringToBytes(data), { - timestamp, - type, - ...(dataType === undefined ? {} : { dataType }), - }); - - return { - data: metadata.toBytes(), - itemId, - version: version ?? (dataType === undefined ? 'v1' : 'v2'), - dataType, - createdAt, - } as FetchedSecretDataItem; -} - describe('secret data utilities', () => { - describe('parseAndSortSecretMetadata', () => { - it('sorts legacy metadata by timestamp and promotes the first mnemonic', () => { - const results = parseAndSortSecretMetadata([ + describe('parseSecretMetadata', () => { + it('sorts primary SRP items ahead of older secrets', () => { + const results = parseSecretMetadata([ createFetchedSecretDataItem({ - data: 'private key', - timestamp: 50, + ...PrivateKey1, itemId: 'private-key', - type: SecretType.PrivateKey, }), createFetchedSecretDataItem({ - data: 'second srp', - timestamp: 200, - itemId: 'srp-2', + ...ImportedSRP1, + timestamp: 1, + itemId: 'imported-srp', }), createFetchedSecretDataItem({ - data: 'first srp', - timestamp: 100, - itemId: 'srp-1', + ...PrimarySRP2, + timestamp: 300, + itemId: 'primary-srp', }), ]); - expect(results.map((result) => result.data)).toStrictEqual([ - stringToBytes('first srp'), - stringToBytes('private key'), - stringToBytes('second srp'), + expect(results.map((result) => result.itemId)).toStrictEqual([ + 'primary-srp', + 'imported-srp', + 'private-key', ]); }); - it('keeps an explicitly tagged primary mnemonic first', () => { - const results = parseAndSortSecretMetadata([ + it('keeps timestamp order when no primary mnemonic is tagged', () => { + const results = parseSecretMetadata([ createFetchedSecretDataItem({ - data: 'imported srp', - timestamp: 1, + ...ImportedSRP1, itemId: 'imported-srp', - dataType: EncAccountDataType.ImportedSrp, - }), - createFetchedSecretDataItem({ - data: 'primary srp', - timestamp: 2, - itemId: 'primary-srp', - dataType: EncAccountDataType.PrimarySrp, }), ]); - expect(results[0]?.data).toStrictEqual(stringToBytes('primary 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(() => - parseAndSortSecretMetadata([ - createFetchedSecretDataItem({ - data: 'imported srp', - timestamp: 1, - itemId: 'imported-srp', - dataType: EncAccountDataType.ImportedSrp, - }), + parseAndValidateSecretMetadataBackup([ + createFetchedSecretDataItem(ImportedSRP1), ]), ).toThrow( new InvalidPrimarySecretDataTypeError([EncAccountDataType.ImportedSrp]), @@ -125,26 +101,16 @@ describe('secret data utilities', () => { describe('getDataTypeMigrationUpdates', () => { it('classifies legacy items in their existing order', () => { const updates = getDataTypeMigrationUpdates([ - createSecretMetadata('first srp', { - itemId: 'srp-1', - timestamp: 100, - }), - createSecretMetadata('second srp', { - itemId: 'srp-2', - timestamp: 200, - }), - createSecretMetadata('private key', { - itemId: 'private-key', - timestamp: 300, - type: SecretType.PrivateKey, - }), + createSecretMetadataFromMock(SRP1), + createSecretMetadataFromMock(SRP2), + createSecretMetadataFromMock(PrivateKey1), ]); expect(updates).toStrictEqual([ - { itemId: 'srp-1', dataType: EncAccountDataType.PrimarySrp }, - { itemId: 'srp-2', dataType: EncAccountDataType.ImportedSrp }, + { itemId: SRP1.itemId, dataType: EncAccountDataType.PrimarySrp }, + { itemId: SRP2.itemId, dataType: EncAccountDataType.ImportedSrp }, { - itemId: 'private-key', + itemId: PrivateKey1.itemId, dataType: EncAccountDataType.ImportedPrivateKey, }, ]); @@ -152,7 +118,7 @@ describe('secret data utilities', () => { it('preserves an existing primary and skips items outside the migration', () => { const updates = getDataTypeMigrationUpdates([ - createSecretMetadata('primary srp', { + createSecretMetadata(SRP1.data, { dataType: EncAccountDataType.PrimarySrp, itemId: 'primary-srp', storageVersion: 'v1', diff --git a/packages/seedless-onboarding-controller/src/utils/secret-data-utils.ts b/packages/seedless-onboarding-controller/src/utils/secret-data-utils.ts index 1264092b997..99f7fb853e1 100644 --- a/packages/seedless-onboarding-controller/src/utils/secret-data-utils.ts +++ b/packages/seedless-onboarding-controller/src/utils/secret-data-utils.ts @@ -17,13 +17,15 @@ export type SecretBackupData = Omit & { }; /** - * Parse, sort, and validate secret metadata fetched from the metadata store. + * 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 and ordered secret metadata. - * @throws If no mnemonic can be used as the primary secret. + * @returns The parsed secret metadata in sort order. */ -export function parseAndSortSecretMetadata( +export function parseSecretMetadata( secretDataItems: FetchedSecretDataItem[], ): SecretMetadata[] { const results: SecretMetadata[] = secretDataItems.map((item) => @@ -38,23 +40,60 @@ export function parseAndSortSecretMetadata( // 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), + 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(results); + throw InvalidPrimarySecretDataTypeError.fromSecretMetadata(secretMetadata); } if (primaryIndex !== 0) { - const [primary] = results.splice(primaryIndex, 1); - results.unshift(primary); + const [primary] = secretMetadata.splice(primaryIndex, 1); + secretMetadata.unshift(primary); } - return results; + 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)); } /** 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..ea4adfe26b4 --- /dev/null +++ b/packages/seedless-onboarding-controller/tests/__fixtures__/secret-metadata.ts @@ -0,0 +1,132 @@ +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({ + data, + timestamp, + itemId, + type = SecretType.Mnemonic, + dataType, + version, + createdAt, +}: FetchedSecretDataItemMock): FetchedSecretDataItem { + 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/yarn.lock b/yarn.lock index dbf375582e0..8b57e147b96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5459,6 +5459,20 @@ __metadata: languageName: unknown linkType: soft +"@metamask/analytics-controller@npm:^3.2.0": + version: 3.2.0 + resolution: "@metamask/analytics-controller@npm:3.2.0" + dependencies: + "@metamask/base-controller": "npm:^10.0.0" + "@metamask/geolocation-controller": "npm:^2.0.0" + "@metamask/messenger": "npm:^3.0.0" + "@metamask/utils": "npm:^12.0.0" + lodash-es: "npm:^4.17.21" + uuid: "npm:^11.1.1" + checksum: 10/583b6fa18851593c39ba79fde04b51162b74d910256b626d91ad6f27a848f20a6bc362c80ee6f22af2c51392a949e5e88c2baf10810c1609eb73e821a9d39169 + languageName: node + linkType: hard + "@metamask/analytics-controller@npm:^4.0.0, @metamask/analytics-controller@workspace:packages/analytics-controller": version: 0.0.0-use.local resolution: "@metamask/analytics-controller@workspace:packages/analytics-controller" @@ -20445,7 +20459,7 @@ __metadata: languageName: node linkType: hard -"lodash-es@npm:^4.18.1": +"lodash-es@npm:^4.17.21, lodash-es@npm:^4.18.1": version: 4.18.1 resolution: "lodash-es@npm:4.18.1" checksum: 10/8bfad225ef09ef42b04283cdaf7830efcc2ba29ae41b56501c74422155ee1ccaa1f0f6e8319def3451a1fe54dec501c8e4bee622bae2b2d98ac993731e0a5cce From b56704f54fef9bf9ebb950bf83fa4fd10d99e958 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 6 Oct 2026 01:37:18 +0800 Subject: [PATCH 15/22] fix: fixed lint --- .../package.json | 2 +- .../src/utils/analytics.ts | 7 +++++-- .../tests/__fixtures__/secret-metadata.ts | 21 +++++++++++-------- yarn.lock | 18 ++-------------- 4 files changed, 20 insertions(+), 28 deletions(-) diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index f13dde5c1b6..24ddaf7f392 100644 --- a/packages/seedless-onboarding-controller/package.json +++ b/packages/seedless-onboarding-controller/package.json @@ -54,7 +54,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/analytics-controller": "^3.2.0", + "@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/utils/analytics.ts b/packages/seedless-onboarding-controller/src/utils/analytics.ts index cb282578486..e0bf8f0a45f 100644 --- a/packages/seedless-onboarding-controller/src/utils/analytics.ts +++ b/packages/seedless-onboarding-controller/src/utils/analytics.ts @@ -70,7 +70,10 @@ export function getIncompleteMetadataBackupEventProperties( */ function getMetadataSchemaVersion( secretMetadata: SecretMetadata[], -): IncompleteMetadataBackupEventProperties['metadata_schema_version'] | 'mixed' | 'none' { +): + | IncompleteMetadataBackupEventProperties['metadata_schema_version'] + | 'mixed' + | 'none' { if (secretMetadata.length === 0) { // No secret metadata found. return 'none'; @@ -157,7 +160,7 @@ export async function identifyIncompleteMetadataBackup({ properties: eventProperties, sensitiveProperties: {}, saveDataRecording: false, - hasProperties: false, + hasProperties: true, }); } catch (error) { if (error instanceof InvalidPrimarySecretDataTypeError && properties) { diff --git a/packages/seedless-onboarding-controller/tests/__fixtures__/secret-metadata.ts b/packages/seedless-onboarding-controller/tests/__fixtures__/secret-metadata.ts index ea4adfe26b4..f6723afdb4f 100644 --- a/packages/seedless-onboarding-controller/tests/__fixtures__/secret-metadata.ts +++ b/packages/seedless-onboarding-controller/tests/__fixtures__/secret-metadata.ts @@ -68,15 +68,18 @@ export const ImportedPrivateKey2 = { * @param mock - The secret metadata fields to encode. * @returns A fetched secret-data item whose payload matches the mock. */ -export function createFetchedSecretDataItem({ - data, - timestamp, - itemId, - type = SecretType.Mnemonic, - dataType, - version, - createdAt, -}: FetchedSecretDataItemMock): FetchedSecretDataItem { +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, diff --git a/yarn.lock b/yarn.lock index 8b57e147b96..a26bbdbec8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5459,20 +5459,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/analytics-controller@npm:^3.2.0": - version: 3.2.0 - resolution: "@metamask/analytics-controller@npm:3.2.0" - dependencies: - "@metamask/base-controller": "npm:^10.0.0" - "@metamask/geolocation-controller": "npm:^2.0.0" - "@metamask/messenger": "npm:^3.0.0" - "@metamask/utils": "npm:^12.0.0" - lodash-es: "npm:^4.17.21" - uuid: "npm:^11.1.1" - checksum: 10/583b6fa18851593c39ba79fde04b51162b74d910256b626d91ad6f27a848f20a6bc362c80ee6f22af2c51392a949e5e88c2baf10810c1609eb73e821a9d39169 - languageName: node - linkType: hard - "@metamask/analytics-controller@npm:^4.0.0, @metamask/analytics-controller@workspace:packages/analytics-controller": version: 0.0.0-use.local resolution: "@metamask/analytics-controller@workspace:packages/analytics-controller" @@ -8524,7 +8510,7 @@ __metadata: dependencies: "@lavamoat/allow-scripts": "npm:^5.1.0" "@lavamoat/preinstall-always-fail": "npm:^3.0.0" - "@metamask/analytics-controller": "npm:^3.2.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" @@ -20459,7 +20445,7 @@ __metadata: languageName: node linkType: hard -"lodash-es@npm:^4.17.21, lodash-es@npm:^4.18.1": +"lodash-es@npm:^4.18.1": version: 4.18.1 resolution: "lodash-es@npm:4.18.1" checksum: 10/8bfad225ef09ef42b04283cdaf7830efcc2ba29ae41b56501c74422155ee1ccaa1f0f6e8319def3451a1fe54dec501c8e4bee622bae2b2d98ac993731e0a5cce From 2bc8482402f20fec5fb1431124c7e9ae2cbbefff Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 6 Oct 2026 01:44:30 +0800 Subject: [PATCH 16/22] fix: fixed tests --- .../src/utils/analytics.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/utils/analytics.test.ts b/packages/seedless-onboarding-controller/src/utils/analytics.test.ts index d2ac51fd127..0f2b9a7f54f 100644 --- a/packages/seedless-onboarding-controller/src/utils/analytics.test.ts +++ b/packages/seedless-onboarding-controller/src/utils/analytics.test.ts @@ -27,11 +27,9 @@ function expectTrackedEvent( { name, properties, - hasProperties, }: { name: string; properties: IncompleteMetadataBackupEventProperties; - hasProperties: boolean; }, ): void { expect(trackEvent).toHaveBeenCalledTimes(1); @@ -40,7 +38,7 @@ function expectTrackedEvent( properties, sensitiveProperties: {}, saveDataRecording: false, - hasProperties, + hasProperties: true, }); } @@ -99,7 +97,6 @@ describe('identifyIncompleteMetadataBackup', () => { number_of_imported_wallets: 1, number_of_imported_accounts: 1, }, - hasProperties: false, }); expect(logFn).not.toHaveBeenCalled(); }); @@ -155,7 +152,6 @@ describe('identifyIncompleteMetadataBackup', () => { number_of_imported_wallets: 1, number_of_imported_accounts: 0, }, - hasProperties: false, }); expect(logFn).not.toHaveBeenCalled(); }); @@ -206,7 +202,6 @@ describe('identifyIncompleteMetadataBackup', () => { expectTrackedEvent(trackEvent, { name: SeedlessPrimarySrpMissingEventName, properties: properties as IncompleteMetadataBackupEventProperties, - hasProperties: true, }); expect(getPrimaryKeyringSeedPhraseFn).not.toHaveBeenCalled(); expect(logFn).not.toHaveBeenCalled(); @@ -300,7 +295,6 @@ describe('identifyIncompleteMetadataBackup', () => { number_of_imported_wallets: 0, number_of_imported_accounts: 0, }, - hasProperties: true, }); expect(logFn).toHaveBeenCalledWith( 'Error tracking incomplete metadata backup event', From 22c17283c9a3fa3154ec468a4cc2d69853a5f961 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 6 Oct 2026 14:37:39 +0800 Subject: [PATCH 17/22] feat: track Primary SRP mismatch event on submitGlobalPassword --- .../src/SeedlessOnboardingController.test.ts | 88 +++++++++++++++++++ .../src/SeedlessOnboardingController.ts | 57 ++++++++---- 2 files changed, 130 insertions(+), 15 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 07c057ca342..ede21269af9 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -5217,6 +5217,94 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should identify a primary SRP mismatch after global password unlock', 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 new Promise((resolve) => setTimeout(resolve, 10)); + + expect(exportSeedPhrase).toHaveBeenCalledWith({ + password: GLOBAL_PASSWORD, + }); + expect(trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: SeedlessPrimarySrpMismatchEventName, + }), + ); + }, + ); + }); + it('should throw if key not set', async () => { await withController( { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 3f6ee793ea3..999f3d94a0c 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -838,21 +838,9 @@ export class SeedlessOnboardingController< this.#setUnlocked(); - // Identify incomplete metadata backup after successful unlock - // Run this async to avoid blocking the main thread - identifyIncompleteMetadataBackup({ - 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, + this.#identifyIncompleteMetadataBackup({ + deserializedVaultData, + password, }); }); } @@ -993,6 +981,15 @@ export class SeedlessOnboardingController< accessTokenBeforeUnlock, decryptedVaultData.accessToken, ); + + // Identify the PrimarySRP mismatch case here. + // Users with the missing PrimarySRP cannot restore the wallet in another device. + // But users with the **PrimarySRP mismatch** case can restore the wallet in another device. + // So we need to track the PrimarySRP mismatch event here. + this.#identifyIncompleteMetadataBackup({ + deserializedVaultData: decryptedVaultData, + password: globalPassword, + }); } catch (error) { if (isAuthTokenError(error)) { throw error; @@ -2459,6 +2456,36 @@ export class SeedlessOnboardingController< return true; // Consider unauthenticated user as having expired tokens } } + + /** + * Identify an incomplete or mismatched primary SRP backup after unlocking. + * + * @param params - The parameters used to identify the incomplete backup. + * @param params.deserializedVaultData - The decrypted vault data. + * @param params.password - The password used to export the local primary SRP. + */ + #identifyIncompleteMetadataBackup({ + deserializedVaultData, + password, + }: { + deserializedVaultData: DeserializedVaultData; + password: string; + }): void { + identifyIncompleteMetadataBackup({ + 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, + }); + } } /** From f25af532ef44050fb8292f43514b0bec224c802f Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 6 Oct 2026 15:24:03 +0800 Subject: [PATCH 18/22] feat: exposed new public method, 'identifyIncompleteMetadataBackup' --- .../CHANGELOG.md | 4 + ...nboardingController-method-action-types.ts | 16 ++++ .../src/SeedlessOnboardingController.test.ts | 21 ++++- .../src/SeedlessOnboardingController.ts | 79 ++++++++----------- .../src/index.ts | 1 + .../src/utils/analytics.test.ts | 20 ++--- .../src/utils/analytics.ts | 2 +- .../src/utils/index.ts | 2 +- 8 files changed, 87 insertions(+), 58 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 81d1223e24c..5b6c4e2f073 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,6 +7,10 @@ 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:** Require `AnalyticsController:trackEvent` and `KeyringController:exportSeedPhrase` actions in `SeedlessOnboardingControllerMessenger`. ([#10568](https://github.com/MetaMask/core/pull/10568)) 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.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index ede21269af9..921c73f74eb 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -5217,7 +5217,7 @@ describe('SeedlessOnboardingController', () => { ); }); - it('should identify a primary SRP mismatch after global password unlock', async () => { + it('should identify a primary SRP mismatch when explicitly requested', async () => { await withController( { state: getMockInitialControllerState({ @@ -5291,7 +5291,11 @@ describe('SeedlessOnboardingController', () => { globalPassword: GLOBAL_PASSWORD, }, ); - await new Promise((resolve) => setTimeout(resolve, 10)); + + await baseMessenger.call( + 'SeedlessOnboardingController:identifyIncompleteMetadataBackup', + GLOBAL_PASSWORD, + ); expect(exportSeedPhrase).toHaveBeenCalledWith({ password: GLOBAL_PASSWORD, @@ -5305,6 +5309,19 @@ describe('SeedlessOnboardingController', () => { ); }); + 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( { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 999f3d94a0c..2f2049cb41c 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -72,7 +72,7 @@ import { deserializeVaultData, getDataTypeMigrationUpdates, getNewSocialBackupsMetadata, - identifyIncompleteMetadataBackup, + trackIncompleteMetadataBackupEvents, isAuthTokenError, isMaxKeyChainLengthError, isTokenNearExpiry, @@ -106,6 +106,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'verifyVaultPassword', 'getSecretDataBackupState', 'submitPassword', + 'identifyIncompleteMetadataBackup', 'setLocked', 'syncLatestGlobalPassword', 'submitGlobalPassword', @@ -838,10 +839,39 @@ export class SeedlessOnboardingController< this.#setUnlocked(); - this.#identifyIncompleteMetadataBackup({ - deserializedVaultData, - password, - }); + 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(); + + // `#setUnlocked` is only called after the decrypted vault data is cached. + 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, }); } @@ -981,15 +1011,6 @@ export class SeedlessOnboardingController< accessTokenBeforeUnlock, decryptedVaultData.accessToken, ); - - // Identify the PrimarySRP mismatch case here. - // Users with the missing PrimarySRP cannot restore the wallet in another device. - // But users with the **PrimarySRP mismatch** case can restore the wallet in another device. - // So we need to track the PrimarySRP mismatch event here. - this.#identifyIncompleteMetadataBackup({ - deserializedVaultData: decryptedVaultData, - password: globalPassword, - }); } catch (error) { if (isAuthTokenError(error)) { throw error; @@ -2456,36 +2477,6 @@ export class SeedlessOnboardingController< return true; // Consider unauthenticated user as having expired tokens } } - - /** - * Identify an incomplete or mismatched primary SRP backup after unlocking. - * - * @param params - The parameters used to identify the incomplete backup. - * @param params.deserializedVaultData - The decrypted vault data. - * @param params.password - The password used to export the local primary SRP. - */ - #identifyIncompleteMetadataBackup({ - deserializedVaultData, - password, - }: { - deserializedVaultData: DeserializedVaultData; - password: string; - }): void { - identifyIncompleteMetadataBackup({ - 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, - }); - } } /** 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/utils/analytics.test.ts b/packages/seedless-onboarding-controller/src/utils/analytics.test.ts index 0f2b9a7f54f..1bf3b95c674 100644 --- a/packages/seedless-onboarding-controller/src/utils/analytics.test.ts +++ b/packages/seedless-onboarding-controller/src/utils/analytics.test.ts @@ -16,7 +16,7 @@ import { SecretType } from '../constants.js'; import type { IncompleteMetadataBackupEventProperties } from './analytics.js'; import { getIncompleteMetadataBackupEventProperties, - identifyIncompleteMetadataBackup, + trackIncompleteMetadataBackupEvents, SeedlessPrimarySrpMismatchEventName, SeedlessPrimarySrpMissingEventName, } from './analytics.js'; @@ -57,7 +57,7 @@ describe('identifyIncompleteMetadataBackup', () => { const trackEvent = jest.fn(); const logFn = jest.fn(); - await identifyIncompleteMetadataBackup({ + await trackIncompleteMetadataBackupEvents({ fetchAllSecretDataFn, getPrimaryKeyringSeedPhraseFn, trackEvent, @@ -83,7 +83,7 @@ describe('identifyIncompleteMetadataBackup', () => { const trackEvent = jest.fn(); const logFn = jest.fn(); - await identifyIncompleteMetadataBackup({ + await trackIncompleteMetadataBackupEvents({ fetchAllSecretDataFn, getPrimaryKeyringSeedPhraseFn, trackEvent, @@ -114,7 +114,7 @@ describe('identifyIncompleteMetadataBackup', () => { const trackEvent = jest.fn(); const logFn = jest.fn(); - await identifyIncompleteMetadataBackup({ + await trackIncompleteMetadataBackupEvents({ fetchAllSecretDataFn, getPrimaryKeyringSeedPhraseFn, trackEvent, @@ -138,7 +138,7 @@ describe('identifyIncompleteMetadataBackup', () => { const trackEvent = jest.fn(); const logFn = jest.fn(); - await identifyIncompleteMetadataBackup({ + await trackIncompleteMetadataBackupEvents({ fetchAllSecretDataFn, getPrimaryKeyringSeedPhraseFn, trackEvent, @@ -192,7 +192,7 @@ describe('identifyIncompleteMetadataBackup', () => { const trackEvent = jest.fn(); const logFn = jest.fn(); - await identifyIncompleteMetadataBackup({ + await trackIncompleteMetadataBackupEvents({ fetchAllSecretDataFn, getPrimaryKeyringSeedPhraseFn, trackEvent, @@ -214,7 +214,7 @@ describe('identifyIncompleteMetadataBackup', () => { const trackEvent = jest.fn(); const logFn = jest.fn(); - await identifyIncompleteMetadataBackup({ + await trackIncompleteMetadataBackupEvents({ fetchAllSecretDataFn, getPrimaryKeyringSeedPhraseFn: jest.fn(), trackEvent, @@ -238,7 +238,7 @@ describe('identifyIncompleteMetadataBackup', () => { const trackEvent = jest.fn(); const logFn = jest.fn(); - await identifyIncompleteMetadataBackup({ + await trackIncompleteMetadataBackupEvents({ fetchAllSecretDataFn, getPrimaryKeyringSeedPhraseFn: jest.fn(), trackEvent, @@ -261,7 +261,7 @@ describe('identifyIncompleteMetadataBackup', () => { const trackEvent = jest.fn(); const logFn = jest.fn(); - await identifyIncompleteMetadataBackup({ + await trackIncompleteMetadataBackupEvents({ fetchAllSecretDataFn, getPrimaryKeyringSeedPhraseFn, trackEvent, @@ -281,7 +281,7 @@ describe('identifyIncompleteMetadataBackup', () => { const trackEvent = jest.fn().mockRejectedValue(trackError); const logFn = jest.fn(); - await identifyIncompleteMetadataBackup({ + await trackIncompleteMetadataBackupEvents({ fetchAllSecretDataFn, getPrimaryKeyringSeedPhraseFn: jest.fn(), trackEvent, diff --git a/packages/seedless-onboarding-controller/src/utils/analytics.ts b/packages/seedless-onboarding-controller/src/utils/analytics.ts index e0bf8f0a45f..b41347ea6ff 100644 --- a/packages/seedless-onboarding-controller/src/utils/analytics.ts +++ b/packages/seedless-onboarding-controller/src/utils/analytics.ts @@ -114,7 +114,7 @@ function getMetadataSchemaVersion( * @returns A promise that resolves after identification and best-effort * telemetry have completed. */ -export async function identifyIncompleteMetadataBackup({ +export async function trackIncompleteMetadataBackupEvents({ fetchAllSecretDataFn, getPrimaryKeyringSeedPhraseFn, trackEvent, diff --git a/packages/seedless-onboarding-controller/src/utils/index.ts b/packages/seedless-onboarding-controller/src/utils/index.ts index 8cb351e485d..accb1d28624 100644 --- a/packages/seedless-onboarding-controller/src/utils/index.ts +++ b/packages/seedless-onboarding-controller/src/utils/index.ts @@ -5,7 +5,7 @@ export { assertIsSeedlessOnboardingUserAuthenticated, assertIsValidPassword, } from './assertions.js'; -export { identifyIncompleteMetadataBackup } from './analytics.js'; +export { trackIncompleteMetadataBackupEvents } from './analytics.js'; export type { SecretBackupData } from './secret-data-utils.js'; export { getDataTypeMigrationUpdates, From c2b80f4ede40aced8f2a1e98766a245aab283463 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 6 Oct 2026 17:07:46 +0800 Subject: [PATCH 19/22] chore: testing --- .../src/SeedlessOnboardingController.ts | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 2f2049cb41c..6367939aa4a 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -839,7 +839,7 @@ export class SeedlessOnboardingController< this.#setUnlocked(); - this.identifyIncompleteMetadataBackup(password); + this.#identifyIncompleteMetadataBackup(password); }); } @@ -855,24 +855,7 @@ export class SeedlessOnboardingController< async identifyIncompleteMetadataBackup(password: string): Promise { this.#assertIsUnlocked(); - // `#setUnlocked` is only called after the decrypted vault data is cached. - 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, - }); + await this.#identifyIncompleteMetadataBackup(password); } /** @@ -2477,6 +2460,28 @@ export class SeedlessOnboardingController< return true; // Consider unauthenticated user as having expired tokens } } + + async #identifyIncompleteMetadataBackup(password: string): Promise { + const deserializedVaultData = + this.#cachedDecryptedVaultData as DeserializedVaultData; + + console.log('identifyIncompleteMetadataBackup::deserializedVaultData', 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, + }); + } } /** From 46660e4987c6e28f55414c6a63bcdeabdba0c854 Mon Sep 17 00:00:00 2001 From: lwin Date: Tue, 6 Oct 2026 17:10:23 +0800 Subject: [PATCH 20/22] chore: removed debug logs --- .../src/SeedlessOnboardingController.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 6367939aa4a..ce7d7aeb199 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -2465,8 +2465,6 @@ export class SeedlessOnboardingController< const deserializedVaultData = this.#cachedDecryptedVaultData as DeserializedVaultData; - console.log('identifyIncompleteMetadataBackup::deserializedVaultData', deserializedVaultData); - await trackIncompleteMetadataBackupEvents({ fetchAllSecretDataFn: () => this.toprfClient.fetchAllSecretDataItems({ From 432039ed3c0c6e04877217ce5788e4af771ee1d3 Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 8 Oct 2026 11:50:46 +0800 Subject: [PATCH 21/22] fix: fixed lint --- packages/seedless-onboarding-controller/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 6810a46a954..e937f49f7e4 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -15,7 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Require `AnalyticsController:trackEvent` and `KeyringController:exportSeedPhrase` actions in `SeedlessOnboardingControllerMessenger`. ([#10568](https://github.com/MetaMask/core/pull/10568)) - The `SeedlessOnboardingControllerMessenger` must allow and delegate these actions. -### Changed - Bump `@noble/curves` from `^1.9.2` to `^1.9.7` ([#10720](https://github.com/MetaMask/core/pull/10720)) ## [11.0.1] From d4430e26326b9b1272544e54982ceb35c2d8bc0a Mon Sep 17 00:00:00 2001 From: lwin Date: Thu, 8 Oct 2026 17:09:26 +0800 Subject: [PATCH 22/22] chore: updated ChangeLog --- packages/seedless-onboarding-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index e937f49f7e4..301aa87fa4a 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** Require `AnalyticsController:trackEvent` and `KeyringController:exportSeedPhrase` actions in `SeedlessOnboardingControllerMessenger`. ([#10568](https://github.com/MetaMask/core/pull/10568)) +- **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))