From 177728cdf80ee2112aaf2f97df0e1dde79c3decf Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 25 Jun 2026 11:11:14 +0200 Subject: [PATCH 01/10] feat: add superstruct.sensitive --- eslint.config.mjs | 24 ++ .../keyring-api/src/api/account-options.ts | 3 +- packages/keyring-api/src/api/address.ts | 2 +- packages/keyring-api/src/api/discovery.ts | 3 +- .../src/v2/api/create-account/bip44.ts | 3 +- .../src/v2/api/create-account/private-key.ts | 3 +- .../src/v2/api/export-account/private-key.ts | 5 +- .../src/v2/api/keyring-capabilities.ts | 2 +- .../keyring-api/src/v2/api/private-key.ts | 3 +- .../keyring-utils/src/superstruct.test.ts | 155 +++++++++++- packages/keyring-utils/src/superstruct.ts | 232 +++++++++++++++++- 11 files changed, 418 insertions(+), 17 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 4d49bdaa8..8e57b3397 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -138,6 +138,30 @@ const config = createConfig([ }, }, + // @metamask/keyring-api + { + files: ['packages/keyring-api/src/**/*.ts'], + rules: { + // Prevent importing superstruct's `object` directly. The keyring-utils + // wrapper must be used instead: it supports `exactOptional()` fields and, + // critically, redacts sensitive() field values from sibling-failure + // branches so secrets never appear in error metadata. + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@metamask/superstruct', + importNames: ['object'], + message: + "Import object() from '@metamask/keyring-utils' instead — it supports exactOptional() and sensitive() field redaction.", + }, + ], + }, + ], + }, + }, + // @metamask/keyring-eth-trezor { files: ['packages/keyring-eth-trezor/src/**/*.ts'], diff --git a/packages/keyring-api/src/api/account-options.ts b/packages/keyring-api/src/api/account-options.ts index a8fbbeb26..a30c87735 100644 --- a/packages/keyring-api/src/api/account-options.ts +++ b/packages/keyring-api/src/api/account-options.ts @@ -1,11 +1,10 @@ -import { exactOptional, selectiveUnion, type } from '@metamask/keyring-utils'; +import { exactOptional, object, selectiveUnion, type } from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; import { boolean, intersection, literal, number, - object, record, string, } from '@metamask/superstruct'; diff --git a/packages/keyring-api/src/api/address.ts b/packages/keyring-api/src/api/address.ts index e5b08710d..963eae52d 100644 --- a/packages/keyring-api/src/api/address.ts +++ b/packages/keyring-api/src/api/address.ts @@ -1,5 +1,5 @@ import type { Infer } from '@metamask/superstruct'; -import { object } from '@metamask/superstruct'; +import { object } from '@metamask/keyring-utils'; import { CaipAccountIdStruct } from '@metamask/utils'; /** diff --git a/packages/keyring-api/src/api/discovery.ts b/packages/keyring-api/src/api/discovery.ts index 1ed0ccf1a..91fff01ac 100644 --- a/packages/keyring-api/src/api/discovery.ts +++ b/packages/keyring-api/src/api/discovery.ts @@ -1,5 +1,6 @@ import type { Infer } from '@metamask/superstruct'; -import { array, literal, object } from '@metamask/superstruct'; +import { array, literal } from '@metamask/superstruct'; +import { object } from '@metamask/keyring-utils'; import { CaipChainIdStruct } from './caip'; import { DerivationPathStruct } from './derivation'; diff --git a/packages/keyring-api/src/v2/api/create-account/bip44.ts b/packages/keyring-api/src/v2/api/create-account/bip44.ts index 4723235c3..2371b7bad 100644 --- a/packages/keyring-api/src/v2/api/create-account/bip44.ts +++ b/packages/keyring-api/src/v2/api/create-account/bip44.ts @@ -1,5 +1,6 @@ -import { literal, number, object, string } from '@metamask/superstruct'; +import { literal, number, string } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; +import { object } from '@metamask/keyring-utils'; import { DerivationPathStruct } from '../../../api/derivation'; diff --git a/packages/keyring-api/src/v2/api/create-account/private-key.ts b/packages/keyring-api/src/v2/api/create-account/private-key.ts index 967f47a3f..53768eb11 100644 --- a/packages/keyring-api/src/v2/api/create-account/private-key.ts +++ b/packages/keyring-api/src/v2/api/create-account/private-key.ts @@ -1,5 +1,6 @@ -import { exactOptional, literal, object, string } from '@metamask/superstruct'; +import { exactOptional, literal, string } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; +import { object } from '@metamask/keyring-utils'; import { KeyringAccountTypeStruct } from '../../../api/account'; import { PrivateKeyEncodingStruct } from '../private-key'; diff --git a/packages/keyring-api/src/v2/api/export-account/private-key.ts b/packages/keyring-api/src/v2/api/export-account/private-key.ts index 566820c73..ed3be8e4a 100644 --- a/packages/keyring-api/src/v2/api/export-account/private-key.ts +++ b/packages/keyring-api/src/v2/api/export-account/private-key.ts @@ -1,5 +1,6 @@ -import { literal, object, string } from '@metamask/superstruct'; +import { literal, string } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; +import { object, sensitive } from '@metamask/keyring-utils'; import { PrivateKeyEncodingStruct } from '../private-key'; @@ -14,7 +15,7 @@ export const PrivateKeyExportedAccountStruct = object({ /** * The private key of the exported account. */ - privateKey: string(), + privateKey: sensitive(string()), /** * The encoding of the exported private key. */ diff --git a/packages/keyring-api/src/v2/api/keyring-capabilities.ts b/packages/keyring-api/src/v2/api/keyring-capabilities.ts index 9e1ae61a8..6d678d3f8 100644 --- a/packages/keyring-api/src/v2/api/keyring-capabilities.ts +++ b/packages/keyring-api/src/v2/api/keyring-capabilities.ts @@ -3,9 +3,9 @@ import { boolean, exactOptional, nonempty, - object, partial, } from '@metamask/superstruct'; +import { object } from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; import { CaipChainIdStruct } from '../../api/caip'; diff --git a/packages/keyring-api/src/v2/api/private-key.ts b/packages/keyring-api/src/v2/api/private-key.ts index 97b657058..d501a9bf1 100644 --- a/packages/keyring-api/src/v2/api/private-key.ts +++ b/packages/keyring-api/src/v2/api/private-key.ts @@ -1,4 +1,5 @@ -import { enums, exactOptional, object } from '@metamask/superstruct'; +import { enums, exactOptional } from '@metamask/superstruct'; +import { object } from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; import { KeyringAccountTypeStruct } from '../../api/account'; diff --git a/packages/keyring-utils/src/superstruct.test.ts b/packages/keyring-utils/src/superstruct.test.ts index 6a15911ee..f5ac21a0a 100644 --- a/packages/keyring-utils/src/superstruct.test.ts +++ b/packages/keyring-utils/src/superstruct.test.ts @@ -1,4 +1,4 @@ -import type { Struct } from '@metamask/superstruct'; +import type { Struct, StructError } from '@metamask/superstruct'; import { assert, coerce, @@ -7,12 +7,163 @@ import { literal, max, number, + refine, string, union, } from '@metamask/superstruct'; import { isPlainObject } from '@metamask/utils'; -import { exactOptional, object, strictMask, selectiveUnion, type } from '.'; +import { + exactOptional, + object, + sensitive, + SENSITIVE_REDACTED, + strictMask, + selectiveUnion, + type, +} from '.'; + +describe('sensitive', () => { + const RAW_SECRET = '0xdeadbeef1234567890abcdef'; + + it('accepts a valid value', () => { + expect(is(RAW_SECRET, sensitive(string()))).toBe(true); + }); + + it('rejects an invalid value', () => { + expect(is(123, sensitive(string()))).toBe(false); + }); + + it('redacts the value in the error message', () => { + expect(() => assert(123, sensitive(string()))).toThrow(SENSITIVE_REDACTED); + }); + + it('does not expose the raw value in the error message', () => { + let error: StructError | undefined; + try { + assert(RAW_SECRET, sensitive(refine(string(), 'hex', () => false))); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.message).toContain(SENSITIVE_REDACTED); + expect(error?.message).not.toContain(RAW_SECRET); + }); + + it('redacts error.value', () => { + let error: StructError | undefined; + try { + assert(123, sensitive(string())); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.value).toBe(SENSITIVE_REDACTED); + }); + + it('does not expose the raw value in error.branch', () => { + let error: StructError | undefined; + try { + assert(123, sensitive(string())); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.branch).not.toContain(123); + }); + + it('redacts the value when used inside an object struct', () => { + const struct = object({ privateKey: sensitive(string()) }); + let error: StructError | undefined; + try { + assert({ privateKey: 123 }, struct); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.message).toContain(SENSITIVE_REDACTED); + expect(error?.message).not.toContain('123'); + }); + + it('redacts refinement failures', () => { + const hexString = sensitive( + refine(string(), 'hex', (str) => str.startsWith('0x') || 'not hex'), + ); + let error: StructError | undefined; + try { + assert('not-a-hex-string', hexString); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.message).toContain(SENSITIVE_REDACTED); + expect(error?.message).not.toContain('not-a-hex-string'); + expect(error?.value).toBe(SENSITIVE_REDACTED); + }); + + it('does not alter coercion behaviour', () => { + const struct = sensitive(string()); + expect(is(RAW_SECRET, struct)).toBe(true); + }); + + describe('branch redaction for sibling fields via object()', () => { + const struct = object({ + privateKey: sensitive(string()), + encoding: literal('hex'), + }); + + it('redacts the sensitive key from branch[0] when a sibling field fails', () => { + let error: StructError | undefined; + try { + assert({ privateKey: RAW_SECRET, encoding: 'invalid' }, struct); + } catch (caughtError) { + error = caughtError as StructError; + } + // The error is about `encoding`, not `privateKey`. + expect(error?.message).toContain('encoding'); + // But the parent object at branch[0] must not expose the raw secret. + const parentInBranch = error?.branch[0] as Record; + expect(parentInBranch?.privateKey).toBe(SENSITIVE_REDACTED); + expect(parentInBranch?.privateKey).not.toBe(RAW_SECRET); + }); + + it('redacts the sensitive key from every failure returned by error.failures()', () => { + let error: StructError | undefined; + try { + assert({ privateKey: RAW_SECRET, encoding: 'invalid' }, struct); + } catch (caughtError) { + error = caughtError as StructError; + } + // Collect every object appearing in any failure branch and assert that + // none of them expose the raw private key. + const allBranchItems = (error?.failures() ?? []).flatMap( + (failure) => failure.branch, + ); + expect(allBranchItems).not.toContainEqual( + expect.objectContaining({ privateKey: RAW_SECRET }), + ); + }); + + it('redacts the sensitive key from branch when a deeply nested sibling field fails', () => { + const nestedStruct = object({ + privateKey: sensitive(string()), + // `meta` is a sibling whose inner field will fail validation. + meta: object({ tag: literal('valid') }), + }); + + let error: StructError | undefined; + try { + assert( + { privateKey: RAW_SECRET, meta: { tag: 'invalid' } }, + nestedStruct, + ); + } catch (caughtError) { + error = caughtError as StructError; + } + // The error path leads into the nested `meta.tag` field. + expect(error?.path).toContain('tag'); + // The top-level parent at branch[0] must still have the key redacted. + const parentInBranch = error?.branch[0] as Record; + expect(parentInBranch?.privateKey).toBe(SENSITIVE_REDACTED); + expect(parentInBranch?.privateKey).not.toBe(RAW_SECRET); + }); + }); +}); describe('exactOptional', () => { const simpleStruct = object({ diff --git a/packages/keyring-utils/src/superstruct.ts b/packages/keyring-utils/src/superstruct.ts index 9bed59bfb..f8151b38c 100644 --- a/packages/keyring-utils/src/superstruct.ts +++ b/packages/keyring-utils/src/superstruct.ts @@ -5,14 +5,15 @@ import { type as stType, } from '@metamask/superstruct'; import type { - Infer, + AnyStruct, Context, + Failure, + Infer, ObjectSchema, OmitBy, Optionalize, PickBy, Simplify, - AnyStruct, } from '@metamask/superstruct'; import type { Equals } from './types'; @@ -59,9 +60,186 @@ export type ObjectType = Simplify< ExactOptionalize }>> >; +export const SENSITIVE_REDACTED = '***'; + +// Tracks which struct instances were created by `sensitive()`. Using a WeakSet +// avoids mutating the struct object itself and does not prevent garbage +// collection when a struct goes out of scope. +const sensitiveStructs = new WeakSet(); + +/** + * Return a shallow copy of `sourceObj` with each key in `sensitiveKeys` + * replaced by the redaction placeholder. + * + * @param sourceObj - The source object to copy and redact. + * @param sensitiveKeys - The property names to redact. + * @returns A shallow copy with the specified keys replaced. + */ +function redactKeys( + sourceObj: Record, + sensitiveKeys: string[], +): Record { + const redacted = { ...sourceObj }; + for (const key of sensitiveKeys) { + if (key in redacted) { + redacted[key] = SENSITIVE_REDACTED; + } + } + return redacted; +} + +/** + * Wrap a struct so that every failure it emits has any occurrence of + * `parentObj` in `failure.branch` replaced with a sanitised copy where + * `sensitiveKeys` are redacted. This prevents the parent object (which holds + * secret field values) from leaking through sibling-field failures. + * + * The wrapping propagates recursively through `entries` so that failures from + * deeply nested sibling structs are covered too. + * + * How the branch override works: superstruct's internal `toFailure` assembles + * each `Failure` as `{ value, branch, ...result, message }`. Because the + * spread of `result` comes *after* the defaults derived from context, returning + * a failure object that carries a sanitised `branch` property causes it to win + * over the raw branch that superstruct would otherwise inject from context. + * + * @param struct - The struct whose failures should be patched. + * @param parentObj - The parent-object reference to look for in branch arrays. + * @param sensitiveKeys - Keys to redact from `parentObj` when it appears. + * @returns The wrapped struct. + */ +function withRedactedBranch( + struct: AnyStruct, + parentObj: unknown, + sensitiveKeys: string[], +): AnyStruct { + function* redactBranch(failures: Iterable): Iterable { + for (const failure of failures) { + yield { + ...failure, + // Replace the parent object in the branch with a sanitised copy. + // Reference equality (`===`) is intentional: we only want to touch + // this specific parent, not an unrelated object at a different depth + // that might happen to have the same shape. + branch: failure.branch.map((branchItem) => { + if ( + branchItem !== parentObj || + typeof parentObj !== 'object' || + parentObj === null + ) { + return branchItem; + } + return redactKeys( + parentObj as Record, + sensitiveKeys, + ); + }), + }; + } + } + + // `as unknown as AnyStruct` is necessary because the Struct constructor + // infers `Type = unknown` when the generics cannot be resolved from the + // spread of an AnyStruct, producing Struct which is not + // directly assignable to Struct (AnyStruct) without the cast. + return new Struct({ + ...struct, + validator(value, context): ReturnType { + return redactBranch(struct.validator(value, context)); + }, + refiner(value, context): ReturnType { + return redactBranch(struct.refiner(value, context)); + }, + // Propagate branch redaction recursively so that failures originating from + // any depth inside a sibling struct are also sanitised. + *entries( + value: unknown, + context: Context, + ): ReturnType { + for (const entry of struct.entries(value, context)) { + const [fieldKey, fieldValue, fieldStruct] = entry; + // Array structs appear only in tuple types and do not carry the same + // branch structure as object entries — pass them through unchanged. + if (Array.isArray(fieldStruct)) { + yield entry; + } else { + yield [ + fieldKey, + fieldValue, + // `fieldStruct` is typed as Struct|Struct + // by TypeScript's entries tuple inference, which does not match + // AnyStruct (Struct) exactly. The cast is safe because both + // shapes represent untyped structs and behave identically at runtime. + withRedactedBranch(fieldStruct as AnyStruct, parentObj, sensitiveKeys), + ]; + } + } + }, + }) as unknown as AnyStruct; +} + +/** + * Wrap a struct so that any validation failure redacts the actual value from + * the error message, `StructError.value`, and `StructError.branch`. Use this + * for fields that hold secrets (private keys, mnemonics, passwords) to prevent + * sensitive material from leaking into error logs or external services. + * + * When composed with the `object()` exported from this module, sibling-field + * failures will also have the parent object's sensitive keys redacted from + * their branch. This does **not** apply when using superstruct's own `object()` + * directly, since that function is unaware of the sensitive marker. + * + * ```ts + * const MyStruct = object({ privateKey: sensitive(string()) }); + * assert({ privateKey: 123 }, MyStruct); + * // throws: At path: privateKey -- Expected a value of type `string`, + * // but received: `[REDACTED]` + * ``` + * + * @param struct - The struct to wrap. + * @returns The wrapped struct with identical validation logic but redacted + * failures. + */ +export function sensitive( + struct: Struct, +): Struct { + function* redact(failures: Iterable): Iterable { + for (const failure of failures) { + yield { + ...failure, + value: SENSITIVE_REDACTED, + message: `Expected a value of type \`${struct.type}\`, but received: \`${SENSITIVE_REDACTED}\``, + branch: failure.branch.map(() => SENSITIVE_REDACTED), + }; + } + } + + // `as unknown as Struct` is necessary because the Struct + // constructor loses the generic Type parameter when the result is stored in + // a variable (it infers Struct from the spread), so we must + // reassert the type we know is correct. + const wrapped = new Struct({ + ...struct, + validator(value, context): ReturnType { + return redact(struct.validator(value, context)); + }, + refiner(value, context): ReturnType { + return redact(struct.refiner(value as Type, context)); + }, + }) as unknown as Struct; + + // Register the wrapped struct so that object() can detect which schema keys + // are sensitive and patch sibling-field failures accordingly. + sensitiveStructs.add(wrapped as AnyStruct); + return wrapped; +} + /** * Change the return type of a superstruct's `object` function to support - * exact optional properties. + * exact optional properties. When the schema contains fields wrapped with + * `sensitive()`, the returned struct also patches every sibling-field failure + * so that the parent object in `failure.branch` has the sensitive keys + * redacted, including failures from deeply nested fields inside siblings. * * @param schema - The object schema. * @returns A struct representing an object with a known set of properties. @@ -69,7 +247,50 @@ export type ObjectType = Simplify< export function object( schema: Schema, ): Struct, Schema> { - return stObject(schema) as any; + // `as unknown as` is required because our ObjectType differs from + // superstruct's own ObjectType (it supports ExactOptional), and the two + // types are not directly comparable without an intermediate unknown cast. + const base = stObject(schema) as unknown as Struct, Schema>; + + // `schema[key]` can theoretically be undefined when the compiler has + // noUncheckedIndexedAccess enabled, even though Object.keys only returns + // keys that exist in the object. The explicit guard makes this safe. + const sensitiveKeys = Object.keys(schema).filter((key) => { + const fieldStruct = schema[key]; + return fieldStruct !== undefined && sensitiveStructs.has(fieldStruct); + }); + + // No sensitive fields — return the base struct directly to avoid the + // overhead of wrapping every entry. + if (sensitiveKeys.length === 0) { + return base; + } + + // Wrap each entry's struct so that failures from any field carry a sanitised + // copy of the parent object in their branch rather than the raw one that + // contains secret values. + return new Struct({ + ...base, + *entries( + value: unknown, + context: Context, + ): ReturnType { + for (const entry of base.entries(value, context)) { + const [fieldKey, fieldValue, fieldStruct] = entry; + if (Array.isArray(fieldStruct)) { + yield entry; + } else { + yield [ + fieldKey, + fieldValue, + // Same inference mismatch as in withRedactedBranch — see that + // function's entries block for the explanation of this cast. + withRedactedBranch(fieldStruct as AnyStruct, value, sensitiveKeys), + ]; + } + } + }, + }) as unknown as Struct, Schema>; } /** @@ -83,7 +304,8 @@ export function object( export function type( schema: Schema, ): Struct, Schema> { - return stType(schema) as any; + // See comment in `object()` above for why this cast is necessary. + return stType(schema) as unknown as Struct, Schema>; } /** From fd44c23b2c30d0a616050584cd70ae948b96b5da Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 25 Jun 2026 11:27:30 +0200 Subject: [PATCH 02/10] chore: changelogs --- packages/keyring-api/CHANGELOG.md | 11 +++++++++++ packages/keyring-utils/CHANGELOG.md | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index 307e2c9f5..3f381d983 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [23.4.0] + +### Added + +- Use `sensitive(string())` for `privateKey` in `PrivateKeyExportedAccountStruct` to prevent raw private keys from leaking into validation error messages ([#577](https://github.com/MetaMask/accounts/pull/577)) + +### Changed + +- Migrate all `object()` usages from `@metamask/superstruct` to `@metamask/keyring-utils` ([#577](https://github.com/MetaMask/accounts/pull/577)) + - This ensures `sensitive()` field redaction and `exactOptional()` support are always active. + ## [23.3.0] ### Added diff --git a/packages/keyring-utils/CHANGELOG.md b/packages/keyring-utils/CHANGELOG.md index acf035cf1..bae5ddcf8 100644 --- a/packages/keyring-utils/CHANGELOG.md +++ b/packages/keyring-utils/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `sensitive()` superstruct wrapper to redact sensitive values from validation errors ([#577](https://github.com/MetaMask/accounts/pull/577)) + - Wrapping a struct with `sensitive()` replaces the value, message, and branch in any `StructError` with `'***'` to prevent secrets (private keys, mnemonics) from leaking into logs or external services. +- Update `object()` to automatically redact sensitive sibling fields from `StructError.branch` when the schema contains `sensitive()`-wrapped fields ([#577](https://github.com/MetaMask/accounts/pull/577)) + ## [3.3.1] ### Fixed From 0943fa93aa1ec34fee1b46158fcb6b5b83d1585c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 25 Jun 2026 12:16:44 +0200 Subject: [PATCH 03/10] fix: also add eslint rule for exactOptional to prevent mixing with the other incompatible one from @metamask/superstruct --- eslint.config.mjs | 6 ++++++ .../src/v2/api/create-account/private-key.ts | 4 ++-- .../keyring-api/src/v2/api/keyring-capabilities.ts | 10 ++-------- packages/keyring-api/src/v2/api/private-key.ts | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 8e57b3397..af67e3825 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -156,6 +156,12 @@ const config = createConfig([ message: "Import object() from '@metamask/keyring-utils' instead — it supports exactOptional() and sensitive() field redaction.", }, + { + name: '@metamask/superstruct', + importNames: ['exactOptional'], + message: + "Import exactOptional() from '@metamask/keyring-utils' instead — mixing superstruct's exactOptional with keyring-utils' object() breaks ExactOptionalize and makes optional fields appear required.", + }, ], }, ], diff --git a/packages/keyring-api/src/v2/api/create-account/private-key.ts b/packages/keyring-api/src/v2/api/create-account/private-key.ts index 53768eb11..d713a14cb 100644 --- a/packages/keyring-api/src/v2/api/create-account/private-key.ts +++ b/packages/keyring-api/src/v2/api/create-account/private-key.ts @@ -1,6 +1,6 @@ -import { exactOptional, literal, string } from '@metamask/superstruct'; +import { literal, string } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; -import { object } from '@metamask/keyring-utils'; +import { exactOptional, object } from '@metamask/keyring-utils'; import { KeyringAccountTypeStruct } from '../../../api/account'; import { PrivateKeyEncodingStruct } from '../private-key'; diff --git a/packages/keyring-api/src/v2/api/keyring-capabilities.ts b/packages/keyring-api/src/v2/api/keyring-capabilities.ts index 6d678d3f8..bd96ac46f 100644 --- a/packages/keyring-api/src/v2/api/keyring-capabilities.ts +++ b/packages/keyring-api/src/v2/api/keyring-capabilities.ts @@ -1,11 +1,5 @@ -import { - array, - boolean, - exactOptional, - nonempty, - partial, -} from '@metamask/superstruct'; -import { object } from '@metamask/keyring-utils'; +import { array, boolean, nonempty, partial } from '@metamask/superstruct'; +import { exactOptional, object } from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; import { CaipChainIdStruct } from '../../api/caip'; diff --git a/packages/keyring-api/src/v2/api/private-key.ts b/packages/keyring-api/src/v2/api/private-key.ts index d501a9bf1..d8da8ef37 100644 --- a/packages/keyring-api/src/v2/api/private-key.ts +++ b/packages/keyring-api/src/v2/api/private-key.ts @@ -1,5 +1,5 @@ -import { enums, exactOptional } from '@metamask/superstruct'; -import { object } from '@metamask/keyring-utils'; +import { enums } from '@metamask/superstruct'; +import { exactOptional, object } from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; import { KeyringAccountTypeStruct } from '../../api/account'; From dfe1c7fcb763217753efea6785da0f58d64561e8 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 25 Jun 2026 14:18:02 +0200 Subject: [PATCH 04/10] chore: lint (suppressions) --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 593f7640c..3ad848f58 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -233,7 +233,7 @@ }, "packages/keyring-utils/src/superstruct.ts": { "@typescript-eslint/no-explicit-any": { - "count": 7 + "count": 5 } }, "packages/keyring-utils/src/types.test.ts": { From 795469dcfc74d62525edf254b07809b73cf0ff21 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 25 Jun 2026 14:27:34 +0200 Subject: [PATCH 05/10] chore: lint --- .../keyring-api/src/api/account-options.ts | 7 ++++++- packages/keyring-api/src/api/address.ts | 2 +- packages/keyring-api/src/api/discovery.ts | 2 +- .../src/v2/api/create-account/bip44.ts | 2 +- .../src/v2/api/create-account/private-key.ts | 2 +- .../src/v2/api/export-account/private-key.ts | 2 +- .../src/v2/api/keyring-capabilities.ts | 2 +- .../keyring-api/src/v2/api/private-key.ts | 2 +- packages/keyring-utils/src/superstruct.ts | 21 ++++++++++--------- 9 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/keyring-api/src/api/account-options.ts b/packages/keyring-api/src/api/account-options.ts index a30c87735..ed57b06a6 100644 --- a/packages/keyring-api/src/api/account-options.ts +++ b/packages/keyring-api/src/api/account-options.ts @@ -1,4 +1,9 @@ -import { exactOptional, object, selectiveUnion, type } from '@metamask/keyring-utils'; +import { + exactOptional, + object, + selectiveUnion, + type, +} from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; import { boolean, diff --git a/packages/keyring-api/src/api/address.ts b/packages/keyring-api/src/api/address.ts index 963eae52d..960b03659 100644 --- a/packages/keyring-api/src/api/address.ts +++ b/packages/keyring-api/src/api/address.ts @@ -1,5 +1,5 @@ -import type { Infer } from '@metamask/superstruct'; import { object } from '@metamask/keyring-utils'; +import type { Infer } from '@metamask/superstruct'; import { CaipAccountIdStruct } from '@metamask/utils'; /** diff --git a/packages/keyring-api/src/api/discovery.ts b/packages/keyring-api/src/api/discovery.ts index 91fff01ac..8d221fcfe 100644 --- a/packages/keyring-api/src/api/discovery.ts +++ b/packages/keyring-api/src/api/discovery.ts @@ -1,6 +1,6 @@ +import { object } from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; import { array, literal } from '@metamask/superstruct'; -import { object } from '@metamask/keyring-utils'; import { CaipChainIdStruct } from './caip'; import { DerivationPathStruct } from './derivation'; diff --git a/packages/keyring-api/src/v2/api/create-account/bip44.ts b/packages/keyring-api/src/v2/api/create-account/bip44.ts index 2371b7bad..026e32129 100644 --- a/packages/keyring-api/src/v2/api/create-account/bip44.ts +++ b/packages/keyring-api/src/v2/api/create-account/bip44.ts @@ -1,6 +1,6 @@ +import { object } from '@metamask/keyring-utils'; import { literal, number, string } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; -import { object } from '@metamask/keyring-utils'; import { DerivationPathStruct } from '../../../api/derivation'; diff --git a/packages/keyring-api/src/v2/api/create-account/private-key.ts b/packages/keyring-api/src/v2/api/create-account/private-key.ts index d713a14cb..fcea4db93 100644 --- a/packages/keyring-api/src/v2/api/create-account/private-key.ts +++ b/packages/keyring-api/src/v2/api/create-account/private-key.ts @@ -1,6 +1,6 @@ +import { exactOptional, object } from '@metamask/keyring-utils'; import { literal, string } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; -import { exactOptional, object } from '@metamask/keyring-utils'; import { KeyringAccountTypeStruct } from '../../../api/account'; import { PrivateKeyEncodingStruct } from '../private-key'; diff --git a/packages/keyring-api/src/v2/api/export-account/private-key.ts b/packages/keyring-api/src/v2/api/export-account/private-key.ts index ed3be8e4a..29dffafe4 100644 --- a/packages/keyring-api/src/v2/api/export-account/private-key.ts +++ b/packages/keyring-api/src/v2/api/export-account/private-key.ts @@ -1,6 +1,6 @@ +import { object, sensitive } from '@metamask/keyring-utils'; import { literal, string } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; -import { object, sensitive } from '@metamask/keyring-utils'; import { PrivateKeyEncodingStruct } from '../private-key'; diff --git a/packages/keyring-api/src/v2/api/keyring-capabilities.ts b/packages/keyring-api/src/v2/api/keyring-capabilities.ts index bd96ac46f..d6eb9fed1 100644 --- a/packages/keyring-api/src/v2/api/keyring-capabilities.ts +++ b/packages/keyring-api/src/v2/api/keyring-capabilities.ts @@ -1,5 +1,5 @@ -import { array, boolean, nonempty, partial } from '@metamask/superstruct'; import { exactOptional, object } from '@metamask/keyring-utils'; +import { array, boolean, nonempty, partial } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { CaipChainIdStruct } from '../../api/caip'; diff --git a/packages/keyring-api/src/v2/api/private-key.ts b/packages/keyring-api/src/v2/api/private-key.ts index d8da8ef37..5474f7e25 100644 --- a/packages/keyring-api/src/v2/api/private-key.ts +++ b/packages/keyring-api/src/v2/api/private-key.ts @@ -1,5 +1,5 @@ -import { enums } from '@metamask/superstruct'; import { exactOptional, object } from '@metamask/keyring-utils'; +import { enums } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { KeyringAccountTypeStruct } from '../../api/account'; diff --git a/packages/keyring-utils/src/superstruct.ts b/packages/keyring-utils/src/superstruct.ts index f8151b38c..8f3b3ec82 100644 --- a/packages/keyring-utils/src/superstruct.ts +++ b/packages/keyring-utils/src/superstruct.ts @@ -152,10 +152,7 @@ function withRedactedBranch( }, // Propagate branch redaction recursively so that failures originating from // any depth inside a sibling struct are also sanitised. - *entries( - value: unknown, - context: Context, - ): ReturnType { + *entries(value: unknown, context: Context): ReturnType { for (const entry of struct.entries(value, context)) { const [fieldKey, fieldValue, fieldStruct] = entry; // Array structs appear only in tuple types and do not carry the same @@ -170,7 +167,11 @@ function withRedactedBranch( // by TypeScript's entries tuple inference, which does not match // AnyStruct (Struct) exactly. The cast is safe because both // shapes represent untyped structs and behave identically at runtime. - withRedactedBranch(fieldStruct as AnyStruct, parentObj, sensitiveKeys), + withRedactedBranch( + fieldStruct as AnyStruct, + parentObj, + sensitiveKeys, + ), ]; } } @@ -250,7 +251,10 @@ export function object( // `as unknown as` is required because our ObjectType differs from // superstruct's own ObjectType (it supports ExactOptional), and the two // types are not directly comparable without an intermediate unknown cast. - const base = stObject(schema) as unknown as Struct, Schema>; + const base = stObject(schema) as unknown as Struct< + ObjectType, + Schema + >; // `schema[key]` can theoretically be undefined when the compiler has // noUncheckedIndexedAccess enabled, even though Object.keys only returns @@ -271,10 +275,7 @@ export function object( // contains secret values. return new Struct({ ...base, - *entries( - value: unknown, - context: Context, - ): ReturnType { + *entries(value: unknown, context: Context): ReturnType { for (const entry of base.entries(value, context)) { const [fieldKey, fieldValue, fieldStruct] = entry; if (Array.isArray(fieldStruct)) { From 240fd9488e1d7c111405755d8ba501fd146e6572 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 25 Jun 2026 14:32:51 +0200 Subject: [PATCH 06/10] chore: fix changelog --- packages/keyring-api/CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index 3f381d983..10dfea06d 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [23.4.0] - ### Added - Use `sensitive(string())` for `privateKey` in `PrivateKeyExportedAccountStruct` to prevent raw private keys from leaking into validation error messages ([#577](https://github.com/MetaMask/accounts/pull/577)) From 309ca2d94a8e63ffae43c02ca82f7514a47706d3 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 25 Jun 2026 15:09:08 +0200 Subject: [PATCH 07/10] chore: ignore coverage for unreachable code path --- packages/keyring-utils/src/superstruct.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/keyring-utils/src/superstruct.ts b/packages/keyring-utils/src/superstruct.ts index 8f3b3ec82..b8b2fad1c 100644 --- a/packages/keyring-utils/src/superstruct.ts +++ b/packages/keyring-utils/src/superstruct.ts @@ -155,8 +155,10 @@ function withRedactedBranch( *entries(value: unknown, context: Context): ReturnType { for (const entry of struct.entries(value, context)) { const [fieldKey, fieldValue, fieldStruct] = entry; - // Array structs appear only in tuple types and do not carry the same - // branch structure as object entries — pass them through unchanged. + // The superstruct entries type allows AnyStruct | AnyStruct[], but in + // practice object() never yields an array — it is a type-level guard + // for future or exotic struct types. + // istanbul ignore next if (Array.isArray(fieldStruct)) { yield entry; } else { @@ -278,6 +280,9 @@ export function object( *entries(value: unknown, context: Context): ReturnType { for (const entry of base.entries(value, context)) { const [fieldKey, fieldValue, fieldStruct] = entry; + // See comment in withRedactedBranch.entries — array structs are a + // type-system possibility that object() never produces at runtime. + // istanbul ignore next if (Array.isArray(fieldStruct)) { yield entry; } else { From c9ed2e1dd112eab1f8d6aa9575d5c72184942515 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 25 Jun 2026 17:03:09 +0200 Subject: [PATCH 08/10] chore: cosmetic --- packages/keyring-utils/src/superstruct.ts | 56 ++++++++++++----------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/keyring-utils/src/superstruct.ts b/packages/keyring-utils/src/superstruct.ts index b8b2fad1c..bbcada2af 100644 --- a/packages/keyring-utils/src/superstruct.ts +++ b/packages/keyring-utils/src/superstruct.ts @@ -28,6 +28,7 @@ export type ExactOptionalTag = { /** * Exclude type `Type` from the properties of `Obj`. * + * @example * ```ts * type Foo = { a: string | null; b: number }; * type Bar = ExcludeType; @@ -41,10 +42,11 @@ export type ExcludeType = { /** * Make optional all properties that have the `ExactOptionalTag` type. * + * @example * ```ts - * type Foo = { a: string | ExactOptionalTag; b: number}; + * type Foo = { a: string | ExactOptionalTag; b: number }; * type Bar = ExactOptionalize; - * // Bar = { a?: string; b: number} + * // Bar = { a?: string; b: number } * ``` */ export type ExactOptionalize = OmitBy< @@ -62,7 +64,7 @@ export type ObjectType = Simplify< export const SENSITIVE_REDACTED = '***'; -// Tracks which struct instances were created by `sensitive()`. Using a WeakSet +// Tracks which struct instances were created by `sensitive()`. Using a `WeakSet` // avoids mutating the struct object itself and does not prevent garbage // collection when a struct goes out of scope. const sensitiveStructs = new WeakSet(); @@ -138,10 +140,10 @@ function withRedactedBranch( } } - // `as unknown as AnyStruct` is necessary because the Struct constructor + // `as unknown as AnyStruct` is necessary because the `Struct` constructor // infers `Type = unknown` when the generics cannot be resolved from the - // spread of an AnyStruct, producing Struct which is not - // directly assignable to Struct (AnyStruct) without the cast. + // spread of an `AnyStruct`, producing `Struct` which is not + // directly assignable to `Struct` (`AnyStruct`) without the cast. return new Struct({ ...struct, validator(value, context): ReturnType { @@ -155,9 +157,9 @@ function withRedactedBranch( *entries(value: unknown, context: Context): ReturnType { for (const entry of struct.entries(value, context)) { const [fieldKey, fieldValue, fieldStruct] = entry; - // The superstruct entries type allows AnyStruct | AnyStruct[], but in - // practice object() never yields an array — it is a type-level guard - // for future or exotic struct types. + // The superstruct `entries` type allows `AnyStruct | AnyStruct[]`, but + // in practice `object()` never yields an array - it is a type-level + // guard for future or exotic struct types. // istanbul ignore next if (Array.isArray(fieldStruct)) { yield entry; @@ -165,9 +167,9 @@ function withRedactedBranch( yield [ fieldKey, fieldValue, - // `fieldStruct` is typed as Struct|Struct + // `fieldStruct` is typed as `Struct | Struct` // by TypeScript's entries tuple inference, which does not match - // AnyStruct (Struct) exactly. The cast is safe because both + // `AnyStruct` (`Struct`) exactly. The cast is safe because both // shapes represent untyped structs and behave identically at runtime. withRedactedBranch( fieldStruct as AnyStruct, @@ -192,11 +194,12 @@ function withRedactedBranch( * their branch. This does **not** apply when using superstruct's own `object()` * directly, since that function is unaware of the sensitive marker. * + * @example * ```ts * const MyStruct = object({ privateKey: sensitive(string()) }); * assert({ privateKey: 123 }, MyStruct); * // throws: At path: privateKey -- Expected a value of type `string`, - * // but received: `[REDACTED]` + * // but received: `***` * ``` * * @param struct - The struct to wrap. @@ -217,9 +220,9 @@ export function sensitive( } } - // `as unknown as Struct` is necessary because the Struct - // constructor loses the generic Type parameter when the result is stored in - // a variable (it infers Struct from the spread), so we must + // `as unknown as Struct` is necessary because the `Struct` + // constructor loses the generic `Type` parameter when the result is stored in + // a variable (it infers `Struct` from the spread), so we must // reassert the type we know is correct. const wrapped = new Struct({ ...struct, @@ -231,7 +234,7 @@ export function sensitive( }, }) as unknown as Struct; - // Register the wrapped struct so that object() can detect which schema keys + // Register the wrapped struct so that `object()` can detect which schema keys // are sensitive and patch sibling-field failures accordingly. sensitiveStructs.add(wrapped as AnyStruct); return wrapped; @@ -250,23 +253,23 @@ export function sensitive( export function object( schema: Schema, ): Struct, Schema> { - // `as unknown as` is required because our ObjectType differs from - // superstruct's own ObjectType (it supports ExactOptional), and the two - // types are not directly comparable without an intermediate unknown cast. + // `as unknown as` is required because our `ObjectType` differs from + // superstruct's own `ObjectType` (it supports `ExactOptional`), and the two + // types are not directly comparable without an intermediate `unknown` cast. const base = stObject(schema) as unknown as Struct< ObjectType, Schema >; - // `schema[key]` can theoretically be undefined when the compiler has - // noUncheckedIndexedAccess enabled, even though Object.keys only returns + // `schema[key]` can theoretically be `undefined` when the compiler has + // `noUncheckedIndexedAccess` enabled, even though `Object.keys` only returns // keys that exist in the object. The explicit guard makes this safe. const sensitiveKeys = Object.keys(schema).filter((key) => { const fieldStruct = schema[key]; return fieldStruct !== undefined && sensitiveStructs.has(fieldStruct); }); - // No sensitive fields — return the base struct directly to avoid the + // No sensitive fields - return the base struct directly to avoid the // overhead of wrapping every entry. if (sensitiveKeys.length === 0) { return base; @@ -280,8 +283,8 @@ export function object( *entries(value: unknown, context: Context): ReturnType { for (const entry of base.entries(value, context)) { const [fieldKey, fieldValue, fieldStruct] = entry; - // See comment in withRedactedBranch.entries — array structs are a - // type-system possibility that object() never produces at runtime. + // See comment in `withRedactedBranch.entries` - array structs are a + // type-system possibility that `object()` never produces at runtime. // istanbul ignore next if (Array.isArray(fieldStruct)) { yield entry; @@ -289,8 +292,8 @@ export function object( yield [ fieldKey, fieldValue, - // Same inference mismatch as in withRedactedBranch — see that - // function's entries block for the explanation of this cast. + // Same inference mismatch as in `withRedactedBranch` - see that + // function's `entries` block for the explanation of this cast. withRedactedBranch(fieldStruct as AnyStruct, value, sensitiveKeys), ]; } @@ -331,6 +334,7 @@ function hasOptional(ctx: Context): boolean { * Augment a struct to allow exact-optional values. Exact-optional values can * be omitted but cannot be `undefined`. * + * @example * ```ts * const foo = object({ bar: exactOptional(string()) }); * type Foo = Infer; From e0696650ba51b04a5be7c1afa6aa3c918807624a Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 25 Jun 2026 17:20:24 +0200 Subject: [PATCH 09/10] test: add test for PrivateKeyExportedAccountStruct redacted value --- .../v2/api/export-account/private-key.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/keyring-api/src/v2/api/export-account/private-key.test.ts diff --git a/packages/keyring-api/src/v2/api/export-account/private-key.test.ts b/packages/keyring-api/src/v2/api/export-account/private-key.test.ts new file mode 100644 index 000000000..9e9b9ffff --- /dev/null +++ b/packages/keyring-api/src/v2/api/export-account/private-key.test.ts @@ -0,0 +1,60 @@ +import type { StructError } from '@metamask/superstruct'; +import { assert } from '@metamask/superstruct'; +import { SENSITIVE_REDACTED } from '@metamask/keyring-utils'; + +import { PrivateKeyExportedAccountStruct } from './private-key'; + +const RAW_PRIVATE_KEY = '0xdeadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678'; + +describe('PrivateKeyExportedAccountStruct', () => { + it('accepts a valid exported account', () => { + expect(() => + assert( + { + type: 'private-key', + privateKey: RAW_PRIVATE_KEY, + encoding: 'hexadecimal', + }, + PrivateKeyExportedAccountStruct, + ), + ).not.toThrow(); + }); + + it('redacts the private key value from the error when `privateKey` is invalid', () => { + let error: StructError | undefined; + try { + assert( + { type: 'private-key', privateKey: 123, encoding: 'hexadecimal' }, + PrivateKeyExportedAccountStruct, + ); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.value).toBe(SENSITIVE_REDACTED); + expect(error?.message).toContain(SENSITIVE_REDACTED); + expect(error?.message).not.toContain('123'); + }); + + it('redacts the private key from `branch` when a sibling field fails', () => { + let error: StructError | undefined; + try { + assert( + { + type: 'private-key', + privateKey: RAW_PRIVATE_KEY, + encoding: 'invalid-encoding', + }, + PrivateKeyExportedAccountStruct, + ); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.message).toContain('encoding'); + const allBranchItems = (error?.failures() ?? []).flatMap( + (failure) => failure.branch, + ); + expect(allBranchItems).not.toContainEqual( + expect.objectContaining({ privateKey: RAW_PRIVATE_KEY }), + ); + }); +}); From 8cc422cb09972ee4ae998cba578a562d352037a1 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 26 Jun 2026 10:03:28 +0200 Subject: [PATCH 10/10] chore: lint --- .../src/v2/api/export-account/private-key.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/keyring-api/src/v2/api/export-account/private-key.test.ts b/packages/keyring-api/src/v2/api/export-account/private-key.test.ts index 9e9b9ffff..e3c674f1e 100644 --- a/packages/keyring-api/src/v2/api/export-account/private-key.test.ts +++ b/packages/keyring-api/src/v2/api/export-account/private-key.test.ts @@ -1,10 +1,11 @@ +import { SENSITIVE_REDACTED } from '@metamask/keyring-utils'; import type { StructError } from '@metamask/superstruct'; import { assert } from '@metamask/superstruct'; -import { SENSITIVE_REDACTED } from '@metamask/keyring-utils'; import { PrivateKeyExportedAccountStruct } from './private-key'; -const RAW_PRIVATE_KEY = '0xdeadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678'; +const RAW_PRIVATE_KEY = + '0xdeadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678'; describe('PrivateKeyExportedAccountStruct', () => { it('accepts a valid exported account', () => {