From 611556ba7a8a8705380c21b26ab55129e723eda9 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:02:01 +0800 Subject: [PATCH 1/8] feat(seedless-onboarding): add dataType support for secret data items - Add dataType parameter to createToprfKeyAndBackupSeedPhrase and addNewSecretData - Add updateSecretDataItem and batchUpdateSecretDataItems methods - Update fetchAllSecretData to return SecretDataItemWithMetadata[] --- .../CHANGELOG.md | 17 + .../package.json | 2 +- .../src/SecretMetadata.ts | 99 +--- .../src/SeedlessOnboardingController.test.ts | 487 ++++++++++++++---- .../src/SeedlessOnboardingController.ts | 143 ++++- .../src/constants.ts | 2 + .../src/index.ts | 3 + .../src/types.ts | 29 +- .../tests/mocks/toprf.ts | 53 +- 9 files changed, 634 insertions(+), 201 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index ffe06496fa0..c861f26dcfd 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,8 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `dataType` parameter to `createToprfKeyAndBackupSeedPhrase` method (defaults to `PrimarySrp` for backward compatibility) +- Add optional `dataType` parameter to `addNewSecretData` method for categorizing secret data on insert +- Add `updateSecretDataItem` method to update fields for existing items by `itemId` +- Add `batchUpdateSecretDataItems` method to batch update fields for multiple items +- Add `SecretDataItemWithMetadata` type for storage-level metadata (`itemId`, `dataType`) +- Add `SecretMetadata.compareByTimestamp` static method for comparing metadata by timestamp +- Add `SecretMetadata.matchesType` static method for checking if metadata matches a given type +- Re-export `EncAccountDataType` from `@metamask/toprf-secure-backup` + ### Changed +- **BREAKING:** Update `fetchAllSecretData` to return `SecretDataItemWithMetadata[]` instead of `SecretMetadata[]` + - Consumers must now access secret data via the `secret` property (e.g., `result[0].secret.data` instead of `result[0].data`) +- **BREAKING:** Remove `parseSecretsFromMetadataStore`, `fromBatch`, and `sort` methods from `SecretMetadata` + - Use `SecretMetadata.compareByTimestamp` for sorting + - Use `SecretMetadata.matchesType` for filtering +- Bump `@metamask/toprf-secure-backup` from `^0.10.0` to `^0.11.0` - Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) - The dependencies moved are: - `@metamask/keyring-controller` (^25.0.0) diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index 2fffa11a4f1..9f5b2309444 100644 --- a/packages/seedless-onboarding-controller/package.json +++ b/packages/seedless-onboarding-controller/package.json @@ -53,7 +53,7 @@ "@metamask/browser-passworder": "^6.0.0", "@metamask/keyring-controller": "^25.0.0", "@metamask/messenger": "^0.3.0", - "@metamask/toprf-secure-backup": "^0.10.0", + "@metamask/toprf-secure-backup": "^0.11.0", "@metamask/utils": "^11.8.1", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.2", diff --git a/packages/seedless-onboarding-controller/src/SecretMetadata.ts b/packages/seedless-onboarding-controller/src/SecretMetadata.ts index 89bd791e2a1..c5f419da48b 100644 --- a/packages/seedless-onboarding-controller/src/SecretMetadata.ts +++ b/packages/seedless-onboarding-controller/src/SecretMetadata.ts @@ -68,37 +68,6 @@ export class SecretMetadata this.#version = options?.version ?? SecretMetadataVersion.V1; } - /** - * Create an Array of SecretMetadata instances from an array of secrets. - * - * To respect the order of the secrets, we add the index to the timestamp - * so that the first secret backup will have the oldest timestamp - * and the last secret backup will have the newest timestamp. - * - * @param data - The data to add metadata to. - * @param data.value - The SeedPhrase/PrivateKey to add metadata to. - * @param data.options - The options for the seed phrase metadata. - * @returns The SecretMetadata instances. - */ - static fromBatch( - data: { - value: DataType; - options?: Partial; - }[], - ): SecretMetadata[] { - const timestamp = Date.now(); - return data.map((d, index) => { - // To respect the order of the seed phrases, we add the index to the timestamp - // so that the first seed phrase backup will have the oldest timestamp - // and the last seed phrase backup will have the newest timestamp - const backupCreatedAt = d.options?.timestamp ?? timestamp + index * 5; - return new SecretMetadata(d.value, { - timestamp: backupCreatedAt, - type: d.options?.type, - }); - }); - } - /** * Assert that the provided value is a valid seed phrase metadata. * @@ -122,34 +91,6 @@ export class SecretMetadata } } - /** - * Parse the SecretMetadata from the metadata store and return the array of SecretMetadata instances. - * - * This method also sorts the secrets by timestamp in ascending order, i.e. the oldest secret will be the first element in the array. - * - * @param secretMetadataArr - The array of SecretMetadata from the metadata store. - * @param filterType - The type of the secret to filter. - * @returns The array of SecretMetadata instances. - */ - static parseSecretsFromMetadataStore< - DataType extends SecretDataType = Uint8Array, - >( - secretMetadataArr: Uint8Array[], - filterType?: SecretType, - ): SecretMetadata[] { - const parsedSecertMetadata = secretMetadataArr.map((metadata) => - SecretMetadata.fromRawMetadata(metadata), - ); - - const secrets = SecretMetadata.sort(parsedSecertMetadata); - - if (filterType) { - return secrets.filter((secret) => secret.type === filterType); - } - - return secrets; - } - /** * Parse and create the SecretMetadata instance from the raw metadata bytes. * @@ -183,23 +124,35 @@ export class SecretMetadata } /** - * Sort the seed phrases by timestamp. - * - * @param data - The secret metadata array to sort. - * @param order - The order to sort the seed phrases. Default is `desc`. + * Compare two SecretMetadata instances by timestamp. * - * @returns The sorted secret metadata array. + * @param a - The first SecretMetadata instance. + * @param b - The second SecretMetadata instance. + * @param order - The sort order. Default is 'asc'. + * @returns A negative number if a < b, positive if a > b, zero if equal. */ - static sort( - data: SecretMetadata[], + static compareByTimestamp( + a: SecretMetadata, + b: SecretMetadata, order: 'asc' | 'desc' = 'asc', - ): SecretMetadata[] { - return data.sort((a, b) => { - if (order === 'asc') { - return a.timestamp - b.timestamp; - } - return b.timestamp - a.timestamp; - }); + ): number { + return order === 'asc' + ? a.timestamp - b.timestamp + : b.timestamp - a.timestamp; + } + + /** + * Check if a SecretMetadata instance matches the given type. + * + * @param secret - The SecretMetadata instance to check. + * @param type - The type to match against. + * @returns True if the secret matches the type. + */ + static matchesType( + secret: SecretMetadata, + type: SecretType, + ): boolean { + return secret.type === type; } get data(): DataType { diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 12b053a2953..a56e663f428 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -17,6 +17,7 @@ import { } from '@metamask/browser-passworder'; import { TOPRFError, + EncAccountDataType, type FetchAuthPubKeyResult, type SEC1EncodedPublicKey, type ChangeEncryptionKeyResult, @@ -1863,6 +1864,252 @@ describe('SeedlessOnboardingController', () => { }); }); + describe('updateSecretDataItem', () => { + const MOCK_PASSWORD = 'mock-password'; + let MOCK_VAULT = ''; + let MOCK_VAULT_ENCRYPTION_KEY = ''; + let MOCK_VAULT_ENCRYPTION_SALT = ''; + + beforeEach(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_PASSWORD_ENCRYPTION_KEY = + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + + const mockResult = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_PASSWORD_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + + it('should update a secret data item by itemId', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient }) => { + await controller.submitPassword(MOCK_PASSWORD); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + const updateSecretDataItemSpy = jest + .spyOn(toprfClient, 'updateSecretDataItem') + .mockResolvedValueOnce(undefined); + + await controller.updateSecretDataItem({ + itemId: 'test-item-id', + dataType: EncAccountDataType.ImportedSrp, + }); + + expect(updateSecretDataItemSpy).toHaveBeenCalledWith({ + itemId: 'test-item-id', + dataType: EncAccountDataType.ImportedSrp, + authKeyPair: expect.any(Object), + }); + }, + ); + }); + + it('should throw error if controller is locked', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller }) => { + await expect( + controller.updateSecretDataItem({ + itemId: 'test-item-id', + dataType: EncAccountDataType.ImportedSrp, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.ControllerLocked, + ); + }, + ); + }); + + it('should throw error if SDK call fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient }) => { + await controller.submitPassword(MOCK_PASSWORD); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + jest + .spyOn(toprfClient, 'updateSecretDataItem') + .mockRejectedValueOnce(new Error('SDK error')); + + await expect( + controller.updateSecretDataItem({ + itemId: 'test-item-id', + dataType: EncAccountDataType.ImportedSrp, + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToUpdateSecretDataItem, + ); + }, + ); + }); + }); + + describe('batchUpdateSecretDataItems', () => { + const MOCK_PASSWORD = 'mock-password'; + let MOCK_VAULT = ''; + let MOCK_VAULT_ENCRYPTION_KEY = ''; + let MOCK_VAULT_ENCRYPTION_SALT = ''; + + beforeEach(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_PASSWORD_ENCRYPTION_KEY = + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + + const mockResult = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_PASSWORD_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + + it('should batch update multiple secret data items', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient }) => { + await controller.submitPassword(MOCK_PASSWORD); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + const batchUpdateSpy = jest + .spyOn(toprfClient, 'batchUpdateSecretDataItems') + .mockResolvedValueOnce(undefined); + + await controller.batchUpdateSecretDataItems({ + updates: [ + { itemId: 'item-1', dataType: EncAccountDataType.PrimarySrp }, + { itemId: 'item-2', dataType: EncAccountDataType.ImportedSrp }, + ], + }); + + expect(batchUpdateSpy).toHaveBeenCalledWith({ + updateItems: [ + { itemId: 'item-1', dataType: EncAccountDataType.PrimarySrp }, + { itemId: 'item-2', dataType: EncAccountDataType.ImportedSrp }, + ], + authKeyPair: expect.any(Object), + }); + }, + ); + }); + + it('should throw error if controller is locked', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller }) => { + await expect( + controller.batchUpdateSecretDataItems({ + updates: [ + { itemId: 'item-1', dataType: EncAccountDataType.PrimarySrp }, + ], + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.ControllerLocked, + ); + }, + ); + }); + + it('should throw error if SDK call fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withMockAuthPubKey: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, toprfClient }) => { + await controller.submitPassword(MOCK_PASSWORD); + + mockFetchAuthPubKey( + toprfClient, + base64ToBytes(controller.state.authPubKey as string), + ); + + jest + .spyOn(toprfClient, 'batchUpdateSecretDataItems') + .mockRejectedValueOnce(new Error('SDK error')); + + await expect( + controller.batchUpdateSecretDataItems({ + updates: [ + { itemId: 'item-1', dataType: EncAccountDataType.PrimarySrp }, + ], + }), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.FailedToBatchUpdateSecretDataItems, + ); + }, + ); + }); + }); + describe('fetchAllSecretData', () => { const MOCK_PASSWORD = 'mock-password'; @@ -1887,10 +2134,14 @@ describe('SeedlessOnboardingController', () => { { data: MOCK_SEED_PHRASE, type: SecretType.Mnemonic, + itemId: 'srp-item-id', + dataType: EncAccountDataType.PrimarySrp, }, { data: MOCK_PRIVATE_KEY, type: SecretType.PrivateKey, + itemId: 'pk-item-id', + dataType: EncAccountDataType.ImportedPrivateKey, }, ], MOCK_PASSWORD, @@ -1901,10 +2152,20 @@ describe('SeedlessOnboardingController', () => { expect(mockSecretDataGet.isDone()).toBe(true); expect(secretData).toBeDefined(); expect(secretData).toHaveLength(2); - expect(secretData[0].type).toStrictEqual(SecretType.Mnemonic); - expect(secretData[0].data).toStrictEqual(MOCK_SEED_PHRASE); - expect(secretData[1].type).toStrictEqual(SecretType.PrivateKey); - expect(secretData[1].data).toStrictEqual(MOCK_PRIVATE_KEY); + // Verify secret metadata + expect(secretData[0].secret.type).toStrictEqual(SecretType.Mnemonic); + expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[1].secret.type).toStrictEqual( + SecretType.PrivateKey, + ); + expect(secretData[1].secret.data).toStrictEqual(MOCK_PRIVATE_KEY); + // Verify storage metadata + expect(secretData[0].itemId).toBe('srp-item-id'); + expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); + expect(secretData[1].itemId).toBe('pk-item-id'); + expect(secretData[1].dataType).toBe( + EncAccountDataType.ImportedPrivateKey, + ); expect(controller.state.vault).toBeDefined(); expect(controller.state.vault).not.toStrictEqual(initialState.vault); @@ -1960,20 +2221,27 @@ describe('SeedlessOnboardingController', () => { expect(secretData).toHaveLength(3); expect( - secretData.every((secret) => secret.type === SecretType.Mnemonic), + secretData.every( + (item) => item.secret.type === SecretType.Mnemonic, + ), ).toBe(true); - // `fetchAndRestoreSeedPhraseMetadata` should sort the seed phrases by timestamp in ascending order and return the seed phrases in the correct order - // the seed phrases are sorted in ascending order, so the oldest seed phrase is the first item in the array - expect(secretData[0].data).toStrictEqual( + // Sorted by timestamp ascending: seedPhrase1 (10), seedPhrase2 (20), seedPhrase3 (60) + expect(secretData[0].secret.data).toStrictEqual( stringToBytes('seedPhrase1'), ); - expect(secretData[1].data).toStrictEqual( + expect(secretData[0].itemId).toBe('srp-1'); + expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); + expect(secretData[1].secret.data).toStrictEqual( stringToBytes('seedPhrase2'), ); - expect(secretData[2].data).toStrictEqual( + expect(secretData[1].itemId).toBe('srp-2'); + expect(secretData[1].dataType).toBe(EncAccountDataType.ImportedSrp); + expect(secretData[2].secret.data).toStrictEqual( stringToBytes('seedPhrase3'), ); + expect(secretData[2].itemId).toBe('srp-3'); + expect(secretData[2].dataType).toBe(EncAccountDataType.ImportedSrp); // verify the vault data const { encryptedMockVault } = await createMockVault( @@ -2020,7 +2288,14 @@ describe('SeedlessOnboardingController', () => { const mockSecretDataGet = handleMockSecretDataGet({ status: 200, body: createMockSecretDataGetResponse( - [MOCK_SEED_PHRASE], + [ + { + data: MOCK_SEED_PHRASE, + type: SecretType.Mnemonic, + itemId: 'primary-srp-id', + dataType: EncAccountDataType.PrimarySrp, + }, + ], MOCK_PASSWORD, ), }); @@ -2028,8 +2303,10 @@ describe('SeedlessOnboardingController', () => { expect(mockSecretDataGet.isDone()).toBe(true); expect(secretData).toBeDefined(); - expect(secretData[0].type).toStrictEqual(SecretType.Mnemonic); - expect(secretData[0].data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[0].secret.type).toStrictEqual(SecretType.Mnemonic); + expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[0].itemId).toBe('primary-srp-id'); + expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); expect(controller.state.vault).toBeDefined(); expect(controller.state.vault).not.toBe(initialState.vault); @@ -2075,7 +2352,14 @@ describe('SeedlessOnboardingController', () => { const mockSecretDataGet = handleMockSecretDataGet({ status: 200, body: createMockSecretDataGetResponse( - [MOCK_SEED_PHRASE], + [ + { + data: MOCK_SEED_PHRASE, + type: SecretType.Mnemonic, + itemId: 'primary-srp-id', + dataType: EncAccountDataType.PrimarySrp, + }, + ], MOCK_PASSWORD, ), }); @@ -2095,8 +2379,10 @@ describe('SeedlessOnboardingController', () => { expect(recoverEncKeySpy).toHaveBeenCalledTimes(2); // should call recoverEncKey twice for the first fail attempt due to token expired error and the second success attempt expect(authenticateSpy).toHaveBeenCalledTimes(1); // should call authenticate once for the token refresh - expect(secretData[0].type).toStrictEqual(SecretType.Mnemonic); - expect(secretData[0].data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[0].secret.type).toStrictEqual(SecretType.Mnemonic); + expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[0].itemId).toBe('primary-srp-id'); + expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); expect(controller.state.vault).toBeDefined(); expect(controller.state.vault).not.toBe(initialState.vault); @@ -2160,7 +2446,14 @@ describe('SeedlessOnboardingController', () => { const mockSecretDataGet = handleMockSecretDataGet({ status: 200, body: createMockSecretDataGetResponse( - [MOCK_SEED_PHRASE], + [ + { + data: MOCK_SEED_PHRASE, + type: SecretType.Mnemonic, + itemId: 'primary-srp-id', + dataType: EncAccountDataType.PrimarySrp, + }, + ], MOCK_PASSWORD, ), }); @@ -2170,8 +2463,10 @@ describe('SeedlessOnboardingController', () => { expect(mockSecretDataGet.isDone()).toBe(true); expect(secretData).toBeDefined(); expect(secretData).toHaveLength(1); - expect(secretData[0].type).toStrictEqual(SecretType.Mnemonic); - expect(secretData[0].data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[0].secret.type).toStrictEqual(SecretType.Mnemonic); + expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[0].itemId).toBe('primary-srp-id'); + expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); }, ); }); @@ -2235,7 +2530,7 @@ describe('SeedlessOnboardingController', () => { jest .spyOn(toprfClient, 'fetchAllSecretDataItems') .mockResolvedValueOnce([ - stringToBytes(JSON.stringify({ key: 'value' })), + { data: stringToBytes(JSON.stringify({ key: 'value' })) }, ]); await expect( controller.fetchAllSecretData(MOCK_PASSWORD), @@ -2379,6 +2674,8 @@ describe('SeedlessOnboardingController', () => { { data: MOCK_PRIVATE_KEY, type: SecretType.PrivateKey, + itemId: 'pk-id', + dataType: EncAccountDataType.ImportedPrivateKey, }, ], MOCK_PASSWORD, @@ -3242,27 +3539,6 @@ describe('SeedlessOnboardingController', () => { expect(seedPhraseMetadata.timestamp).toBe(timestamp); }); - it('should be able to correctly create `SecretMetadata` Array for batch seedphrases', () => { - const seedPhrases = ['seed phrase 1', 'seed phrase 2', 'seed phrase 3']; - const rawSeedPhrases = seedPhrases.map((srp) => ({ - value: stringToBytes(srp), - options: { - type: SecretType.Mnemonic, - }, - })); - - const seedPhraseMetadataArray = SecretMetadata.fromBatch(rawSeedPhrases); - expect(seedPhraseMetadataArray).toHaveLength(seedPhrases.length); - - // check the timestamp, the first one should be the oldest - expect(seedPhraseMetadataArray[0].timestamp).toBeLessThan( - seedPhraseMetadataArray[1].timestamp, - ); - expect(seedPhraseMetadataArray[1].timestamp).toBeLessThan( - seedPhraseMetadataArray[2].timestamp, - ); - }); - it('should be able to serialized and parse a seed phrase metadata', () => { const seedPhraseMetadata = new SecretMetadata(MOCK_SEED_PHRASE); const serializedSeedPhraseBytes = seedPhraseMetadata.toBytes(); @@ -3275,7 +3551,7 @@ describe('SeedlessOnboardingController', () => { expect(parsedSeedPhraseMetadata.data).toStrictEqual(MOCK_SEED_PHRASE); }); - it('should be able to sort seed phrase metadata', () => { + it('should be able to compare seed phrase metadata by timestamp', () => { const mockSeedPhraseMetadata1 = new SecretMetadata(MOCK_SEED_PHRASE, { timestamp: 1000, }); @@ -3283,23 +3559,23 @@ describe('SeedlessOnboardingController', () => { timestamp: 2000, }); - // sort in ascending order - const sortedSeedPhraseMetadata = SecretMetadata.sort( - [mockSeedPhraseMetadata1, mockSeedPhraseMetadata2], - 'asc', - ); - expect(sortedSeedPhraseMetadata[0].timestamp).toBeLessThan( - sortedSeedPhraseMetadata[1].timestamp, - ); - - // sort in descending order - const sortedSeedPhraseMetadataDesc = SecretMetadata.sort( - [mockSeedPhraseMetadata1, mockSeedPhraseMetadata2], - 'desc', - ); - expect(sortedSeedPhraseMetadataDesc[0].timestamp).toBeGreaterThan( - sortedSeedPhraseMetadataDesc[1].timestamp, - ); + // ascending order: earlier timestamp first + expect( + SecretMetadata.compareByTimestamp( + mockSeedPhraseMetadata1, + mockSeedPhraseMetadata2, + 'asc', + ), + ).toBeLessThan(0); + + // descending order: later timestamp first + expect( + SecretMetadata.compareByTimestamp( + mockSeedPhraseMetadata1, + mockSeedPhraseMetadata2, + 'desc', + ), + ).toBeGreaterThan(0); }); it('should be able to overwrite the default Generic DataType', () => { @@ -3342,8 +3618,9 @@ describe('SeedlessOnboardingController', () => { const secrets = [secret1.toBytes(), secret2.toBytes()]; - const parsedSecrets = - SecretMetadata.parseSecretsFromMetadataStore(secrets); + const parsedSecrets = secrets + .map((s) => SecretMetadata.fromRawMetadata(s)) + .sort((a, b) => SecretMetadata.compareByTimestamp(a, b, 'asc')); expect(parsedSecrets).toHaveLength(2); expect(parsedSecrets[0].data).toBe(mockPrivKeyString); expect(parsedSecrets[0].type).toBe(SecretType.PrivateKey); @@ -3363,9 +3640,12 @@ describe('SeedlessOnboardingController', () => { const secrets = [secret1.toBytes(), secret2.toBytes(), secret3.toBytes()]; - const mnemonicSecrets = SecretMetadata.parseSecretsFromMetadataStore( - secrets, - SecretType.Mnemonic, + const allSecrets = secrets + .map((s) => SecretMetadata.fromRawMetadata(s)) + .sort((a, b) => SecretMetadata.compareByTimestamp(a, b, 'asc')); + + const mnemonicSecrets = allSecrets.filter((s) => + SecretMetadata.matchesType(s, SecretType.Mnemonic), ); expect(mnemonicSecrets).toHaveLength(2); expect(mnemonicSecrets[0].data).toStrictEqual(MOCK_SEED_PHRASE); @@ -3373,9 +3653,8 @@ describe('SeedlessOnboardingController', () => { expect(mnemonicSecrets[1].data).toStrictEqual(MOCK_SEED_PHRASE); expect(mnemonicSecrets[1].type).toBe(SecretType.Mnemonic); - const privateKeySecrets = SecretMetadata.parseSecretsFromMetadataStore( - secrets, - SecretType.PrivateKey, + const privateKeySecrets = allSecrets.filter((s) => + SecretMetadata.matchesType(s, SecretType.PrivateKey), ); expect(privateKeySecrets).toHaveLength(1); @@ -5430,31 +5709,47 @@ describe('SeedlessOnboardingController', () => { jest .spyOn(toprfClient, 'fetchAllSecretDataItems') .mockResolvedValueOnce([ - stringToBytes( - JSON.stringify({ - data: bytesToBase64(MOCK_SEED_PHRASE), - timestamp: 1234567890, - type: SecretType.Mnemonic, - version: 'v1', - }), - ), - stringToBytes( - JSON.stringify({ - data: bytesToBase64(MOCK_PRIVATE_KEY), - timestamp: 1234567890, - type: SecretType.PrivateKey, - version: 'v1', - }), - ), + { + data: stringToBytes( + JSON.stringify({ + data: bytesToBase64(MOCK_SEED_PHRASE), + timestamp: 1234567890, + type: SecretType.Mnemonic, + version: 'v1', + }), + ), + itemId: 'primary-srp-id', + dataType: EncAccountDataType.PrimarySrp, + }, + { + data: stringToBytes( + JSON.stringify({ + data: bytesToBase64(MOCK_PRIVATE_KEY), + timestamp: 1234567890, + type: SecretType.PrivateKey, + version: 'v1', + }), + ), + itemId: 'pk-id', + dataType: EncAccountDataType.ImportedPrivateKey, + }, ]); const secretData = await controller.fetchAllSecretData(MOCK_PASSWORD); expect(secretData).toBeDefined(); expect(secretData).toHaveLength(2); - expect(secretData[0].type).toStrictEqual(SecretType.Mnemonic); - expect(secretData[0].data).toStrictEqual(MOCK_SEED_PHRASE); - expect(secretData[1].type).toStrictEqual(SecretType.PrivateKey); - expect(secretData[1].data).toStrictEqual(MOCK_PRIVATE_KEY); + expect(secretData[0].secret.type).toStrictEqual(SecretType.Mnemonic); + expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[0].itemId).toBe('primary-srp-id'); + expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); + expect(secretData[1].secret.type).toStrictEqual( + SecretType.PrivateKey, + ); + expect(secretData[1].secret.data).toStrictEqual(MOCK_PRIVATE_KEY); + expect(secretData[1].itemId).toBe('pk-id'); + expect(secretData[1].dataType).toBe( + EncAccountDataType.ImportedPrivateKey, + ); // expect(mockSecretDataGet.isDone()).toBe(true); }, @@ -5479,14 +5774,16 @@ describe('SeedlessOnboardingController', () => { jest .spyOn(toprfClient, 'fetchAllSecretDataItems') .mockResolvedValueOnce([ - stringToBytes( - JSON.stringify({ - data: 'value', - timestamp: 1234567890, - type: 'mnemonic', - version: 'v1', - }), - ), + { + data: stringToBytes( + JSON.stringify({ + data: 'value', + timestamp: 1234567890, + type: 'mnemonic', + version: 'v1', + }), + ), + }, ]); await expect( diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 72d58c7ed2a..81ee83e8360 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -5,11 +5,13 @@ import type { KeyPair, RecoverEncryptionKeyResult, SEC1EncodedPublicKey, + FetchedSecretDataItem, } from '@metamask/toprf-secure-backup'; import { ToprfSecureBackup, TOPRFErrorCode, TOPRFError, + EncAccountDataType, } from '@metamask/toprf-secure-backup'; import { base64ToBytes, @@ -51,6 +53,7 @@ import type { RenewRefreshToken, VaultData, DeserializedVaultData, + SecretDataItemWithMetadata, } from './types'; import { decodeJWTToken, @@ -438,12 +441,14 @@ export class SeedlessOnboardingController< * @param password - The password used to create new wallet and seedphrase * @param seedPhrase - The initial seed phrase (Mnemonic) created together with the wallet. * @param keyringId - The keyring id of the backup seed phrase + * @param dataType - Optional data type for categorizing the secret data. Defaults to PrimarySrp. * @returns A promise that resolves to the encrypted seed phrase and the encryption key. */ async createToprfKeyAndBackupSeedPhrase( password: string, seedPhrase: Uint8Array, keyringId: string, + dataType: EncAccountDataType = EncAccountDataType.PrimarySrp, ): Promise { return await this.#withControllerLock(async () => { // to make sure that fail fast, @@ -464,6 +469,7 @@ export class SeedlessOnboardingController< authKeyPair, options: { keyringId, + dataType, }, }); @@ -495,6 +501,7 @@ export class SeedlessOnboardingController< * @param type - The type of the secret data. * @param options - Optional options object, which includes optional data to be added to the metadata store. * @param options.keyringId - The keyring id of the backup keyring (SRP). + * @param options.dataType - Optional data type for categorizing the secret data. * @returns A promise that resolves to the success of the operation. */ async addNewSecretData( @@ -502,6 +509,7 @@ export class SeedlessOnboardingController< type: SecretType, options?: { keyringId?: string; + dataType?: EncAccountDataType; }, ): Promise { return await this.#withControllerLock(async () => { @@ -532,14 +540,113 @@ export class SeedlessOnboardingController< } /** - * Fetches all encrypted secret data and metadata for user's account from the metadata store. + * Update fields for an existing secret data item by itemId. + * + * This method updates metadata fields for an existing secret data item + * without modifying the encrypted data itself. + * + * @param params - The parameters for updating the secret data item. + * @param params.itemId - The item ID of the secret data to update. + * @param params.dataType - The data type to set for the item. + * @returns A promise that resolves when the update is complete. + */ + async updateSecretDataItem(params: { + itemId: string; + dataType: EncAccountDataType; + }): Promise { + return await this.#withControllerLock(async () => { + this.#assertIsUnlocked(); + + await this.#assertPasswordInSync({ + skipCache: true, + skipLock: true, // skip lock since we already have the lock + }); + + const performUpdate = async (): Promise => { + const { toprfAuthKeyPair } = await this.#unlockVaultAndGetVaultData(); + + try { + await this.toprfClient.updateSecretDataItem({ + itemId: params.itemId, + dataType: params.dataType, + authKeyPair: toprfAuthKeyPair, + }); + } catch (error) { + if (this.#isAuthTokenError(error)) { + throw error; + } + log('Error updating secret data item', error); + throw new Error( + SeedlessOnboardingControllerErrorMessage.FailedToUpdateSecretDataItem, + ); + } + }; + + await this.#executeWithTokenRefresh( + performUpdate, + 'updateSecretDataItem', + ); + }); + } + + /** + * Batch update fields for multiple existing secret data items by their itemIds. + * + * This method updates metadata fields for multiple existing secret data items + * without modifying the encrypted data itself. + * + * @param params - The parameters for batch updating secret data items. + * @param params.updates - Array of objects containing itemId and fields to update. + * @returns A promise that resolves when all updates are complete. + */ + async batchUpdateSecretDataItems(params: { + updates: { itemId: string; dataType: EncAccountDataType }[]; + }): Promise { + return await this.#withControllerLock(async () => { + this.#assertIsUnlocked(); + + await this.#assertPasswordInSync({ + skipCache: true, + skipLock: true, // skip lock since we already have the lock + }); + + const performBatchUpdate = async (): Promise => { + const { toprfAuthKeyPair } = await this.#unlockVaultAndGetVaultData(); + + try { + await this.toprfClient.batchUpdateSecretDataItems({ + updateItems: params.updates, + authKeyPair: toprfAuthKeyPair, + }); + } catch (error) { + if (this.#isAuthTokenError(error)) { + throw error; + } + log('Error batch updating secret data items', error); + throw new Error( + SeedlessOnboardingControllerErrorMessage.FailedToBatchUpdateSecretDataItems, + ); + } + }; + + await this.#executeWithTokenRefresh( + performBatchUpdate, + 'batchUpdateSecretDataItems', + ); + }); + } + + /** + * Fetches all secret data items from the metadata store. * * Decrypts the secret data and returns the decrypted secret data using the recovered encryption key from the password. * * @param password - The optional password used to create new wallet. If not provided, `cached Encryption Key` will be used. - * @returns A promise that resolves to the secret data. + * @returns A promise that resolves to the secret data items with metadata. */ - async fetchAllSecretData(password?: string): Promise { + async fetchAllSecretData( + password?: string, + ): Promise { return await this.#withControllerLock(async () => { return await this.#executeWithTokenRefresh(async () => { // assert that the user is authenticated before fetching the secret data @@ -1161,11 +1268,11 @@ export class SeedlessOnboardingController< async #fetchAllSecretDataFromMetadataStore( encKey: Uint8Array, authKeyPair: KeyPair, - ) { - let secretData: Uint8Array[] = []; + ): Promise { + let secretDataItems: FetchedSecretDataItem[] = []; try { // fetch and decrypt the secret data from the metadata store - secretData = await this.toprfClient.fetchAllSecretDataItems({ + secretDataItems = await this.toprfClient.fetchAllSecretDataItems({ decKey: encKey, authKeyPair, }); @@ -1180,16 +1287,27 @@ export class SeedlessOnboardingController< } // user must have at least one secret data - if (secretData?.length > 0) { - const secrets = SecretMetadata.parseSecretsFromMetadataStore(secretData); + if (secretDataItems?.length > 0) { + const results: SecretDataItemWithMetadata[] = secretDataItems.map( + (item) => ({ + secret: SecretMetadata.fromRawMetadata(item.data), + itemId: item.itemId, + dataType: item.dataType, + }), + ); + + // Sort by timestamp (oldest first) + results.sort((a, b) => + SecretMetadata.compareByTimestamp(a.secret, b.secret, 'asc'), + ); + // validate the primary secret data is a mnemonic (SRP) - const primarySecret = secrets[0]; - if (primarySecret.type !== SecretType.Mnemonic) { + if (!SecretMetadata.matchesType(results[0].secret, SecretType.Mnemonic)) { throw new Error( SeedlessOnboardingControllerErrorMessage.InvalidPrimarySecretDataType, ); } - return secrets; + return results; } throw new Error(SeedlessOnboardingControllerErrorMessage.NoSecretDataFound); @@ -1258,6 +1376,7 @@ export class SeedlessOnboardingController< * @param params.authKeyPair - The authentication key pair to store. * @param params.options - Optional options object, which includes optional data to be added to the metadata store. * @param params.options.keyringId - The keyring id of the backup keyring (SRP). + * @param params.options.dataType - Optional data type for categorizing the secret data. * * @returns A promise that resolves to the success of the operation. */ @@ -1268,6 +1387,7 @@ export class SeedlessOnboardingController< authKeyPair: KeyPair; options?: { keyringId?: string; + dataType?: EncAccountDataType; }; }): Promise { const { options, data, encKey, authKeyPair, type } = params; @@ -1296,6 +1416,7 @@ export class SeedlessOnboardingController< encKey, secretData, authKeyPair, + dataType: options?.dataType, }); return { keyringId, diff --git a/packages/seedless-onboarding-controller/src/constants.ts b/packages/seedless-onboarding-controller/src/constants.ts index 4ceb62c3930..f36de7583b5 100644 --- a/packages/seedless-onboarding-controller/src/constants.ts +++ b/packages/seedless-onboarding-controller/src/constants.ts @@ -60,4 +60,6 @@ export enum SeedlessOnboardingControllerErrorMessage { FailedToFetchAuthPubKey = `${controllerName} - Failed to fetch latest auth pub key`, InvalidPasswordOutdatedCache = `${controllerName} - Invalid password outdated cache provided.`, FailedToRefreshJWTTokens = `${controllerName} - Failed to refresh JWT tokens`, + FailedToUpdateSecretDataItem = `${controllerName} - Failed to update secret data item`, + FailedToBatchUpdateSecretDataItems = `${controllerName} - Failed to batch update secret data items`, } diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 4d445795530..7de8e7e63c6 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -14,6 +14,7 @@ export type { SeedlessOnboardingControllerEvents, ToprfKeyDeriver, RecoveryErrorData, + SecretDataItemWithMetadata, } from './types'; export { Web3AuthNetwork, @@ -23,3 +24,5 @@ export { } from './constants'; export { SecretMetadata } from './SecretMetadata'; export { RecoveryError } from './errors'; + +export { EncAccountDataType } from '@metamask/toprf-secure-backup'; diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index cf471a4344d..be727391376 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -4,7 +4,11 @@ import type { } from '@metamask/base-controller'; import type { Encryptor } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; -import type { KeyPair, NodeAuthTokens } from '@metamask/toprf-secure-backup'; +import type { + KeyPair, + NodeAuthTokens, + EncAccountDataType, +} from '@metamask/toprf-secure-backup'; import type { MutexInterface } from 'async-mutex'; import type { @@ -14,6 +18,29 @@ import type { SecretType, Web3AuthNetwork, } from './constants'; +import type { SecretMetadata } from './SecretMetadata'; + +/** + * A secret data item with storage-level metadata from the metadata store. + */ +export type SecretDataItemWithMetadata< + DataType extends SecretDataType = SecretDataType, +> = { + /** + * The parsed secret metadata (serialized data). + */ + secret: SecretMetadata; + + /** + * The server-assigned item ID for this row. + */ + itemId?: string; + + /** + * The client-assigned data type classification. + */ + dataType?: EncAccountDataType; +}; /** * The backup state of the secret data. diff --git a/packages/seedless-onboarding-controller/tests/mocks/toprf.ts b/packages/seedless-onboarding-controller/tests/mocks/toprf.ts index caeecb5304e..b35e5f68621 100644 --- a/packages/seedless-onboarding-controller/tests/mocks/toprf.ts +++ b/packages/seedless-onboarding-controller/tests/mocks/toprf.ts @@ -1,5 +1,7 @@ +import { EncAccountDataType } from '@metamask/toprf-secure-backup'; + import { MockToprfEncryptorDecryptor } from './toprfEncryptor'; -import type { SecretType } from '../../src/constants'; +import { SecretType } from '../../src/constants'; export const TOPRF_BASE_URL = /https:\/\/node-[1-5]\.dev-node\.web3auth\.io/u; @@ -56,17 +58,34 @@ export const MULTIPLE_MOCK_SECRET_METADATA = [ { data: new Uint8Array(Buffer.from('seedPhrase1', 'utf-8')), timestamp: 10, + type: SecretType.Mnemonic, + itemId: 'srp-1', + dataType: EncAccountDataType.PrimarySrp, }, { data: new Uint8Array(Buffer.from('seedPhrase3', 'utf-8')), timestamp: 60, + type: SecretType.Mnemonic, + itemId: 'srp-3', + dataType: EncAccountDataType.ImportedSrp, }, { data: new Uint8Array(Buffer.from('seedPhrase2', 'utf-8')), timestamp: 20, + type: SecretType.Mnemonic, + itemId: 'srp-2', + dataType: EncAccountDataType.ImportedSrp, }, ]; +type MockSecretDataInput = { + data: Uint8Array; + timestamp?: number; + type?: SecretType; + itemId: string; + dataType: EncAccountDataType; +}; + /** * Creates a mock secret data get response * @@ -74,30 +93,25 @@ export const MULTIPLE_MOCK_SECRET_METADATA = [ * @param password - The password to be used * @returns The mock secret data get response */ -export function createMockSecretDataGetResponse< - T extends - | Uint8Array - | { data: Uint8Array; timestamp?: number; type?: SecretType }, ->(secretDataArr: T[], password: string) { +export function createMockSecretDataGetResponse( + secretDataArr: MockSecretDataInput[], + password: string, +) { const mockToprfEncryptor = new MockToprfEncryptorDecryptor(); const ids: string[] = []; + const dataTypes: EncAccountDataType[] = []; const encryptedSecretData = secretDataArr.map((secretData) => { - let b64SecretData: string; - let timestamp = Date.now(); - let type: SecretType | undefined; - if (secretData instanceof Uint8Array) { - b64SecretData = Buffer.from(secretData).toString('base64'); - } else { - b64SecretData = Buffer.from(secretData.data).toString('base64'); - timestamp = secretData.timestamp || Date.now(); - type = secretData.type; - } + const b64SecretData = Buffer.from(secretData.data).toString('base64'); + const timestamp = secretData.timestamp ?? Date.now(); + + ids.push(secretData.itemId); + dataTypes.push(secretData.dataType); const metadata = JSON.stringify({ data: b64SecretData, timestamp, - type, + type: secretData.type, }); return mockToprfEncryptor.encrypt( @@ -106,11 +120,10 @@ export function createMockSecretDataGetResponse< ); }); - const jsonData = { + return { success: true, data: encryptedSecretData, ids, + dataTypes, }; - - return jsonData; } From 85275f56407e4d0342f74565b587c83c379da193 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Tue, 2 Dec 2025 20:09:25 +0800 Subject: [PATCH 2/8] refactor: Removed default dataType parameter from createToprfKeyAndBackupSeedPhrase method --- packages/seedless-onboarding-controller/CHANGELOG.md | 1 - .../src/SeedlessOnboardingController.ts | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index c861f26dcfd..b78949d48ee 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `dataType` parameter to `createToprfKeyAndBackupSeedPhrase` method (defaults to `PrimarySrp` for backward compatibility) - Add optional `dataType` parameter to `addNewSecretData` method for categorizing secret data on insert - Add `updateSecretDataItem` method to update fields for existing items by `itemId` - Add `batchUpdateSecretDataItems` method to batch update fields for multiple items diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 81ee83e8360..0ca79852af2 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -441,14 +441,12 @@ export class SeedlessOnboardingController< * @param password - The password used to create new wallet and seedphrase * @param seedPhrase - The initial seed phrase (Mnemonic) created together with the wallet. * @param keyringId - The keyring id of the backup seed phrase - * @param dataType - Optional data type for categorizing the secret data. Defaults to PrimarySrp. * @returns A promise that resolves to the encrypted seed phrase and the encryption key. */ async createToprfKeyAndBackupSeedPhrase( password: string, seedPhrase: Uint8Array, keyringId: string, - dataType: EncAccountDataType = EncAccountDataType.PrimarySrp, ): Promise { return await this.#withControllerLock(async () => { // to make sure that fail fast, @@ -469,7 +467,7 @@ export class SeedlessOnboardingController< authKeyPair, options: { keyringId, - dataType, + dataType: EncAccountDataType.PrimarySrp, }, }); From fc73107ccdf8d53ebdd56b5ba0bee5c87592136b Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Tue, 2 Dec 2025 20:14:22 +0800 Subject: [PATCH 3/8] refactor: update documentation for secret data item methods --- .../src/SeedlessOnboardingController.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 0ca79852af2..a7c464a68bf 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -538,14 +538,17 @@ export class SeedlessOnboardingController< } /** - * Update fields for an existing secret data item by itemId. + * Update the dataType for an existing secret data item. * - * This method updates metadata fields for an existing secret data item - * without modifying the encrypted data itself. + * This is used for migrating legacy data that was stored before the dataType + * field was introduced. When users created wallets with older SDK versions, + * their secrets were stored without dataType classification. This method allows + * clients to retroactively assign the correct dataType (e.g., PrimarySrp, + * ImportedSrp) when the wallet is unlocked. * * @param params - The parameters for updating the secret data item. - * @param params.itemId - The item ID of the secret data to update. - * @param params.dataType - The data type to set for the item. + * @param params.itemId - The server-assigned item ID from fetchAllSecretData. + * @param params.dataType - The data type classification to assign. * @returns A promise that resolves when the update is complete. */ async updateSecretDataItem(params: { @@ -588,13 +591,14 @@ export class SeedlessOnboardingController< } /** - * Batch update fields for multiple existing secret data items by their itemIds. + * Batch update the dataType for multiple existing secret data items. * - * This method updates metadata fields for multiple existing secret data items - * without modifying the encrypted data itself. + * This is the batch version of updateSecretDataItem, used for migrating + * multiple legacy secrets in a single operation. Useful when a user with + * multiple SRPs/private keys upgrades from an older SDK version. * * @param params - The parameters for batch updating secret data items. - * @param params.updates - Array of objects containing itemId and fields to update. + * @param params.updates - Array of objects containing itemId and dataType to assign. * @returns A promise that resolves when all updates are complete. */ async batchUpdateSecretDataItems(params: { From 3afa87907945bad3407d16c50e75cc646365fe40 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Tue, 2 Dec 2025 20:21:42 +0800 Subject: [PATCH 4/8] refactor: enhance primary secret data validation in SeedlessOnboardingController --- .../src/SeedlessOnboardingController.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index a7c464a68bf..df94b079ed4 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1303,8 +1303,17 @@ export class SeedlessOnboardingController< SecretMetadata.compareByTimestamp(a.secret, b.secret, 'asc'), ); - // validate the primary secret data is a mnemonic (SRP) - if (!SecretMetadata.matchesType(results[0].secret, SecretType.Mnemonic)) { + // Validate the first item is the primary SRP + const firstItem = results[0]; + const isDataTypePrimary = + firstItem.dataType === undefined || // Legacy data (before dataType was introduced) + firstItem.dataType === EncAccountDataType.PrimarySrp; + const isMnemonic = SecretMetadata.matchesType( + firstItem.secret, + SecretType.Mnemonic, + ); + + if (!isDataTypePrimary || !isMnemonic) { throw new Error( SeedlessOnboardingControllerErrorMessage.InvalidPrimarySecretDataType, ); From 90d642bbffbc8bb5652664855f032acdc4eeb0dc Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:48:53 +0800 Subject: [PATCH 5/8] fix: Update sorting mechanism to prioritize PrimarySrp dataType over legacy data --- .../src/SeedlessOnboardingController.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index df94b079ed4..fa4976b20c0 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1298,10 +1298,20 @@ export class SeedlessOnboardingController< }), ); - // Sort by timestamp (oldest first) - results.sort((a, b) => - SecretMetadata.compareByTimestamp(a.secret, b.secret, 'asc'), - ); + // Sort: PrimarySrp first, then by timestamp (oldest first) + // - New data: has explicit dataType, PrimarySrp should be first + // - Legacy data: no dataType, fall back to timestamp ordering + results.sort((a, b) => { + // PrimarySrp always comes first + if (a.dataType === EncAccountDataType.PrimarySrp) { + return -1; + } + if (b.dataType === EncAccountDataType.PrimarySrp) { + return 1; + } + // Fall back to timestamp ordering (oldest first) + return SecretMetadata.compareByTimestamp(a.secret, b.secret, 'asc'); + }); // Validate the first item is the primary SRP const firstItem = results[0]; From ac52a760e7cee3ba9d0916db951ddde53996882b Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:42:18 +0800 Subject: [PATCH 6/8] feat(seedless-onboarding-controller): add createdAt field and sort by PrimarySrp first, then createdAt --- .../CHANGELOG.md | 2 +- .../src/SeedlessOnboardingController.test.ts | 177 +++++++++++++++++- .../src/SeedlessOnboardingController.ts | 11 +- .../src/types.ts | 5 + .../tests/mocks/toprf.ts | 13 +- 5 files changed, 197 insertions(+), 11 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index b78949d48ee..e21c72a5927 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add optional `dataType` parameter to `addNewSecretData` method for categorizing secret data on insert - Add `updateSecretDataItem` method to update fields for existing items by `itemId` - Add `batchUpdateSecretDataItems` method to batch update fields for multiple items -- Add `SecretDataItemWithMetadata` type for storage-level metadata (`itemId`, `dataType`) +- Add `SecretDataItemWithMetadata` type for storage-level metadata (`itemId`, `dataType`, `createdAt`) - Add `SecretMetadata.compareByTimestamp` static method for comparing metadata by timestamp - Add `SecretMetadata.matchesType` static method for checking if metadata matches a given type - Re-export `EncAccountDataType` from `@metamask/toprf-secure-backup` diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index a56e663f428..9535e520a23 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -2136,12 +2136,14 @@ describe('SeedlessOnboardingController', () => { type: SecretType.Mnemonic, itemId: 'srp-item-id', dataType: EncAccountDataType.PrimarySrp, + createdAt: '00000000-0000-1000-8000-000000000001', }, { data: MOCK_PRIVATE_KEY, type: SecretType.PrivateKey, itemId: 'pk-item-id', dataType: EncAccountDataType.ImportedPrivateKey, + createdAt: '00000000-0000-1000-8000-000000000002', }, ], MOCK_PASSWORD, @@ -2162,10 +2164,16 @@ describe('SeedlessOnboardingController', () => { // Verify storage metadata expect(secretData[0].itemId).toBe('srp-item-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); + expect(secretData[0].createdAt).toBe( + '00000000-0000-1000-8000-000000000001', + ); expect(secretData[1].itemId).toBe('pk-item-id'); expect(secretData[1].dataType).toBe( EncAccountDataType.ImportedPrivateKey, ); + expect(secretData[1].createdAt).toBe( + '00000000-0000-1000-8000-000000000002', + ); expect(controller.state.vault).toBeDefined(); expect(controller.state.vault).not.toStrictEqual(initialState.vault); @@ -2226,22 +2234,31 @@ describe('SeedlessOnboardingController', () => { ), ).toBe(true); - // Sorted by timestamp ascending: seedPhrase1 (10), seedPhrase2 (20), seedPhrase3 (60) + // Sorted: PrimarySrp first, then by createdAt expect(secretData[0].secret.data).toStrictEqual( stringToBytes('seedPhrase1'), ); expect(secretData[0].itemId).toBe('srp-1'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); + expect(secretData[0].createdAt).toBe( + '00000000-0000-1000-8000-000000000001', + ); expect(secretData[1].secret.data).toStrictEqual( stringToBytes('seedPhrase2'), ); expect(secretData[1].itemId).toBe('srp-2'); expect(secretData[1].dataType).toBe(EncAccountDataType.ImportedSrp); + expect(secretData[1].createdAt).toBe( + '00000000-0000-1000-8000-000000000002', + ); expect(secretData[2].secret.data).toStrictEqual( stringToBytes('seedPhrase3'), ); expect(secretData[2].itemId).toBe('srp-3'); expect(secretData[2].dataType).toBe(EncAccountDataType.ImportedSrp); + expect(secretData[2].createdAt).toBe( + '00000000-0000-1000-8000-000000000003', + ); // verify the vault data const { encryptedMockVault } = await createMockVault( @@ -2294,6 +2311,7 @@ describe('SeedlessOnboardingController', () => { type: SecretType.Mnemonic, itemId: 'primary-srp-id', dataType: EncAccountDataType.PrimarySrp, + createdAt: '00000000-0000-1000-8000-000000000001', }, ], MOCK_PASSWORD, @@ -2307,6 +2325,9 @@ describe('SeedlessOnboardingController', () => { expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); expect(secretData[0].itemId).toBe('primary-srp-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); + expect(secretData[0].createdAt).toBe( + '00000000-0000-1000-8000-000000000001', + ); expect(controller.state.vault).toBeDefined(); expect(controller.state.vault).not.toBe(initialState.vault); @@ -2452,6 +2473,7 @@ describe('SeedlessOnboardingController', () => { type: SecretType.Mnemonic, itemId: 'primary-srp-id', dataType: EncAccountDataType.PrimarySrp, + createdAt: '00000000-0000-1000-8000-000000000001', }, ], MOCK_PASSWORD, @@ -2467,6 +2489,9 @@ describe('SeedlessOnboardingController', () => { expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); expect(secretData[0].itemId).toBe('primary-srp-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); + expect(secretData[0].createdAt).toBe( + '00000000-0000-1000-8000-000000000001', + ); }, ); }); @@ -2692,6 +2717,146 @@ describe('SeedlessOnboardingController', () => { }, ); }); + + it('should sort PrimarySrp first regardless of createdAt order', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // PrimarySrp has later createdAt but should still come first + const mockSecretDataGet = handleMockSecretDataGet({ + status: 200, + body: createMockSecretDataGetResponse( + [ + { + data: new Uint8Array(Buffer.from('importedSrp', 'utf-8')), + type: SecretType.Mnemonic, + itemId: 'imported-srp-id', + dataType: EncAccountDataType.ImportedSrp, + createdAt: '00000000-0000-1000-8000-000000000001', // Earlier + }, + { + data: new Uint8Array(Buffer.from('primarySrp', 'utf-8')), + type: SecretType.Mnemonic, + itemId: 'primary-srp-id', + dataType: EncAccountDataType.PrimarySrp, + createdAt: '00000000-0000-1000-8000-000000000002', // Later + }, + ], + MOCK_PASSWORD, + ), + }); + + const secretData = await controller.fetchAllSecretData(MOCK_PASSWORD); + + expect(mockSecretDataGet.isDone()).toBe(true); + expect(secretData).toHaveLength(2); + // PrimarySrp should be first despite having later createdAt + expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); + expect(secretData[0].secret.data).toStrictEqual( + stringToBytes('primarySrp'), + ); + expect(secretData[1].dataType).toBe(EncAccountDataType.ImportedSrp); + expect(secretData[1].secret.data).toStrictEqual( + stringToBytes('importedSrp'), + ); + }, + ); + }); + + it('should fall back to timestamp sorting when createdAt is null (legacy data)', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // Legacy data without createdAt - should sort by timestamp + const mockSecretDataGet = handleMockSecretDataGet({ + status: 200, + body: createMockSecretDataGetResponse( + [ + { + data: new Uint8Array(Buffer.from('srp2', 'utf-8')), + timestamp: 200, // Later + type: SecretType.Mnemonic, + itemId: 'srp-2', + // No dataType or createdAt (legacy) + }, + { + data: new Uint8Array(Buffer.from('srp1', 'utf-8')), + timestamp: 100, // Earlier + type: SecretType.Mnemonic, + itemId: 'srp-1', + // No dataType or createdAt (legacy) + }, + ], + MOCK_PASSWORD, + ), + }); + + const secretData = await controller.fetchAllSecretData(MOCK_PASSWORD); + + expect(mockSecretDataGet.isDone()).toBe(true); + expect(secretData).toHaveLength(2); + // Should be sorted by timestamp (oldest first) + expect(secretData[0].secret.data).toStrictEqual( + stringToBytes('srp1'), + ); + expect(secretData[0].dataType).toBeUndefined(); + expect(secretData[0].createdAt).toBeUndefined(); + expect(secretData[1].secret.data).toStrictEqual( + stringToBytes('srp2'), + ); + }, + ); + }); + + it('should throw an error if the first item has non-primary dataType', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient }) => { + mockRecoverEncKey(toprfClient, MOCK_PASSWORD); + + // ImportedSrp as first item (no PrimarySrp) - should throw + const mockSecretDataGet = handleMockSecretDataGet({ + status: 200, + body: createMockSecretDataGetResponse( + [ + { + data: MOCK_SEED_PHRASE, + type: SecretType.Mnemonic, + itemId: 'imported-srp-id', + dataType: EncAccountDataType.ImportedSrp, + createdAt: '00000000-0000-1000-8000-000000000001', + }, + ], + MOCK_PASSWORD, + ), + }); + + await expect( + controller.fetchAllSecretData(MOCK_PASSWORD), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidPrimarySecretDataType, + ); + + expect(mockSecretDataGet.isDone()).toBe(true); + }, + ); + }); }); describe('submitPassword', () => { @@ -5720,6 +5885,7 @@ describe('SeedlessOnboardingController', () => { ), itemId: 'primary-srp-id', dataType: EncAccountDataType.PrimarySrp, + createdAt: '00000000-0000-1000-8000-000000000001', }, { data: stringToBytes( @@ -5732,6 +5898,7 @@ describe('SeedlessOnboardingController', () => { ), itemId: 'pk-id', dataType: EncAccountDataType.ImportedPrivateKey, + createdAt: '00000000-0000-1000-8000-000000000002', }, ]); @@ -5742,6 +5909,9 @@ describe('SeedlessOnboardingController', () => { expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); expect(secretData[0].itemId).toBe('primary-srp-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); + expect(secretData[0].createdAt).toBe( + '00000000-0000-1000-8000-000000000001', + ); expect(secretData[1].secret.type).toStrictEqual( SecretType.PrivateKey, ); @@ -5750,8 +5920,9 @@ describe('SeedlessOnboardingController', () => { expect(secretData[1].dataType).toBe( EncAccountDataType.ImportedPrivateKey, ); - - // expect(mockSecretDataGet.isDone()).toBe(true); + expect(secretData[1].createdAt).toBe( + '00000000-0000-1000-8000-000000000002', + ); }, ); }); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index fa4976b20c0..0be9fdfbf3b 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1295,12 +1295,11 @@ export class SeedlessOnboardingController< secret: SecretMetadata.fromRawMetadata(item.data), itemId: item.itemId, dataType: item.dataType, + createdAt: item.createdAt, }), ); - // Sort: PrimarySrp first, then by timestamp (oldest first) - // - New data: has explicit dataType, PrimarySrp should be first - // - Legacy data: no dataType, fall back to timestamp ordering + // Sort: PrimarySrp first, then by createdAt/timestamp (oldest first) results.sort((a, b) => { // PrimarySrp always comes first if (a.dataType === EncAccountDataType.PrimarySrp) { @@ -1309,7 +1308,11 @@ export class SeedlessOnboardingController< if (b.dataType === EncAccountDataType.PrimarySrp) { return 1; } - // Fall back to timestamp ordering (oldest first) + // Use server-side createdAt if available (TIMEUUID is lexicographically sortable) + if (a.createdAt && b.createdAt) { + return a.createdAt.localeCompare(b.createdAt); + } + // Fall back to client-side timestamp return SecretMetadata.compareByTimestamp(a.secret, b.secret, 'asc'); }); diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index be727391376..abc13d0fbe6 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -40,6 +40,11 @@ export type SecretDataItemWithMetadata< * The client-assigned data type classification. */ dataType?: EncAccountDataType; + + /** + * The server-assigned creation timestamp (TIMEUUID string). + */ + createdAt?: string; }; /** diff --git a/packages/seedless-onboarding-controller/tests/mocks/toprf.ts b/packages/seedless-onboarding-controller/tests/mocks/toprf.ts index b35e5f68621..2c2a6240beb 100644 --- a/packages/seedless-onboarding-controller/tests/mocks/toprf.ts +++ b/packages/seedless-onboarding-controller/tests/mocks/toprf.ts @@ -61,6 +61,7 @@ export const MULTIPLE_MOCK_SECRET_METADATA = [ type: SecretType.Mnemonic, itemId: 'srp-1', dataType: EncAccountDataType.PrimarySrp, + createdAt: '00000000-0000-1000-8000-000000000001', }, { data: new Uint8Array(Buffer.from('seedPhrase3', 'utf-8')), @@ -68,6 +69,7 @@ export const MULTIPLE_MOCK_SECRET_METADATA = [ type: SecretType.Mnemonic, itemId: 'srp-3', dataType: EncAccountDataType.ImportedSrp, + createdAt: '00000000-0000-1000-8000-000000000003', }, { data: new Uint8Array(Buffer.from('seedPhrase2', 'utf-8')), @@ -75,6 +77,7 @@ export const MULTIPLE_MOCK_SECRET_METADATA = [ type: SecretType.Mnemonic, itemId: 'srp-2', dataType: EncAccountDataType.ImportedSrp, + createdAt: '00000000-0000-1000-8000-000000000002', }, ]; @@ -83,7 +86,8 @@ type MockSecretDataInput = { timestamp?: number; type?: SecretType; itemId: string; - dataType: EncAccountDataType; + dataType?: EncAccountDataType; + createdAt?: string; }; /** @@ -99,14 +103,16 @@ export function createMockSecretDataGetResponse( ) { const mockToprfEncryptor = new MockToprfEncryptorDecryptor(); const ids: string[] = []; - const dataTypes: EncAccountDataType[] = []; + const dataTypes: (EncAccountDataType | null)[] = []; + const createdAt: (string | null)[] = []; const encryptedSecretData = secretDataArr.map((secretData) => { const b64SecretData = Buffer.from(secretData.data).toString('base64'); const timestamp = secretData.timestamp ?? Date.now(); ids.push(secretData.itemId); - dataTypes.push(secretData.dataType); + dataTypes.push(secretData.dataType ?? null); + createdAt.push(secretData.createdAt ?? null); const metadata = JSON.stringify({ data: b64SecretData, @@ -125,5 +131,6 @@ export function createMockSecretDataGetResponse( data: encryptedSecretData, ids, dataTypes, + createdAt, }; } From 11c09f898346bb4107164f7d46b967dbc1b8fb13 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:35:36 +0800 Subject: [PATCH 7/8] feat(seedless-onboarding-controller): add storage metadata to SecretMetadata - Add itemId, dataType, createdAt properties to SecretMetadata class - Remove SecretDataItemWithMetadata wrapper type - Update fetchAllSecretData to return SecretMetadata[] directly - Add tests for storage metadata properties --- .../CHANGELOG.md | 4 +- .../src/SecretMetadata.ts | 56 ++++++- .../src/SeedlessOnboardingController.test.ts | 142 +++++++++++++----- .../src/SeedlessOnboardingController.ts | 18 +-- .../src/index.ts | 1 - .../src/types.ts | 34 +---- 6 files changed, 167 insertions(+), 88 deletions(-) diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index e21c72a5927..f6be86feebb 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -12,15 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add optional `dataType` parameter to `addNewSecretData` method for categorizing secret data on insert - Add `updateSecretDataItem` method to update fields for existing items by `itemId` - Add `batchUpdateSecretDataItems` method to batch update fields for multiple items -- Add `SecretDataItemWithMetadata` type for storage-level metadata (`itemId`, `dataType`, `createdAt`) +- Add `itemId`, `dataType`, and `createdAt` storage-level properties to `SecretMetadata` - Add `SecretMetadata.compareByTimestamp` static method for comparing metadata by timestamp - Add `SecretMetadata.matchesType` static method for checking if metadata matches a given type - Re-export `EncAccountDataType` from `@metamask/toprf-secure-backup` ### Changed -- **BREAKING:** Update `fetchAllSecretData` to return `SecretDataItemWithMetadata[]` instead of `SecretMetadata[]` - - Consumers must now access secret data via the `secret` property (e.g., `result[0].secret.data` instead of `result[0].data`) - **BREAKING:** Remove `parseSecretsFromMetadataStore`, `fromBatch`, and `sort` methods from `SecretMetadata` - Use `SecretMetadata.compareByTimestamp` for sorting - Use `SecretMetadata.matchesType` for filtering diff --git a/packages/seedless-onboarding-controller/src/SecretMetadata.ts b/packages/seedless-onboarding-controller/src/SecretMetadata.ts index c5f419da48b..5e27fe557d5 100644 --- a/packages/seedless-onboarding-controller/src/SecretMetadata.ts +++ b/packages/seedless-onboarding-controller/src/SecretMetadata.ts @@ -1,3 +1,4 @@ +import type { EncAccountDataType } from '@metamask/toprf-secure-backup'; import { base64ToBytes, bytesToBase64, @@ -42,6 +43,15 @@ type SecretMetadataJson = Omit< * const secretMetadata = new SecretMetadata(secret); * ``` */ +/** + * Storage-level metadata from the metadata store (not encrypted). + */ +type StorageMetadata = { + itemId?: string; + dataType?: EncAccountDataType; + createdAt?: string; +}; + export class SecretMetadata implements ISecretMetadata { @@ -53,6 +63,13 @@ export class SecretMetadata readonly #version: SecretMetadataVersion; + // Storage-level metadata (not encrypted) + readonly #itemId?: string; + + readonly #dataType?: EncAccountDataType; + + readonly #createdAt?: string; + /** * Create a new SecretMetadata instance. * @@ -60,12 +77,21 @@ export class SecretMetadata * @param options - The options for the secret metadata. * @param options.timestamp - The timestamp when the secret was created. * @param options.type - The type of the secret. + * @param options.version - The version of the secret metadata. + * @param storageMetadata - Storage-level metadata from the metadata store. */ - constructor(data: DataType, options?: Partial) { + constructor( + data: DataType, + options?: Partial, + storageMetadata?: StorageMetadata, + ) { this.#data = data; this.#timestamp = options?.timestamp ?? Date.now(); this.#type = options?.type ?? SecretType.Mnemonic; this.#version = options?.version ?? SecretMetadataVersion.V1; + this.#itemId = storageMetadata?.itemId; + this.#dataType = storageMetadata?.dataType; + this.#createdAt = storageMetadata?.createdAt; } /** @@ -95,10 +121,12 @@ export class SecretMetadata * Parse and create the SecretMetadata instance from the raw metadata bytes. * * @param rawMetadata - The raw metadata. + * @param storageMetadata - Storage-level metadata from the metadata store. * @returns The parsed secret metadata. */ static fromRawMetadata( rawMetadata: Uint8Array, + storageMetadata?: StorageMetadata, ): SecretMetadata { const serializedMetadata = bytesToString(rawMetadata); const parsedMetadata = JSON.parse(serializedMetadata); @@ -116,11 +144,15 @@ export class SecretMetadata data = parsedMetadata.data as DataType; } - return new SecretMetadata(data, { - timestamp: parsedMetadata.timestamp, - type, - version, - }); + return new SecretMetadata( + data, + { + timestamp: parsedMetadata.timestamp, + type, + version, + }, + storageMetadata, + ); } /** @@ -171,6 +203,18 @@ export class SecretMetadata return this.#version; } + get itemId() { + return this.#itemId; + } + + get dataType() { + return this.#dataType; + } + + get createdAt() { + return this.#createdAt; + } + /** * Serialize the secret metadata and convert it to a Uint8Array. * diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index 9535e520a23..bf6d4d8316a 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -29,6 +29,7 @@ import { import { base64ToBytes, bytesToBase64, + bytesToString, stringToBytes, bigIntToHex, } from '@metamask/utils'; @@ -2155,12 +2156,10 @@ describe('SeedlessOnboardingController', () => { expect(secretData).toBeDefined(); expect(secretData).toHaveLength(2); // Verify secret metadata - expect(secretData[0].secret.type).toStrictEqual(SecretType.Mnemonic); - expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); - expect(secretData[1].secret.type).toStrictEqual( - SecretType.PrivateKey, - ); - expect(secretData[1].secret.data).toStrictEqual(MOCK_PRIVATE_KEY); + expect(secretData[0].type).toStrictEqual(SecretType.Mnemonic); + expect(secretData[0].data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[1].type).toStrictEqual(SecretType.PrivateKey); + expect(secretData[1].data).toStrictEqual(MOCK_PRIVATE_KEY); // Verify storage metadata expect(secretData[0].itemId).toBe('srp-item-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); @@ -2229,13 +2228,11 @@ describe('SeedlessOnboardingController', () => { expect(secretData).toHaveLength(3); expect( - secretData.every( - (item) => item.secret.type === SecretType.Mnemonic, - ), + secretData.every((item) => item.type === SecretType.Mnemonic), ).toBe(true); // Sorted: PrimarySrp first, then by createdAt - expect(secretData[0].secret.data).toStrictEqual( + expect(secretData[0].data).toStrictEqual( stringToBytes('seedPhrase1'), ); expect(secretData[0].itemId).toBe('srp-1'); @@ -2243,7 +2240,7 @@ describe('SeedlessOnboardingController', () => { expect(secretData[0].createdAt).toBe( '00000000-0000-1000-8000-000000000001', ); - expect(secretData[1].secret.data).toStrictEqual( + expect(secretData[1].data).toStrictEqual( stringToBytes('seedPhrase2'), ); expect(secretData[1].itemId).toBe('srp-2'); @@ -2251,7 +2248,7 @@ describe('SeedlessOnboardingController', () => { expect(secretData[1].createdAt).toBe( '00000000-0000-1000-8000-000000000002', ); - expect(secretData[2].secret.data).toStrictEqual( + expect(secretData[2].data).toStrictEqual( stringToBytes('seedPhrase3'), ); expect(secretData[2].itemId).toBe('srp-3'); @@ -2321,8 +2318,8 @@ describe('SeedlessOnboardingController', () => { expect(mockSecretDataGet.isDone()).toBe(true); expect(secretData).toBeDefined(); - expect(secretData[0].secret.type).toStrictEqual(SecretType.Mnemonic); - expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[0].type).toStrictEqual(SecretType.Mnemonic); + expect(secretData[0].data).toStrictEqual(MOCK_SEED_PHRASE); expect(secretData[0].itemId).toBe('primary-srp-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); expect(secretData[0].createdAt).toBe( @@ -2400,8 +2397,8 @@ describe('SeedlessOnboardingController', () => { expect(recoverEncKeySpy).toHaveBeenCalledTimes(2); // should call recoverEncKey twice for the first fail attempt due to token expired error and the second success attempt expect(authenticateSpy).toHaveBeenCalledTimes(1); // should call authenticate once for the token refresh - expect(secretData[0].secret.type).toStrictEqual(SecretType.Mnemonic); - expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[0].type).toStrictEqual(SecretType.Mnemonic); + expect(secretData[0].data).toStrictEqual(MOCK_SEED_PHRASE); expect(secretData[0].itemId).toBe('primary-srp-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); @@ -2485,8 +2482,8 @@ describe('SeedlessOnboardingController', () => { expect(mockSecretDataGet.isDone()).toBe(true); expect(secretData).toBeDefined(); expect(secretData).toHaveLength(1); - expect(secretData[0].secret.type).toStrictEqual(SecretType.Mnemonic); - expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[0].type).toStrictEqual(SecretType.Mnemonic); + expect(secretData[0].data).toStrictEqual(MOCK_SEED_PHRASE); expect(secretData[0].itemId).toBe('primary-srp-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); expect(secretData[0].createdAt).toBe( @@ -2758,11 +2755,9 @@ describe('SeedlessOnboardingController', () => { expect(secretData).toHaveLength(2); // PrimarySrp should be first despite having later createdAt expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); - expect(secretData[0].secret.data).toStrictEqual( - stringToBytes('primarySrp'), - ); + expect(secretData[0].data).toStrictEqual(stringToBytes('primarySrp')); expect(secretData[1].dataType).toBe(EncAccountDataType.ImportedSrp); - expect(secretData[1].secret.data).toStrictEqual( + expect(secretData[1].data).toStrictEqual( stringToBytes('importedSrp'), ); }, @@ -2808,14 +2803,10 @@ describe('SeedlessOnboardingController', () => { expect(mockSecretDataGet.isDone()).toBe(true); expect(secretData).toHaveLength(2); // Should be sorted by timestamp (oldest first) - expect(secretData[0].secret.data).toStrictEqual( - stringToBytes('srp1'), - ); + expect(secretData[0].data).toStrictEqual(stringToBytes('srp1')); expect(secretData[0].dataType).toBeUndefined(); expect(secretData[0].createdAt).toBeUndefined(); - expect(secretData[1].secret.data).toStrictEqual( - stringToBytes('srp2'), - ); + expect(secretData[1].data).toStrictEqual(stringToBytes('srp2')); }, ); }); @@ -3826,6 +3817,91 @@ describe('SeedlessOnboardingController', () => { expect(privateKeySecrets[0].data).toBe(mockPrivKeyString); expect(privateKeySecrets[0].type).toBe(SecretType.PrivateKey); }); + + it('should be able to create SecretMetadata with storage metadata', () => { + const storageMetadata = { + itemId: 'test-item-id', + dataType: EncAccountDataType.PrimarySrp, + createdAt: '00000000-0000-1000-8000-000000000001', + }; + + const secretMetadata = new SecretMetadata( + MOCK_SEED_PHRASE, + { type: SecretType.Mnemonic }, + storageMetadata, + ); + + expect(secretMetadata.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretMetadata.type).toBe(SecretType.Mnemonic); + expect(secretMetadata.itemId).toBe('test-item-id'); + expect(secretMetadata.dataType).toBe(EncAccountDataType.PrimarySrp); + expect(secretMetadata.createdAt).toBe( + '00000000-0000-1000-8000-000000000001', + ); + }); + + it('should have undefined storage metadata when not provided', () => { + const secretMetadata = new SecretMetadata(MOCK_SEED_PHRASE); + + expect(secretMetadata.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretMetadata.itemId).toBeUndefined(); + expect(secretMetadata.dataType).toBeUndefined(); + expect(secretMetadata.createdAt).toBeUndefined(); + }); + + it('should NOT serialize storage metadata in toBytes()', () => { + const storageMetadata = { + itemId: 'test-item-id', + dataType: EncAccountDataType.PrimarySrp, + createdAt: '00000000-0000-1000-8000-000000000001', + }; + + const secretMetadata = new SecretMetadata( + MOCK_SEED_PHRASE, + { type: SecretType.Mnemonic }, + storageMetadata, + ); + + const serializedBytes = secretMetadata.toBytes(); + const serializedString = bytesToString(serializedBytes); + const parsed = JSON.parse(serializedString); + + // Storage metadata should NOT be in serialized data + expect(parsed.itemId).toBeUndefined(); + expect(parsed.dataType).toBeUndefined(); + expect(parsed.createdAt).toBeUndefined(); + + // Only encrypted metadata should be present + expect(parsed.data).toBeDefined(); + expect(parsed.timestamp).toBeDefined(); + expect(parsed.type).toBe(SecretType.Mnemonic); + }); + + it('should be able to parse raw metadata with storage metadata', () => { + const originalMetadata = new SecretMetadata(MOCK_SEED_PHRASE, { + type: SecretType.Mnemonic, + }); + const serializedBytes = originalMetadata.toBytes(); + + const storageMetadata = { + itemId: 'server-assigned-id', + dataType: EncAccountDataType.ImportedSrp, + createdAt: '00000000-0000-1000-8000-000000000002', + }; + + const parsedMetadata = SecretMetadata.fromRawMetadata( + serializedBytes, + storageMetadata, + ); + + expect(parsedMetadata.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(parsedMetadata.type).toBe(SecretType.Mnemonic); + expect(parsedMetadata.itemId).toBe('server-assigned-id'); + expect(parsedMetadata.dataType).toBe(EncAccountDataType.ImportedSrp); + expect(parsedMetadata.createdAt).toBe( + '00000000-0000-1000-8000-000000000002', + ); + }); }); describe('store and recover keyring encryption key', () => { @@ -5905,17 +5981,15 @@ describe('SeedlessOnboardingController', () => { const secretData = await controller.fetchAllSecretData(MOCK_PASSWORD); expect(secretData).toBeDefined(); expect(secretData).toHaveLength(2); - expect(secretData[0].secret.type).toStrictEqual(SecretType.Mnemonic); - expect(secretData[0].secret.data).toStrictEqual(MOCK_SEED_PHRASE); + expect(secretData[0].type).toStrictEqual(SecretType.Mnemonic); + expect(secretData[0].data).toStrictEqual(MOCK_SEED_PHRASE); expect(secretData[0].itemId).toBe('primary-srp-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); expect(secretData[0].createdAt).toBe( '00000000-0000-1000-8000-000000000001', ); - expect(secretData[1].secret.type).toStrictEqual( - SecretType.PrivateKey, - ); - expect(secretData[1].secret.data).toStrictEqual(MOCK_PRIVATE_KEY); + expect(secretData[1].type).toStrictEqual(SecretType.PrivateKey); + expect(secretData[1].data).toStrictEqual(MOCK_PRIVATE_KEY); expect(secretData[1].itemId).toBe('pk-id'); expect(secretData[1].dataType).toBe( EncAccountDataType.ImportedPrivateKey, diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 0be9fdfbf3b..38203ddec7b 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -53,7 +53,6 @@ import type { RenewRefreshToken, VaultData, DeserializedVaultData, - SecretDataItemWithMetadata, } from './types'; import { decodeJWTToken, @@ -644,11 +643,9 @@ export class SeedlessOnboardingController< * Decrypts the secret data and returns the decrypted secret data using the recovered encryption key from the password. * * @param password - The optional password used to create new wallet. If not provided, `cached Encryption Key` will be used. - * @returns A promise that resolves to the secret data items with metadata. + * @returns A promise that resolves to the secret metadata items. */ - async fetchAllSecretData( - password?: string, - ): Promise { + async fetchAllSecretData(password?: string): Promise { return await this.#withControllerLock(async () => { return await this.#executeWithTokenRefresh(async () => { // assert that the user is authenticated before fetching the secret data @@ -1270,7 +1267,7 @@ export class SeedlessOnboardingController< async #fetchAllSecretDataFromMetadataStore( encKey: Uint8Array, authKeyPair: KeyPair, - ): Promise { + ): Promise { let secretDataItems: FetchedSecretDataItem[] = []; try { // fetch and decrypt the secret data from the metadata store @@ -1290,9 +1287,8 @@ export class SeedlessOnboardingController< // user must have at least one secret data if (secretDataItems?.length > 0) { - const results: SecretDataItemWithMetadata[] = secretDataItems.map( - (item) => ({ - secret: SecretMetadata.fromRawMetadata(item.data), + const results: SecretMetadata[] = secretDataItems.map((item) => + SecretMetadata.fromRawMetadata(item.data, { itemId: item.itemId, dataType: item.dataType, createdAt: item.createdAt, @@ -1313,7 +1309,7 @@ export class SeedlessOnboardingController< return a.createdAt.localeCompare(b.createdAt); } // Fall back to client-side timestamp - return SecretMetadata.compareByTimestamp(a.secret, b.secret, 'asc'); + return SecretMetadata.compareByTimestamp(a, b, 'asc'); }); // Validate the first item is the primary SRP @@ -1322,7 +1318,7 @@ export class SeedlessOnboardingController< firstItem.dataType === undefined || // Legacy data (before dataType was introduced) firstItem.dataType === EncAccountDataType.PrimarySrp; const isMnemonic = SecretMetadata.matchesType( - firstItem.secret, + firstItem, SecretType.Mnemonic, ); diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 7de8e7e63c6..1260798b1ca 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -14,7 +14,6 @@ export type { SeedlessOnboardingControllerEvents, ToprfKeyDeriver, RecoveryErrorData, - SecretDataItemWithMetadata, } from './types'; export { Web3AuthNetwork, diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index abc13d0fbe6..cf471a4344d 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -4,11 +4,7 @@ import type { } from '@metamask/base-controller'; import type { Encryptor } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; -import type { - KeyPair, - NodeAuthTokens, - EncAccountDataType, -} from '@metamask/toprf-secure-backup'; +import type { KeyPair, NodeAuthTokens } from '@metamask/toprf-secure-backup'; import type { MutexInterface } from 'async-mutex'; import type { @@ -18,34 +14,6 @@ import type { SecretType, Web3AuthNetwork, } from './constants'; -import type { SecretMetadata } from './SecretMetadata'; - -/** - * A secret data item with storage-level metadata from the metadata store. - */ -export type SecretDataItemWithMetadata< - DataType extends SecretDataType = SecretDataType, -> = { - /** - * The parsed secret metadata (serialized data). - */ - secret: SecretMetadata; - - /** - * The server-assigned item ID for this row. - */ - itemId?: string; - - /** - * The client-assigned data type classification. - */ - dataType?: EncAccountDataType; - - /** - * The server-assigned creation timestamp (TIMEUUID string). - */ - createdAt?: string; -}; /** * The backup state of the secret data. From 28853145061d5ed9958de17f5a0dc0645851ef26 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:33:10 +0800 Subject: [PATCH 8/8] fix(seedless-onboarding-controller): use timestamp extraction for TIMEUUID sorting TIMEUUID strings are not lexicographically sortable. Replace localeCompare with compareTimeuuid utility that extracts and compares actual timestamps. --- .../src/SeedlessOnboardingController.test.ts | 52 +++++++------- .../src/SeedlessOnboardingController.ts | 5 +- .../src/utils.test.ts | 68 ++++++++++++++++++- .../src/utils.ts | 47 +++++++++++++ .../tests/mocks/toprf.ts | 6 +- 5 files changed, 146 insertions(+), 32 deletions(-) diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index bf6d4d8316a..06c26acc3bb 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -2137,14 +2137,14 @@ describe('SeedlessOnboardingController', () => { type: SecretType.Mnemonic, itemId: 'srp-item-id', dataType: EncAccountDataType.PrimarySrp, - createdAt: '00000000-0000-1000-8000-000000000001', + createdAt: '00000001-0000-1000-8000-000000000001', }, { data: MOCK_PRIVATE_KEY, type: SecretType.PrivateKey, itemId: 'pk-item-id', dataType: EncAccountDataType.ImportedPrivateKey, - createdAt: '00000000-0000-1000-8000-000000000002', + createdAt: '00000002-0000-1000-8000-000000000002', }, ], MOCK_PASSWORD, @@ -2164,14 +2164,14 @@ describe('SeedlessOnboardingController', () => { expect(secretData[0].itemId).toBe('srp-item-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); expect(secretData[0].createdAt).toBe( - '00000000-0000-1000-8000-000000000001', + '00000001-0000-1000-8000-000000000001', ); expect(secretData[1].itemId).toBe('pk-item-id'); expect(secretData[1].dataType).toBe( EncAccountDataType.ImportedPrivateKey, ); expect(secretData[1].createdAt).toBe( - '00000000-0000-1000-8000-000000000002', + '00000002-0000-1000-8000-000000000002', ); expect(controller.state.vault).toBeDefined(); @@ -2231,14 +2231,14 @@ describe('SeedlessOnboardingController', () => { secretData.every((item) => item.type === SecretType.Mnemonic), ).toBe(true); - // Sorted: PrimarySrp first, then by createdAt + // Sorted: PrimarySrp first, then by createdAt (TIMEUUID timestamp) expect(secretData[0].data).toStrictEqual( stringToBytes('seedPhrase1'), ); expect(secretData[0].itemId).toBe('srp-1'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); expect(secretData[0].createdAt).toBe( - '00000000-0000-1000-8000-000000000001', + '00000001-0000-1000-8000-000000000001', ); expect(secretData[1].data).toStrictEqual( stringToBytes('seedPhrase2'), @@ -2246,7 +2246,7 @@ describe('SeedlessOnboardingController', () => { expect(secretData[1].itemId).toBe('srp-2'); expect(secretData[1].dataType).toBe(EncAccountDataType.ImportedSrp); expect(secretData[1].createdAt).toBe( - '00000000-0000-1000-8000-000000000002', + '00000002-0000-1000-8000-000000000002', ); expect(secretData[2].data).toStrictEqual( stringToBytes('seedPhrase3'), @@ -2254,7 +2254,7 @@ describe('SeedlessOnboardingController', () => { expect(secretData[2].itemId).toBe('srp-3'); expect(secretData[2].dataType).toBe(EncAccountDataType.ImportedSrp); expect(secretData[2].createdAt).toBe( - '00000000-0000-1000-8000-000000000003', + '00000003-0000-1000-8000-000000000003', ); // verify the vault data @@ -2308,7 +2308,7 @@ describe('SeedlessOnboardingController', () => { type: SecretType.Mnemonic, itemId: 'primary-srp-id', dataType: EncAccountDataType.PrimarySrp, - createdAt: '00000000-0000-1000-8000-000000000001', + createdAt: '00000001-0000-1000-8000-000000000001', }, ], MOCK_PASSWORD, @@ -2323,7 +2323,7 @@ describe('SeedlessOnboardingController', () => { expect(secretData[0].itemId).toBe('primary-srp-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); expect(secretData[0].createdAt).toBe( - '00000000-0000-1000-8000-000000000001', + '00000001-0000-1000-8000-000000000001', ); expect(controller.state.vault).toBeDefined(); @@ -2470,7 +2470,7 @@ describe('SeedlessOnboardingController', () => { type: SecretType.Mnemonic, itemId: 'primary-srp-id', dataType: EncAccountDataType.PrimarySrp, - createdAt: '00000000-0000-1000-8000-000000000001', + createdAt: '00000001-0000-1000-8000-000000000001', }, ], MOCK_PASSWORD, @@ -2487,7 +2487,7 @@ describe('SeedlessOnboardingController', () => { expect(secretData[0].itemId).toBe('primary-srp-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); expect(secretData[0].createdAt).toBe( - '00000000-0000-1000-8000-000000000001', + '00000001-0000-1000-8000-000000000001', ); }, ); @@ -2735,14 +2735,14 @@ describe('SeedlessOnboardingController', () => { type: SecretType.Mnemonic, itemId: 'imported-srp-id', dataType: EncAccountDataType.ImportedSrp, - createdAt: '00000000-0000-1000-8000-000000000001', // Earlier + createdAt: '00000001-0000-1000-8000-000000000001', }, { data: new Uint8Array(Buffer.from('primarySrp', 'utf-8')), type: SecretType.Mnemonic, itemId: 'primary-srp-id', dataType: EncAccountDataType.PrimarySrp, - createdAt: '00000000-0000-1000-8000-000000000002', // Later + createdAt: '00000002-0000-1000-8000-000000000002', }, ], MOCK_PASSWORD, @@ -2781,14 +2781,14 @@ describe('SeedlessOnboardingController', () => { [ { data: new Uint8Array(Buffer.from('srp2', 'utf-8')), - timestamp: 200, // Later + timestamp: 200, type: SecretType.Mnemonic, itemId: 'srp-2', // No dataType or createdAt (legacy) }, { data: new Uint8Array(Buffer.from('srp1', 'utf-8')), - timestamp: 100, // Earlier + timestamp: 100, type: SecretType.Mnemonic, itemId: 'srp-1', // No dataType or createdAt (legacy) @@ -2831,7 +2831,7 @@ describe('SeedlessOnboardingController', () => { type: SecretType.Mnemonic, itemId: 'imported-srp-id', dataType: EncAccountDataType.ImportedSrp, - createdAt: '00000000-0000-1000-8000-000000000001', + createdAt: '00000001-0000-1000-8000-000000000001', }, ], MOCK_PASSWORD, @@ -3822,7 +3822,7 @@ describe('SeedlessOnboardingController', () => { const storageMetadata = { itemId: 'test-item-id', dataType: EncAccountDataType.PrimarySrp, - createdAt: '00000000-0000-1000-8000-000000000001', + createdAt: '00000001-0000-1000-8000-000000000001', }; const secretMetadata = new SecretMetadata( @@ -3836,7 +3836,7 @@ describe('SeedlessOnboardingController', () => { expect(secretMetadata.itemId).toBe('test-item-id'); expect(secretMetadata.dataType).toBe(EncAccountDataType.PrimarySrp); expect(secretMetadata.createdAt).toBe( - '00000000-0000-1000-8000-000000000001', + '00000001-0000-1000-8000-000000000001', ); }); @@ -3853,7 +3853,7 @@ describe('SeedlessOnboardingController', () => { const storageMetadata = { itemId: 'test-item-id', dataType: EncAccountDataType.PrimarySrp, - createdAt: '00000000-0000-1000-8000-000000000001', + createdAt: '00000001-0000-1000-8000-000000000001', }; const secretMetadata = new SecretMetadata( @@ -3886,7 +3886,7 @@ describe('SeedlessOnboardingController', () => { const storageMetadata = { itemId: 'server-assigned-id', dataType: EncAccountDataType.ImportedSrp, - createdAt: '00000000-0000-1000-8000-000000000002', + createdAt: '00000002-0000-1000-8000-000000000002', }; const parsedMetadata = SecretMetadata.fromRawMetadata( @@ -3899,7 +3899,7 @@ describe('SeedlessOnboardingController', () => { expect(parsedMetadata.itemId).toBe('server-assigned-id'); expect(parsedMetadata.dataType).toBe(EncAccountDataType.ImportedSrp); expect(parsedMetadata.createdAt).toBe( - '00000000-0000-1000-8000-000000000002', + '00000002-0000-1000-8000-000000000002', ); }); }); @@ -5961,7 +5961,7 @@ describe('SeedlessOnboardingController', () => { ), itemId: 'primary-srp-id', dataType: EncAccountDataType.PrimarySrp, - createdAt: '00000000-0000-1000-8000-000000000001', + createdAt: '00000001-0000-1000-8000-000000000001', }, { data: stringToBytes( @@ -5974,7 +5974,7 @@ describe('SeedlessOnboardingController', () => { ), itemId: 'pk-id', dataType: EncAccountDataType.ImportedPrivateKey, - createdAt: '00000000-0000-1000-8000-000000000002', + createdAt: '00000002-0000-1000-8000-000000000002', }, ]); @@ -5986,7 +5986,7 @@ describe('SeedlessOnboardingController', () => { expect(secretData[0].itemId).toBe('primary-srp-id'); expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp); expect(secretData[0].createdAt).toBe( - '00000000-0000-1000-8000-000000000001', + '00000001-0000-1000-8000-000000000001', ); expect(secretData[1].type).toStrictEqual(SecretType.PrivateKey); expect(secretData[1].data).toStrictEqual(MOCK_PRIVATE_KEY); @@ -5995,7 +5995,7 @@ describe('SeedlessOnboardingController', () => { EncAccountDataType.ImportedPrivateKey, ); expect(secretData[1].createdAt).toBe( - '00000000-0000-1000-8000-000000000002', + '00000002-0000-1000-8000-000000000002', ); }, ); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 38203ddec7b..b7a75d01f82 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -57,6 +57,7 @@ import type { import { decodeJWTToken, decodeNodeAuthToken, + compareTimeuuid, deserializeVaultData, serializeVaultData, } from './utils'; @@ -1304,9 +1305,9 @@ export class SeedlessOnboardingController< if (b.dataType === EncAccountDataType.PrimarySrp) { return 1; } - // Use server-side createdAt if available (TIMEUUID is lexicographically sortable) + // Use server-side createdAt if available (TIMEUUID requires timestamp extraction) if (a.createdAt && b.createdAt) { - return a.createdAt.localeCompare(b.createdAt); + return compareTimeuuid(a.createdAt, b.createdAt); } // Fall back to client-side timestamp return SecretMetadata.compareByTimestamp(a, b, 'asc'); diff --git a/packages/seedless-onboarding-controller/src/utils.test.ts b/packages/seedless-onboarding-controller/src/utils.test.ts index 497c8e8c896..159c9865c32 100644 --- a/packages/seedless-onboarding-controller/src/utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils.test.ts @@ -2,7 +2,12 @@ import { bytesToBase64 } from '@metamask/utils'; import { utf8ToBytes } from '@noble/ciphers/utils'; import type { DecodedNodeAuthToken, DecodedBaseJWTToken } from './types'; -import { decodeNodeAuthToken, decodeJWTToken } from './utils'; +import { + compareTimeuuid, + decodeJWTToken, + decodeNodeAuthToken, + getTimestampFromTimeuuid, +} from './utils'; describe('utils', () => { describe('decodeNodeAuthToken', () => { @@ -175,4 +180,65 @@ describe('utils', () => { expect(result.sub).toBe('user-123@example.com'); }); }); + + describe('getTimestampFromTimeuuid', () => { + it('should extract timestamp from TIMEUUID', () => { + const uuid = '00000001-0000-1000-8000-000000000001'; + const timestamp = getTimestampFromTimeuuid(uuid); + expect(timestamp).toBe(BigInt(1)); + }); + + it('should correctly parse TIMEUUID timestamps in chronological order', () => { + const uuid1 = 'c14cc4a0-d1cd-11f0-9878-3be4d8d3a8a0'; + const uuid2 = '04e72250-d1ce-11f0-9878-3be4d8d3a8a0'; + const uuid3 = '11765040-d1ce-11f0-9878-3be4d8d3a8a0'; + const uuid4 = 'b2649610-d1ce-11f0-9878-3be4d8d3a8a0'; + + const ts1 = getTimestampFromTimeuuid(uuid1); + const ts2 = getTimestampFromTimeuuid(uuid2); + const ts3 = getTimestampFromTimeuuid(uuid3); + const ts4 = getTimestampFromTimeuuid(uuid4); + + expect(ts1 < ts2).toBe(true); + expect(ts2 < ts3).toBe(true); + expect(ts3 < ts4).toBe(true); + }); + }); + + describe('compareTimeuuid', () => { + it('should return negative when a < b in ascending order', () => { + const earlier = '00000001-0000-1000-8000-000000000001'; + const later = '00000002-0000-1000-8000-000000000002'; + expect(compareTimeuuid(earlier, later, 'asc')).toBe(-1); + }); + + it('should return positive when a > b in ascending order', () => { + const earlier = '00000001-0000-1000-8000-000000000001'; + const later = '00000002-0000-1000-8000-000000000002'; + expect(compareTimeuuid(later, earlier, 'asc')).toBe(1); + }); + + it('should return zero when timestamps are equal', () => { + const uuid = '00000001-0000-1000-8000-000000000001'; + expect(compareTimeuuid(uuid, uuid, 'asc')).toBe(0); + }); + + it('should return positive when a < b in descending order', () => { + const earlier = '00000001-0000-1000-8000-000000000001'; + const later = '00000002-0000-1000-8000-000000000002'; + expect(compareTimeuuid(earlier, later, 'desc')).toBe(1); + }); + + it('should return negative when a > b in descending order', () => { + const earlier = '00000001-0000-1000-8000-000000000001'; + const later = '00000002-0000-1000-8000-000000000002'; + expect(compareTimeuuid(later, earlier, 'desc')).toBe(-1); + }); + + it('should default to ascending order', () => { + const earlier = '00000001-0000-1000-8000-000000000001'; + const later = '00000002-0000-1000-8000-000000000002'; + expect(compareTimeuuid(earlier, later)).toBe(-1); + }); + }); }); diff --git a/packages/seedless-onboarding-controller/src/utils.ts b/packages/seedless-onboarding-controller/src/utils.ts index b769c9f9f76..194dad6d2b3 100644 --- a/packages/seedless-onboarding-controller/src/utils.ts +++ b/packages/seedless-onboarding-controller/src/utils.ts @@ -112,3 +112,50 @@ export function deserializeAuthKeyPair(value: string): KeyPair { pk: base64ToBytes(parsedKeyPair.pk), }; } + +/** + * Extract the 60-bit timestamp from a TIMEUUID (version 1 UUID) string. + * + * TIMEUUID structure: xxxxxxxx-xxxx-1xxx-xxxx-xxxxxxxxxxxx + * - time_low (first 8 hex chars): least significant 32 bits of timestamp + * - time_mid (chars 9-12): middle 16 bits of timestamp + * - time_hi (chars 14-16, after version nibble): most significant 12 bits of timestamp + * + * @param uuid - The TIMEUUID string to extract timestamp from. + * @returns The 60-bit timestamp as a bigint. + */ +export function getTimestampFromTimeuuid(uuid: string): bigint { + const parts = uuid.split('-'); + const timeLow = parts[0]; // 32 bits (least significant) + const timeMid = parts[1]; // 16 bits + const timeHi = parts[2].slice(1); // 12 bits (remove version nibble '1') + // Reconstruct timestamp: timeHi | timeMid | timeLow + return BigInt(`0x${timeHi}${timeMid}${timeLow}`); +} + +/** + * Compare two TIMEUUID strings by their actual timestamps. + * + * Note: TIMEUUID strings are NOT lexicographically sortable because the + * least significant bits of the timestamp appear first in the string. + * + * @param a - First TIMEUUID string. + * @param b - Second TIMEUUID string. + * @param order - Sort order: 'asc' for oldest first, 'desc' for newest first. Default is 'asc'. + * @returns Negative if a < b (in ascending order), positive if a > b, zero if equal. + */ +export function compareTimeuuid( + a: string, + b: string, + order: 'asc' | 'desc' = 'asc', +): number { + const tsA = getTimestampFromTimeuuid(a); + const tsB = getTimestampFromTimeuuid(b); + if (tsA < tsB) { + return order === 'asc' ? -1 : 1; + } + if (tsA > tsB) { + return order === 'asc' ? 1 : -1; + } + return 0; +} diff --git a/packages/seedless-onboarding-controller/tests/mocks/toprf.ts b/packages/seedless-onboarding-controller/tests/mocks/toprf.ts index 2c2a6240beb..6898a212367 100644 --- a/packages/seedless-onboarding-controller/tests/mocks/toprf.ts +++ b/packages/seedless-onboarding-controller/tests/mocks/toprf.ts @@ -61,7 +61,7 @@ export const MULTIPLE_MOCK_SECRET_METADATA = [ type: SecretType.Mnemonic, itemId: 'srp-1', dataType: EncAccountDataType.PrimarySrp, - createdAt: '00000000-0000-1000-8000-000000000001', + createdAt: '00000001-0000-1000-8000-000000000001', }, { data: new Uint8Array(Buffer.from('seedPhrase3', 'utf-8')), @@ -69,7 +69,7 @@ export const MULTIPLE_MOCK_SECRET_METADATA = [ type: SecretType.Mnemonic, itemId: 'srp-3', dataType: EncAccountDataType.ImportedSrp, - createdAt: '00000000-0000-1000-8000-000000000003', + createdAt: '00000003-0000-1000-8000-000000000003', }, { data: new Uint8Array(Buffer.from('seedPhrase2', 'utf-8')), @@ -77,7 +77,7 @@ export const MULTIPLE_MOCK_SECRET_METADATA = [ type: SecretType.Mnemonic, itemId: 'srp-2', dataType: EncAccountDataType.ImportedSrp, - createdAt: '00000000-0000-1000-8000-000000000002', + createdAt: '00000002-0000-1000-8000-000000000002', }, ];