Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
611556b
feat(seedless-onboarding): add dataType support for secret data items
huggingbot Dec 2, 2025
85275f5
refactor: Removed default dataType parameter from createToprfKeyAndBa…
huggingbot Dec 2, 2025
fc73107
refactor: update documentation for secret data item methods
huggingbot Dec 2, 2025
3afa879
refactor: enhance primary secret data validation in SeedlessOnboardin…
huggingbot Dec 2, 2025
90d642b
fix: Update sorting mechanism to prioritize PrimarySrp dataType over …
huggingbot Dec 2, 2025
ac52a76
feat(seedless-onboarding-controller): add createdAt field and sort by…
huggingbot Dec 3, 2025
11c09f8
feat(seedless-onboarding-controller): add storage metadata to SecretM…
huggingbot Dec 3, 2025
2885314
fix(seedless-onboarding-controller): use timestamp extraction for TIM…
huggingbot Dec 5, 2025
3e2b477
chore: Update version
huggingbot Dec 16, 2025
f0b9638
Merge branch 'main' into feat/data-type
huggingbot Dec 16, 2025
ebcc4e0
chore: Update CHANGELOG and remove deprecated methods in SecretMetadata
huggingbot Dec 16, 2025
b279fa5
fix: improve sorting logic for secret data with mixed createdAt values
huggingbot Dec 18, 2025
074d8af
feat: add runMigrations for legacy dataType migration
huggingbot Jan 5, 2026
eb77007
feat: add setMigrationVersion method for direct migration version set…
huggingbot Jan 5, 2026
0bde969
Merge branch 'main' into feat/data-type
huggingbot Jan 5, 2026
7634383
feat: update SecretMetadata to use SecretDataItemOutput for storageVe…
huggingbot Jan 6, 2026
12d0ed0
fix: update SecretMetadata method signature to require storageMetadat…
huggingbot Jan 6, 2026
3aae970
refactor: remove SecretMetadataVersion from SecretMetadata and relate…
huggingbot Jan 6, 2026
e820ef5
fix: handle null dataType in SeedlessOnboardingController validation
huggingbot Jan 6, 2026
22b695e
refactor: replace type param with dataType in addNewSecretData
huggingbot Jan 6, 2026
c0dcad7
refactor: update SecretMetadata to streamline constructor options and…
huggingbot Jan 6, 2026
6e5a3ce
fix: ensure password synchronization before running migrations in See…
huggingbot Jan 6, 2026
ed01779
fix: handle null dataType in SecretMetadata constructor options
huggingbot Jan 6, 2026
10c0c09
refactor: remove updateSecretDataItem and batchUpdateSecretDataItems …
huggingbot Jan 6, 2026
2f02601
refactor: rename SeedlessOnboardingMigrationVersion.DataType to Seedl…
huggingbot Jan 6, 2026
4019328
refactor: move secret metadata sorting logic to SecretMetadata.compare
huggingbot Jan 6, 2026
2f3255a
Merge branch 'main' into feat/data-type
huggingbot Jan 6, 2026
b78c9af
chore: Update changelog
huggingbot Jan 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/seedless-onboarding-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Add `runMigrations` method to run pending data migrations for legacy secrets ([#7284](https://github.com/MetaMask/core/pull/7284))
- Add `setMigrationVersion` method to set migration version directly for new users ([#7284](https://github.com/MetaMask/core/pull/7284))
- Add `SeedlessOnboardingMigrationVersion` enum for tracking migration versions ([#7284](https://github.com/MetaMask/core/pull/7284))
- Add `migrationVersion` to controller state to track applied migrations ([#7284](https://github.com/MetaMask/core/pull/7284))
- Add `itemId`, `dataType`, `createdAt`, and `storageVersion` storage-level properties to `SecretMetadata` ([#7284](https://github.com/MetaMask/core/pull/7284))
- Add `SecretMetadata.compare` static method for comparing metadata with PrimarySrp prioritization and TIMEUUID-based sorting ([#7284](https://github.com/MetaMask/core/pull/7284))
- Add `SecretMetadata.compareByTimestamp` static method for comparing metadata by timestamp ([#7284](https://github.com/MetaMask/core/pull/7284))
- Add `SecretMetadata.matchesType` static method for checking if metadata matches a given type ([#7284](https://github.com/MetaMask/core/pull/7284))
- Re-export `EncAccountDataType` from `@metamask/toprf-secure-backup` ([#7284](https://github.com/MetaMask/core/pull/7284))

### Changed

- **BREAKING:** Change `addNewSecretData` method signature to require `dataType: EncAccountDataType` instead of `type: SecretType` ([#7284](https://github.com/MetaMask/core/pull/7284))
- `SecretType` is now derived internally from `EncAccountDataType`
- Encrypted payload still includes `type` for backward compatibility with older clients
- **BREAKING:** Remove `parseSecretsFromMetadataStore`, `fromBatch`, and `sort` methods from `SecretMetadata` ([#7284](https://github.com/MetaMask/core/pull/7284))
- Use `SecretMetadata.compare` or `SecretMetadata.compareByTimestamp` for sorting
- Use `SecretMetadata.matchesType` for filtering
- **BREAKING:** Change `SecretMetadata.fromRawMetadata` signature to require `storageMetadata` parameter ([#7284](https://github.com/MetaMask/core/pull/7284))
- **BREAKING:** Remove `version` getter from `SecretMetadata`; use `storageVersion` instead ([#7284](https://github.com/MetaMask/core/pull/7284))

- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511))

### Fixed

- Fix TIMEUUID sorting by extracting actual timestamps instead of using lexicographic comparison ([#7284](https://github.com/MetaMask/core/pull/7284))

## [7.1.0]

### Added
Expand Down
227 changes: 137 additions & 90 deletions packages/seedless-onboarding-controller/src/SecretMetadata.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { SecretDataItemOutput } from '@metamask/toprf-secure-backup';
import { EncAccountDataType } from '@metamask/toprf-secure-backup';
import {
base64ToBytes,
bytesToBase64,
Expand All @@ -8,15 +10,14 @@ import {
import {
SeedlessOnboardingControllerErrorMessage,
SecretType,
SecretMetadataVersion,
} from './constants';
import type { SecretDataType, SecretMetadataOptions } from './types';
import type { SecretDataType } from './types';
import { compareTimeuuid, getSecretTypeFromDataType } from './utils';

type ISecretMetadata<DataType extends SecretDataType = Uint8Array> = {
data: DataType;
timestamp: number;
type: SecretType;
version: SecretMetadataVersion;
toBytes: () => Uint8Array;
};

Expand All @@ -42,6 +43,30 @@ type SecretMetadataJson<DataType extends SecretDataType> = Omit<
* const secretMetadata = new SecretMetadata(secret);
* ```
*/

/**
* Options for SecretMetadata constructor.
*
* New clients: provide V2 fields (`dataType`, `createdAt`, etc).
* Reading V1 data: `timestamp` and `type` come from encrypted JSON.
*/
type SecretMetadataOptions = {
// V1 fields (from encrypted JSON payload, for backward compat)
timestamp?: number;
type?: SecretType;

// Storage-level metadata from the metadata store (not encrypted).
itemId?: string;
dataType?: EncAccountDataType;
createdAt?: string;
/**
* The storage-level version from the SDK ('v1' or 'v2').
* - 'v1': Legacy items created before dataType was introduced
* - 'v2': Items with dataType set (either new or migrated)
*/
storageVersion?: SecretDataItemOutput['version'];
};

export class SecretMetadata<DataType extends SecretDataType = Uint8Array>
implements ISecretMetadata<DataType>
{
Expand All @@ -51,52 +76,33 @@ export class SecretMetadata<DataType extends SecretDataType = Uint8Array>

readonly #type: SecretType;

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

readonly #dataType?: EncAccountDataType;

readonly #createdAt?: string;

readonly #storageVersion?: SecretDataItemOutput['version'];

/**
* 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 data - The secret data.
* @param options - Optional metadata. New clients should provide `dataType`.
*/
constructor(data: DataType, options?: Partial<SecretMetadataOptions>) {
constructor(data: DataType, options?: SecretMetadataOptions) {
this.#data = data;
this.#timestamp = options?.timestamp ?? Date.now();
this.#type = options?.type ?? SecretType.Mnemonic;
this.#version = options?.version ?? SecretMetadataVersion.V1;
}
this.#itemId = options?.itemId;
this.#dataType = options?.dataType;
this.#createdAt = options?.createdAt;
this.#storageVersion = options?.storageVersion;

/**
* 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 batchData - The data to add metadata to.
* @param batchData.value - The SeedPhrase/PrivateKey to add metadata to.
* @param batchData.options - The options for the seed phrase metadata.
* @returns The SecretMetadata instances.
*/
static fromBatch<DataType extends SecretDataType = Uint8Array>(
batchData: {
value: DataType;
options?: Partial<SecretMetadataOptions>;
}[],
): SecretMetadata<DataType>[] {
const timestamp = Date.now();
return batchData.map((data, 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 = data.options?.timestamp ?? timestamp + index * 5;
return new SecretMetadata(data.value, {
timestamp: backupCreatedAt,
type: data.options?.type,
});
});
// Derive type from dataType (new clients), or use provided type (V1 compat)
if (options?.dataType === undefined || options?.dataType === null) {
this.#type = options?.type ?? SecretType.Mnemonic;
} else {
this.#type = getSecretTypeFromDataType(options.dataType);
}
}

/**
Expand All @@ -122,51 +128,23 @@ export class SecretMetadata<DataType extends SecretDataType = Uint8Array>
}
}

/**
* 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<DataType>[] {
const parsedSecertMetadata = secretMetadataArr.map((metadata) =>
SecretMetadata.fromRawMetadata<DataType>(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<DataType extends SecretDataType>(
rawMetadata: Uint8Array,
storageMetadata: Omit<SecretMetadataOptions, 'timestamp' | 'type'>,
): SecretMetadata<DataType> {
const serializedMetadata = bytesToString(rawMetadata);
const parsedMetadata = JSON.parse(serializedMetadata);

SecretMetadata.assertIsValidSecretMetadataJson<DataType>(parsedMetadata);

// if the type is not provided, we default to Mnemonic for the backwards compatibility
const type = parsedMetadata.type ?? SecretType.Mnemonic;
const version = parsedMetadata.version ?? SecretMetadataVersion.V1;

let data: DataType;
try {
Expand All @@ -178,28 +156,81 @@ export class SecretMetadata<DataType extends SecretDataType = Uint8Array>
return new SecretMetadata<DataType>(data, {
timestamp: parsedMetadata.timestamp,
type,
version,
...storageMetadata,
});
}

/**
* Sort the seed phrases by timestamp.
* Compare two SecretMetadata instances by timestamp.
*
* @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 compareByTimestamp<DataType extends SecretDataType = SecretDataType>(
a: SecretMetadata<DataType>,
b: SecretMetadata<DataType>,
order: 'asc' | 'desc' = 'asc',
): number {
return order === 'asc'
? a.timestamp - b.timestamp
: b.timestamp - a.timestamp;
}

/**
* Compare two SecretMetadata instances for ordering.
*
* @param data - The secret metadata array to sort.
* @param order - The order to sort the seed phrases. Default is `desc`.
* Ordering priority:
* 1. PrimarySrp always comes first (regardless of order direction)
* 2. Server-side createdAt (TIMEUUID) if both have it
* 3. Legacy items (null createdAt) are considered older
* 4. Fall back to client-side 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<DataType extends SecretDataType = Uint8Array>(
data: SecretMetadata<DataType>[],
static compare<DataType extends SecretDataType = SecretDataType>(
a: SecretMetadata<DataType>,
b: SecretMetadata<DataType>,
order: 'asc' | 'desc' = 'asc',
): SecretMetadata<DataType>[] {
return data.sort((a, b) => {
if (order === 'asc') {
return a.timestamp - b.timestamp;
}
return b.timestamp - a.timestamp;
});
): number {
// PrimarySrp always comes first (regardless of order direction)
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, order);
}
// Handle mixed createdAt: legacy items (null) are older
if (!a.createdAt && b.createdAt) {
return order === 'asc' ? -1 : 1; // a (legacy/older) comes before b in asc
}
if (a.createdAt && !b.createdAt) {
return order === 'asc' ? 1 : -1; // b (legacy/older) comes before a in asc
}
// Both null: fall back to client-side timestamp
return SecretMetadata.compareByTimestamp(a, b, order);
}

/**
* 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<DataType extends SecretDataType = SecretDataType>(
secret: SecretMetadata<DataType>,
type: SecretType,
): boolean {
return secret.type === type;
}

get data(): DataType {
Expand All @@ -214,8 +245,25 @@ export class SecretMetadata<DataType extends SecretDataType = Uint8Array>
return this.#type;
}

get version(): SecretMetadataVersion {
return this.#version;
get itemId(): string | undefined {
return this.#itemId;
}

get dataType(): EncAccountDataType | undefined {
return this.#dataType;
}

get createdAt(): string | undefined {
return this.#createdAt;
}

/**
* The storage-level version from the SDK ('v1' or 'v2').
*
* @returns The storage-level version.
*/
get storageVersion(): SecretDataItemOutput['version'] | undefined {
return this.#storageVersion;
}

/**
Expand All @@ -236,7 +284,6 @@ export class SecretMetadata<DataType extends SecretDataType = Uint8Array>
data: _data,
timestamp: this.#timestamp,
type: this.#type,
version: this.#version,
});

// convert the serialized metadata to bytes(Uint8Array)
Expand Down
Loading
Loading