Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
30 changes: 30 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,36 @@ 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.",
},
Comment on lines +153 to +158

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This forces us to use our own object type that supports our exactOptional AND also allows us to use the new sensitive decorator.

{
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.",
},
Comment on lines +159 to +164

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, we cannot use the exactOptional from the main repo (that works slightly differently from ours)

],
},
],
},
},

// @metamask/keyring-eth-trezor
{
files: ['packages/keyring-eth-trezor/src/**/*.ts'],
Expand Down
9 changes: 9 additions & 0 deletions packages/keyring-api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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
Expand Down
8 changes: 6 additions & 2 deletions packages/keyring-api/src/api/account-options.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
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';
Expand Down
2 changes: 1 addition & 1 deletion packages/keyring-api/src/api/address.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { object } from '@metamask/keyring-utils';
import type { Infer } from '@metamask/superstruct';
import { object } from '@metamask/superstruct';
import { CaipAccountIdStruct } from '@metamask/utils';

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/keyring-api/src/api/discovery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { object } from '@metamask/keyring-utils';
import type { Infer } from '@metamask/superstruct';
import { array, literal, object } from '@metamask/superstruct';
import { array, literal } from '@metamask/superstruct';

import { CaipChainIdStruct } from './caip';
import { DerivationPathStruct } from './derivation';
Expand Down
3 changes: 2 additions & 1 deletion packages/keyring-api/src/v2/api/create-account/bip44.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { literal, number, object, string } from '@metamask/superstruct';
import { object } from '@metamask/keyring-utils';
import { literal, number, string } from '@metamask/superstruct';
import type { Infer } from '@metamask/superstruct';

import { DerivationPathStruct } from '../../../api/derivation';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { exactOptional, literal, object, string } from '@metamask/superstruct';
import { exactOptional, object } from '@metamask/keyring-utils';
import { literal, string } from '@metamask/superstruct';
import type { Infer } from '@metamask/superstruct';

import { KeyringAccountTypeStruct } from '../../../api/account';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { SENSITIVE_REDACTED } from '@metamask/keyring-utils';
import type { StructError } from '@metamask/superstruct';
import { assert } from '@metamask/superstruct';

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 }),
);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { literal, object, string } from '@metamask/superstruct';
import { object, sensitive } from '@metamask/keyring-utils';
import { literal, string } from '@metamask/superstruct';
import type { Infer } from '@metamask/superstruct';

import { PrivateKeyEncodingStruct } from '../private-key';
Expand All @@ -14,7 +15,7 @@ export const PrivateKeyExportedAccountStruct = object({
/**
* The private key of the exported account.
*/
privateKey: string(),
privateKey: sensitive(string()),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's marked as sensitive AND that we use our object struct, this field will never be display in validation/error messages from superstruct.

/**
* The encoding of the exported private key.
*/
Expand Down
10 changes: 2 additions & 8 deletions packages/keyring-api/src/v2/api/keyring-capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import {
array,
boolean,
exactOptional,
nonempty,
object,
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';
Expand Down
3 changes: 2 additions & 1 deletion packages/keyring-api/src/v2/api/private-key.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { enums, exactOptional, object } 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';
Expand Down
6 changes: 6 additions & 0 deletions packages/keyring-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
155 changes: 153 additions & 2 deletions packages/keyring-utils/src/superstruct.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Struct } from '@metamask/superstruct';
import type { Struct, StructError } from '@metamask/superstruct';
import {
assert,
coerce,
Expand All @@ -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<string, unknown>;
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<string, unknown>;
expect(parentInBranch?.privateKey).toBe(SENSITIVE_REDACTED);
expect(parentInBranch?.privateKey).not.toBe(RAW_SECRET);
});
});
});

describe('exactOptional', () => {
const simpleStruct = object({
Expand Down
Loading
Loading