diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index ffe06496fa0..f6be86feebb 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,8 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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 `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:** 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..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,43 +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; - } - - /** - * 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, - }); - }); + this.#itemId = storageMetadata?.itemId; + this.#dataType = storageMetadata?.dataType; + this.#createdAt = storageMetadata?.createdAt; } /** @@ -122,42 +117,16 @@ 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. * * @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); @@ -175,31 +144,47 @@ 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, + ); } /** - * 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 { @@ -218,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 12b053a2953..06c26acc3bb 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, @@ -28,6 +29,7 @@ import { import { base64ToBytes, bytesToBase64, + bytesToString, stringToBytes, bigIntToHex, } from '@metamask/utils'; @@ -1863,6 +1865,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 +2135,16 @@ describe('SeedlessOnboardingController', () => { { data: MOCK_SEED_PHRASE, type: SecretType.Mnemonic, + itemId: 'srp-item-id', + dataType: EncAccountDataType.PrimarySrp, + createdAt: '00000001-0000-1000-8000-000000000001', }, { data: MOCK_PRIVATE_KEY, type: SecretType.PrivateKey, + itemId: 'pk-item-id', + dataType: EncAccountDataType.ImportedPrivateKey, + createdAt: '00000002-0000-1000-8000-000000000002', }, ], MOCK_PASSWORD, @@ -1901,10 +2155,24 @@ describe('SeedlessOnboardingController', () => { expect(mockSecretDataGet.isDone()).toBe(true); expect(secretData).toBeDefined(); expect(secretData).toHaveLength(2); + // Verify secret metadata 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); + expect(secretData[0].createdAt).toBe( + '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( + '00000002-0000-1000-8000-000000000002', + ); expect(controller.state.vault).toBeDefined(); expect(controller.state.vault).not.toStrictEqual(initialState.vault); @@ -1960,20 +2228,34 @@ describe('SeedlessOnboardingController', () => { expect(secretData).toHaveLength(3); expect( - secretData.every((secret) => secret.type === SecretType.Mnemonic), + secretData.every((item) => item.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 + // 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( + '00000001-0000-1000-8000-000000000001', + ); expect(secretData[1].data).toStrictEqual( stringToBytes('seedPhrase2'), ); + expect(secretData[1].itemId).toBe('srp-2'); + expect(secretData[1].dataType).toBe(EncAccountDataType.ImportedSrp); + expect(secretData[1].createdAt).toBe( + '00000002-0000-1000-8000-000000000002', + ); expect(secretData[2].data).toStrictEqual( stringToBytes('seedPhrase3'), ); + expect(secretData[2].itemId).toBe('srp-3'); + expect(secretData[2].dataType).toBe(EncAccountDataType.ImportedSrp); + expect(secretData[2].createdAt).toBe( + '00000003-0000-1000-8000-000000000003', + ); // verify the vault data const { encryptedMockVault } = await createMockVault( @@ -2020,7 +2302,15 @@ 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, + createdAt: '00000001-0000-1000-8000-000000000001', + }, + ], MOCK_PASSWORD, ), }); @@ -2030,6 +2320,11 @@ describe('SeedlessOnboardingController', () => { expect(secretData).toBeDefined(); 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( + '00000001-0000-1000-8000-000000000001', + ); expect(controller.state.vault).toBeDefined(); expect(controller.state.vault).not.toBe(initialState.vault); @@ -2075,7 +2370,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, ), }); @@ -2097,6 +2399,8 @@ describe('SeedlessOnboardingController', () => { 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(controller.state.vault).toBeDefined(); expect(controller.state.vault).not.toBe(initialState.vault); @@ -2160,7 +2464,15 @@ 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, + createdAt: '00000001-0000-1000-8000-000000000001', + }, + ], MOCK_PASSWORD, ), }); @@ -2172,6 +2484,11 @@ describe('SeedlessOnboardingController', () => { expect(secretData).toHaveLength(1); 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( + '00000001-0000-1000-8000-000000000001', + ); }, ); }); @@ -2235,7 +2552,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 +2696,142 @@ describe('SeedlessOnboardingController', () => { { data: MOCK_PRIVATE_KEY, type: SecretType.PrivateKey, + itemId: 'pk-id', + dataType: EncAccountDataType.ImportedPrivateKey, + }, + ], + MOCK_PASSWORD, + ), + }); + + await expect( + controller.fetchAllSecretData(MOCK_PASSWORD), + ).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.InvalidPrimarySecretDataType, + ); + + expect(mockSecretDataGet.isDone()).toBe(true); + }, + ); + }); + + 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: '00000001-0000-1000-8000-000000000001', + }, + { + data: new Uint8Array(Buffer.from('primarySrp', 'utf-8')), + type: SecretType.Mnemonic, + itemId: 'primary-srp-id', + dataType: EncAccountDataType.PrimarySrp, + createdAt: '00000002-0000-1000-8000-000000000002', + }, + ], + 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].data).toStrictEqual(stringToBytes('primarySrp')); + expect(secretData[1].dataType).toBe(EncAccountDataType.ImportedSrp); + expect(secretData[1].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, + type: SecretType.Mnemonic, + itemId: 'srp-2', + // No dataType or createdAt (legacy) + }, + { + data: new Uint8Array(Buffer.from('srp1', 'utf-8')), + timestamp: 100, + 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].data).toStrictEqual(stringToBytes('srp1')); + expect(secretData[0].dataType).toBeUndefined(); + expect(secretData[0].createdAt).toBeUndefined(); + expect(secretData[1].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: '00000001-0000-1000-8000-000000000001', }, ], MOCK_PASSWORD, @@ -3242,27 +3695,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 +3707,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 +3715,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 +3774,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 +3796,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,15 +3809,99 @@ 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); 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: '00000001-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( + '00000001-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: '00000001-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: '00000002-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( + '00000002-0000-1000-8000-000000000002', + ); + }); }); describe('store and recover keyring encryption key', () => { @@ -5430,22 +5950,32 @@ 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, + createdAt: '00000001-0000-1000-8000-000000000001', + }, + { + data: stringToBytes( + JSON.stringify({ + data: bytesToBase64(MOCK_PRIVATE_KEY), + timestamp: 1234567890, + type: SecretType.PrivateKey, + version: 'v1', + }), + ), + itemId: 'pk-id', + dataType: EncAccountDataType.ImportedPrivateKey, + createdAt: '00000002-0000-1000-8000-000000000002', + }, ]); const secretData = await controller.fetchAllSecretData(MOCK_PASSWORD); @@ -5453,10 +5983,20 @@ describe('SeedlessOnboardingController', () => { expect(secretData).toHaveLength(2); 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( + '00000001-0000-1000-8000-000000000001', + ); expect(secretData[1].type).toStrictEqual(SecretType.PrivateKey); expect(secretData[1].data).toStrictEqual(MOCK_PRIVATE_KEY); - - // expect(mockSecretDataGet.isDone()).toBe(true); + expect(secretData[1].itemId).toBe('pk-id'); + expect(secretData[1].dataType).toBe( + EncAccountDataType.ImportedPrivateKey, + ); + expect(secretData[1].createdAt).toBe( + '00000002-0000-1000-8000-000000000002', + ); }, ); }); @@ -5479,14 +6019,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..b7a75d01f82 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, @@ -55,6 +57,7 @@ import type { import { decodeJWTToken, decodeNodeAuthToken, + compareTimeuuid, deserializeVaultData, serializeVaultData, } from './utils'; @@ -464,6 +467,7 @@ export class SeedlessOnboardingController< authKeyPair, options: { keyringId, + dataType: EncAccountDataType.PrimarySrp, }, }); @@ -495,6 +499,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 +507,7 @@ export class SeedlessOnboardingController< type: SecretType, options?: { keyringId?: string; + dataType?: EncAccountDataType; }, ): Promise { return await this.#withControllerLock(async () => { @@ -532,12 +538,113 @@ export class SeedlessOnboardingController< } /** - * Fetches all encrypted secret data and metadata for user's account from the metadata store. + * Update the dataType for an existing secret data item. + * + * 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 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: { + 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 the dataType for multiple existing secret data items. + * + * 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 dataType to assign. + * @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 metadata items. */ async fetchAllSecretData(password?: string): Promise { return await this.#withControllerLock(async () => { @@ -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,48 @@ export class SeedlessOnboardingController< } // user must have at least one secret data - if (secretData?.length > 0) { - const secrets = SecretMetadata.parseSecretsFromMetadataStore(secretData); - // validate the primary secret data is a mnemonic (SRP) - const primarySecret = secrets[0]; - if (primarySecret.type !== SecretType.Mnemonic) { + if (secretDataItems?.length > 0) { + const results: SecretMetadata[] = secretDataItems.map((item) => + SecretMetadata.fromRawMetadata(item.data, { + itemId: item.itemId, + dataType: item.dataType, + createdAt: item.createdAt, + }), + ); + + // Sort: PrimarySrp first, then by createdAt/timestamp (oldest first) + results.sort((a, b) => { + // PrimarySrp always comes first + if (a.dataType === EncAccountDataType.PrimarySrp) { + return -1; + } + if (b.dataType === EncAccountDataType.PrimarySrp) { + return 1; + } + // Use server-side createdAt if available (TIMEUUID requires timestamp extraction) + if (a.createdAt && b.createdAt) { + return compareTimeuuid(a.createdAt, b.createdAt); + } + // Fall back to client-side timestamp + return SecretMetadata.compareByTimestamp(a, b, 'asc'); + }); + + // 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, + SecretType.Mnemonic, + ); + + if (!isDataTypePrimary || !isMnemonic) { throw new Error( SeedlessOnboardingControllerErrorMessage.InvalidPrimarySecretDataType, ); } - return secrets; + return results; } throw new Error(SeedlessOnboardingControllerErrorMessage.NoSecretDataFound); @@ -1258,6 +1397,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 +1408,7 @@ export class SeedlessOnboardingController< authKeyPair: KeyPair; options?: { keyringId?: string; + dataType?: EncAccountDataType; }; }): Promise { const { options, data, encKey, authKeyPair, type } = params; @@ -1296,6 +1437,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..1260798b1ca 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -23,3 +23,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/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 caeecb5304e..6898a212367 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,38 @@ 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, + createdAt: '00000001-0000-1000-8000-000000000001', }, { data: new Uint8Array(Buffer.from('seedPhrase3', 'utf-8')), timestamp: 60, + type: SecretType.Mnemonic, + itemId: 'srp-3', + dataType: EncAccountDataType.ImportedSrp, + createdAt: '00000003-0000-1000-8000-000000000003', }, { data: new Uint8Array(Buffer.from('seedPhrase2', 'utf-8')), timestamp: 20, + type: SecretType.Mnemonic, + itemId: 'srp-2', + dataType: EncAccountDataType.ImportedSrp, + createdAt: '00000002-0000-1000-8000-000000000002', }, ]; +type MockSecretDataInput = { + data: Uint8Array; + timestamp?: number; + type?: SecretType; + itemId: string; + dataType?: EncAccountDataType; + createdAt?: string; +}; + /** * Creates a mock secret data get response * @@ -74,30 +97,27 @@ 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 | null)[] = []; + const createdAt: (string | null)[] = []; 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 ?? null); + createdAt.push(secretData.createdAt ?? null); const metadata = JSON.stringify({ data: b64SecretData, timestamp, - type, + type: secretData.type, }); return mockToprfEncryptor.encrypt( @@ -106,11 +126,11 @@ export function createMockSecretDataGetResponse< ); }); - const jsonData = { + return { success: true, data: encryptedSecretData, ids, + dataTypes, + createdAt, }; - - return jsonData; }