Skip to content
Draft
Prev Previous commit
Next Next commit
feat(seedless-onboarding-controller): add storage metadata to SecretM…
…etadata

- Add itemId, dataType, createdAt properties to SecretMetadata class
- Remove SecretDataItemWithMetadata wrapper type
- Update fetchAllSecretData to return SecretMetadata[] directly
- Add tests for storage metadata properties
  • Loading branch information
huggingbot committed Dec 3, 2025
commit 11c09f898346bb4107164f7d46b967dbc1b8fb13
4 changes: 1 addition & 3 deletions packages/seedless-onboarding-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 50 additions & 6 deletions packages/seedless-onboarding-controller/src/SecretMetadata.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { EncAccountDataType } from '@metamask/toprf-secure-backup';
import {
base64ToBytes,
bytesToBase64,
Expand Down Expand Up @@ -42,6 +43,15 @@ type SecretMetadataJson<DataType extends SecretDataType> = 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<DataType extends SecretDataType = Uint8Array>
implements ISecretMetadata<DataType>
{
Expand All @@ -53,19 +63,35 @@ export class SecretMetadata<DataType extends SecretDataType = Uint8Array>

readonly #version: SecretMetadataVersion;

// Storage-level metadata (not encrypted)
readonly #itemId?: string;

readonly #dataType?: EncAccountDataType;

readonly #createdAt?: string;

/**
* Create a new SecretMetadata instance.
*
* @param data - The secret to add metadata to.
* @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<SecretMetadataOptions>) {
constructor(
data: DataType,
options?: Partial<SecretMetadataOptions>,
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;
}

/**
Expand Down Expand Up @@ -95,10 +121,12 @@ export class SecretMetadata<DataType extends SecretDataType = Uint8Array>
* 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<DataType extends SecretDataType>(
rawMetadata: Uint8Array,
storageMetadata?: StorageMetadata,
): SecretMetadata<DataType> {
const serializedMetadata = bytesToString(rawMetadata);
const parsedMetadata = JSON.parse(serializedMetadata);
Expand All @@ -116,11 +144,15 @@ export class SecretMetadata<DataType extends SecretDataType = Uint8Array>
data = parsedMetadata.data as DataType;
}

return new SecretMetadata<DataType>(data, {
timestamp: parsedMetadata.timestamp,
type,
version,
});
return new SecretMetadata<DataType>(
data,
{
timestamp: parsedMetadata.timestamp,
type,
version,
},
storageMetadata,
);
}

/**
Expand Down Expand Up @@ -171,6 +203,18 @@ export class SecretMetadata<DataType extends SecretDataType = Uint8Array>
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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import {
base64ToBytes,
bytesToBase64,
bytesToString,
stringToBytes,
bigIntToHex,
} from '@metamask/utils';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2229,29 +2228,27 @@ 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');
expect(secretData[0].dataType).toBe(EncAccountDataType.PrimarySrp);
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');
expect(secretData[1].dataType).toBe(EncAccountDataType.ImportedSrp);
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');
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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'),
);
},
Expand Down Expand Up @@ -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'));
},
);
});
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
Expand Down
Loading