diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index 1e9bb99b9bf..e3ac3a22419 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `extractSignatureAddresses` utility, plus `ExtractedSignatureAddresses` and `ExtractSignatureAddressesOptions` types, to collect the `address`-typed values from an EIP-712 typed-data message for real-time address scanning ([#9875](https://github.com/MetaMask/core/pull/9875)) + - Walks the `types` schema from `primaryType`, matching fields by declared type (`address`/`address[]`, including nested structs and arrays) rather than by field name, so custom and unknown message shapes are covered without per-protocol handling. + - Normalizes non-canonical `address` encodings (variable-length hex and decimal strings) into canonical lower-case 20-byte hex by taking the leading 20 bytes of the signer-compatible big-endian encoding, and de-duplicates case-insensitively. + - Excludes the zero address, a caller-provided `exclude` list (e.g. the signer), and caller-provided top-level `excludeFields`. + - Bounds work with a distinct-address cap (default 10, caller-overridable via `maxAddresses`, hard ceiling 50), a traversal depth limit, and a node budget, reporting `overflow` when the message could not be fully walked. Exports `DEFAULT_MAX_SIGNATURE_ADDRESSES` and `MAX_SIGNATURE_ADDRESSES_CEILING`. + - Returns the field name each address was found under so callers can attribute alerts. + ### Changed - Bump `@metamask/transaction-controller` from `^69.8.0` to `^69.8.1` ([#10124](https://github.com/MetaMask/core/pull/10124)) diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index 284c215a383..8b6be05a680 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -68,6 +68,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", + "@metamask/eth-sig-util": "^9.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", diff --git a/packages/phishing-controller/src/index.ts b/packages/phishing-controller/src/index.ts index 7292c6e2f86..5a656acf38c 100644 --- a/packages/phishing-controller/src/index.ts +++ b/packages/phishing-controller/src/index.ts @@ -38,6 +38,15 @@ export { isAddressScanSupportedChainId, isPhishingDetectionPathBasedHostname, } from './utils.js'; +export { + extractSignatureAddresses, + DEFAULT_MAX_SIGNATURE_ADDRESSES, + MAX_SIGNATURE_ADDRESSES_CEILING, +} from './signature-address-extraction.js'; +export type { + ExtractedSignatureAddresses, + ExtractSignatureAddressesOptions, +} from './signature-address-extraction.js'; export type { PhishingControllerMaybeUpdateStateAction, diff --git a/packages/phishing-controller/src/signature-address-extraction.test.ts b/packages/phishing-controller/src/signature-address-extraction.test.ts new file mode 100644 index 00000000000..8207eaa9b0a --- /dev/null +++ b/packages/phishing-controller/src/signature-address-extraction.test.ts @@ -0,0 +1,878 @@ +import { SignTypedDataVersion, TypedDataUtils } from '@metamask/eth-sig-util'; + +import { + DEFAULT_MAX_SIGNATURE_ADDRESSES, + MAX_SIGNATURE_ADDRESSES_CEILING, + extractSignatureAddresses, +} from './signature-address-extraction.js'; + +const ADDR_A = '0x1111111111111111111111111111111111111111'; +const ADDR_B = '0x2222222222222222222222222222222222222222'; +const ADDR_C = '0x3333333333333333333333333333333333333333'; +const ADDR_D = '0x5555555555555555555555555555555555555555'; +const SIGNER = '0x4444444444444444444444444444444444444444'; +const ZERO = '0x0000000000000000000000000000000000000000'; + +const DOMAIN_TYPE = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, +]; + +type TypedDataFixture = { + types: Record; + primaryType: string; + domain: { verifyingContract: string }; + message: Record; +}; + +const build = ( + primaryType: string, + types: Record, + message: Record, +): TypedDataFixture => ({ + types: { EIP712Domain: DOMAIN_TYPE, ...types }, + primaryType, + domain: { verifyingContract: ADDR_C }, + message, +}); + +const addressesOf = ( + ...args: Parameters +): string[] => extractSignatureAddresses(...args).addresses; + +const nAddresses = (count: number): string[] => + Array.from( + { length: count }, + (_, i) => `0x${(i + 1).toString(16).padStart(2, '0').repeat(20)}`, + ); + +describe('extractSignatureAddresses', () => { + it('extracts a permit `spender` from the schema', () => { + const data = build( + 'Permit', + { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + ], + }, + { owner: SIGNER, spender: ADDR_A, value: '1' }, + ); + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ADDR_A]); + }); + + it('extracts an EIP-3009 `to`', () => { + const data = build( + 'ReceiveWithAuthorization', + { + ReceiveWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + ], + }, + { from: SIGNER, to: ADDR_A, value: '1' }, + ); + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ADDR_A]); + }); + + it('extracts an address field with a protocol-specific name', () => { + const data = { + domain: { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 8453, + verifyingContract: ZERO, + }, + message: { + hyperliquidChain: 'Mainnet', + signatureChainId: '0x2105', + agentAddress: ADDR_A, + agentName: '', + nonce: 1784737070579, + type: 'approveAgent', + }, + primaryType: 'HyperliquidTransaction:ApproveAgent', + types: { + EIP712Domain: DOMAIN_TYPE, + 'HyperliquidTransaction:ApproveAgent': [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'agentAddress', type: 'address' }, + { name: 'agentName', type: 'string' }, + { name: 'nonce', type: 'uint64' }, + ], + }, + }; + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ADDR_A]); + }); + + it('extracts EVERY address field in a Seaport order (offerer/zone/token/recipient), nested structs + arrays', () => { + const data = build( + 'OrderComponents', + { + OrderComponents: [ + { name: 'offerer', type: 'address' }, + { name: 'zone', type: 'address' }, + { name: 'offer', type: 'OfferItem[]' }, + { name: 'consideration', type: 'ConsiderationItem[]' }, + { name: 'startTime', type: 'uint256' }, + ], + OfferItem: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + ConsiderationItem: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'recipient', type: 'address' }, + ], + }, + { + offerer: SIGNER, + zone: ADDR_A, + offer: [{ token: ADDR_B, amount: '1' }], + consideration: [{ token: ADDR_C, amount: '1', recipient: ADDR_D }], + startTime: '0', + }, + ); + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ + ADDR_A, + ADDR_B, + ADDR_C, + ADDR_D, + ]); + }); + + it('extracts addresses from a Permit2 batch (struct array + top-level spender)', () => { + const data = build( + 'PermitBatch', + { + PermitBatch: [ + { name: 'details', type: 'PermitDetails[]' }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' }, + ], + PermitDetails: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint160' }, + ], + }, + { + details: [ + { token: ADDR_A, amount: '1' }, + { token: ADDR_B, amount: '2' }, + ], + spender: ADDR_C, + sigDeadline: '0', + }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A, ADDR_B, ADDR_C]); + }); + + it('extracts an `address[]` field', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: [ADDR_A, ADDR_B] }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A, ADDR_B]); + }); + + it('handles a fixed-size `address[N]` field', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[2]' }] }, + { recipients: [ADDR_A, ADDR_B] }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A, ADDR_B]); + }); + + it('flags overflow when an address-array field value is not an array', () => { + const data = build( + 'Airdrop', + { + Airdrop: [ + { name: 'recipients', type: 'address[]' }, + { name: 'to', type: 'address' }, + ], + }, + { recipients: 'not-an-array', to: ADDR_A }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + expect(extractSignatureAddresses(data).overflow).toBe(true); + }); + + it('does not flag overflow when a non-address array type is not an array', () => { + const data = build( + 'T', + { + T: [ + { name: 'amounts', type: 'uint256[]' }, + { name: 'to', type: 'address' }, + ], + }, + { amounts: 'not-an-array', to: ADDR_A }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toStrictEqual([ADDR_A]); + expect(result.overflow).toBe(false); + }); + + it('treats `address[abc]` as an array (signer matches on a trailing `]`)', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[abc]' }] }, + { recipients: [ADDR_A, ADDR_B] }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A, ADDR_B]); + }); + + it('treats a custom type named `address[]` as a struct (signer schema first)', () => { + const data = build( + 'Mail', + { + Mail: [{ name: 'wrapper', type: 'address[]' }], + 'address[]': [{ name: 'to', type: 'address' }], + }, + { wrapper: { to: ADDR_A } }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('extracts an arbitrarily-named address field in an unknown schema', () => { + const data = build( + 'Weird', + { + Weird: [ + { name: 'maker', type: 'address' }, + { name: 'superSecretSink', type: 'address' }, + ], + }, + { maker: SIGNER, superSecretSink: ADDR_A }, + ); + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ADDR_A]); + }); + + it('ignores non-address typed fields even if the value looks like an address', () => { + const data = build( + 'T', + { + T: [ + { name: 'owner', type: 'address' }, + { name: 'notAnAddress', type: 'uint256' }, + { name: 'blob', type: 'bytes32' }, + ], + }, + // notAnAddress carries an address-shaped string but is typed uint256. + { owner: ADDR_A, notAnAddress: ADDR_B, blob: `0x${'ab'.repeat(32)}` }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('canonicalizes to lower case and de-duplicates case-insensitively', () => { + const lower = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const upper = '0xABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD'; + const data = build( + 'Two', + { + Two: [ + { name: 'to', type: 'address' }, + { name: 'recipient', type: 'address' }, + ], + }, + { to: lower, recipient: upper }, + ); + expect(addressesOf(data)).toStrictEqual([lower]); + }); + + it('excludes the zero address and provided addresses', () => { + const data = build( + 'Three', + { + Three: [ + { name: 'a', type: 'address' }, + { name: 'b', type: 'address' }, + { name: 'c', type: 'address' }, + ], + }, + { a: ZERO, b: SIGNER, c: ADDR_A }, + ); + expect(addressesOf(data, { exclude: [SIGNER] })).toStrictEqual([ADDR_A]); + }); + + it('ignores non-address-like entries in the exclude list', () => { + const data = build( + 'X', + { X: [{ name: 'a', type: 'address' }] }, + { a: ADDR_A }, + ); + // A garbage exclude value normalizes to undefined and is skipped, so the + // real address is still returned. + expect(addressesOf(data, { exclude: ['not-an-address'] })).toStrictEqual([ + ADDR_A, + ]); + }); + + it('honors excludeFields (e.g. spender handled elsewhere)', () => { + const data = build( + 'Permit', + { + Permit: [ + { name: 'spender', type: 'address' }, + { name: 'to', type: 'address' }, + ], + }, + { spender: ADDR_A, to: ADDR_B }, + ); + expect(addressesOf(data, { excludeFields: ['spender'] })).toStrictEqual([ + ADDR_B, + ]); + }); + + it('matches excludeFields exactly (does not collect-skip `Spender` for `spender`)', () => { + const data = build( + 'Permit', + { + Permit: [ + { name: 'Spender', type: 'address' }, + { name: 'to', type: 'address' }, + ], + }, + { Spender: ADDR_A, to: ADDR_B }, + ); + expect(addressesOf(data, { excludeFields: ['spender'] })).toStrictEqual([ + ADDR_A, + ADDR_B, + ]); + }); + + it('only excludes fields at the top level', () => { + const data = build( + 'Order', + { + Order: [ + { name: 'spender', type: 'address' }, + { name: 'inner', type: 'Inner' }, + ], + Inner: [{ name: 'spender', type: 'address' }], + }, + { spender: ADDR_A, inner: { spender: ADDR_B } }, + ); + expect(addressesOf(data, { excludeFields: ['spender'] })).toStrictEqual([ + ADDR_B, + ]); + }); + + it('skips malformed schema fields', () => { + const data = build( + 'T', + { + T: [ + // Malformed entries the walker must skip without throwing. + null as unknown as { name: string; type: string }, + { name: 123 as unknown as string, type: 'address' }, + { name: 'to', type: 456 as unknown as string }, + { name: 'good', type: 'address' }, + ], + }, + { to: ADDR_A, good: ADDR_B }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_B]); + }); + + it('normalizes decimal and non-canonical hex address encodings', () => { + const data = build( + 'Batch', + { + Batch: [ + { name: 'a', type: 'address' }, + { name: 'b', type: 'address' }, + ], + }, + { a: BigInt(ADDR_A).toString(10), b: '0x1' }, + ); + expect(addressesOf(data)).toStrictEqual([ + ADDR_A, + '0x0000000000000000000000000000000000000001', + ]); + }); + + it('normalizes a whitespace-padded address value', () => { + const data = build( + 'X', + { X: [{ name: 'a', type: 'address' }] }, + { a: ` ${ADDR_A} ` }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('normalizes a non-negative integer number value', () => { + const data = build('X', { X: [{ name: 'a', type: 'address' }] }, { a: 1 }); + expect(addressesOf(data)).toStrictEqual([ + '0x0000000000000000000000000000000000000001', + ]); + }); + + it('ignores non-address-like values (negative, float, object, null)', () => { + const data = build( + 'X', + { + X: [ + { name: 'a', type: 'address' }, + { name: 'b', type: 'address' }, + { name: 'c', type: 'address' }, + { name: 'd', type: 'address' }, + { name: 'e', type: 'address' }, + ], + }, + { a: -1, b: 1.5, c: {}, d: null, e: ADDR_A }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('canonicalizes mixed-case addresses to lower case', () => { + const mixed = '0xAbCdEf0000000000000000000000000000000001'; + const data = build( + 'X', + { X: [{ name: 'a', type: 'address' }] }, + { a: mixed }, + ); + expect(addressesOf(data)).toStrictEqual([mixed.toLowerCase()]); + }); + + it('takes the leading 20 bytes of an oversized decimal address (ADDR_A * 256 + 0x42)', () => { + // The signer encodes an address as big-endian bytes and keeps the high / + // first 20 bytes, so a trailing extra byte is dropped. + const oversized = (BigInt(ADDR_A) * 256n + 0x42n).toString(10); + const data = build( + 'X', + { X: [{ name: 'a', type: 'address' }] }, + { a: oversized }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('takes the leading 20 bytes of an oversized hex address', () => { + const oversizedHex = `0x${ADDR_A.slice(2)}42`; + const data = build( + 'X', + { X: [{ name: 'a', type: 'address' }] }, + { a: oversizedHex }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('agrees with eth-sig-util encodeData / eip712Hash on leading-20-byte addresses', () => { + const types = { + EIP712Domain: DOMAIN_TYPE, + Mail: [{ name: 'to', type: 'address' }], + }; + const domain = { + name: 't', + version: '1', + chainId: 1, + verifyingContract: ADDR_C, + }; + const oversized = (BigInt(ADDR_A) * 256n + 0x42n).toString(10); + const canonical = build('Mail', { Mail: types.Mail }, { to: ADDR_A }); + const shifted = build('Mail', { Mail: types.Mail }, { to: oversized }); + + expect( + TypedDataUtils.encodeData( + 'Mail', + { to: oversized }, + types, + SignTypedDataVersion.V4, + ), + ).toStrictEqual( + TypedDataUtils.encodeData( + 'Mail', + { to: ADDR_A }, + types, + SignTypedDataVersion.V4, + ), + ); + expect( + TypedDataUtils.eip712Hash( + { types, primaryType: 'Mail', domain, message: { to: oversized } }, + SignTypedDataVersion.V4, + ), + ).toStrictEqual( + TypedDataUtils.eip712Hash( + { types, primaryType: 'Mail', domain, message: { to: ADDR_A } }, + SignTypedDataVersion.V4, + ), + ); + expect(addressesOf(shifted)).toStrictEqual([ADDR_A]); + expect(addressesOf(canonical)).toStrictEqual([ADDR_A]); + }); + + it('agrees with eth-sig-util on odd-length 0x-hex (isStrictHexString is true)', () => { + // Odd-length 0x-hex is still isStrictHexString in @metamask/utils, so the + // signer hexToBytes-pads the nibble and takes 20 bytes — same as we do. + // It does not fall through to reallyStrangeAddressToBytes. + const types = { + EIP712Domain: DOMAIN_TYPE, + Mail: [{ name: 'to', type: 'address' }], + }; + const oddShort = '0x1'; + const oddPadded = '0x0111111111111111111111111111111111111111'; + const odd39 = `0x${'1'.repeat(39)}`; + + expect( + TypedDataUtils.encodeData( + 'Mail', + { to: oddShort }, + types, + SignTypedDataVersion.V4, + ), + ).toStrictEqual( + TypedDataUtils.encodeData( + 'Mail', + { to: '0x0000000000000000000000000000000000000001' }, + types, + SignTypedDataVersion.V4, + ), + ); + expect( + addressesOf(build('Mail', { Mail: types.Mail }, { to: oddShort })), + ).toStrictEqual(['0x0000000000000000000000000000000000000001']); + + expect( + TypedDataUtils.encodeData( + 'Mail', + { to: odd39 }, + types, + SignTypedDataVersion.V4, + ), + ).toStrictEqual( + TypedDataUtils.encodeData( + 'Mail', + { to: oddPadded }, + types, + SignTypedDataVersion.V4, + ), + ); + expect( + addressesOf(build('Mail', { Mail: types.Mail }, { to: odd39 })), + ).toStrictEqual([oddPadded]); + }); + + it('does not treat oversized 0x-hex as a signable address (encoder rejects 21 bytes)', () => { + const types = { + EIP712Domain: DOMAIN_TYPE, + Mail: [{ name: 'to', type: 'address' }], + }; + const oversizedHex = `0x${ADDR_A.slice(2)}42`; + + expect(() => + TypedDataUtils.encodeData( + 'Mail', + { to: oversizedHex }, + types, + SignTypedDataVersion.V4, + ), + ).toThrow(/21 bytes/u); + + // We still collect the leading 20 bytes. That is an extra scan of a value + // that cannot be signed, not a signed-but-unscanned address. + expect( + addressesOf(build('Mail', { Mail: types.Mail }, { to: oversizedHex })), + ).toStrictEqual([ADDR_A]); + }); + + it('bounds traversal work for a very large array', () => { + const huge = Array.from({ length: 100000 }, () => ADDR_A); + const data = build( + 'Batch', + { Batch: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: huge }, + ); + // Returns the distinct address without walking every element. + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + }); + + it('does not flag overflow at exactly the cap', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(10) }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toHaveLength(10); + expect(result.overflow).toBe(false); + }); + + it('caps returned addresses and flags overflow past the cap', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(15) }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toHaveLength(DEFAULT_MAX_SIGNATURE_ADDRESSES); + expect(result.overflow).toBe(true); + expect(result.maxAddresses).toBe(DEFAULT_MAX_SIGNATURE_ADDRESSES); + }); + + describe('maxAddresses', () => { + const permitBatch = (tokenCount: number): TypedDataFixture => + build( + 'PermitBatch', + { + PermitBatch: [ + { name: 'details', type: 'PermitDetails[]' }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' }, + ], + PermitDetails: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint160' }, + ], + }, + { + details: nAddresses(tokenCount).map((token) => ({ + token, + amount: '1', + })), + spender: ADDR_D, + sigDeadline: '1', + }, + ); + + it('honors a caller override below the ceiling', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(20) }, + ); + const result = extractSignatureAddresses(data, { maxAddresses: 20 }); + expect(result.addresses).toHaveLength(20); + expect(result.overflow).toBe(false); + expect(result.maxAddresses).toBe(20); + }); + + it('clamps an override above the ceiling', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(MAX_SIGNATURE_ADDRESSES_CEILING + 5) }, + ); + const result = extractSignatureAddresses(data, { maxAddresses: 100 }); + expect(result.addresses).toHaveLength(MAX_SIGNATURE_ADDRESSES_CEILING); + expect(result.overflow).toBe(true); + expect(result.maxAddresses).toBe(MAX_SIGNATURE_ADDRESSES_CEILING); + }); + + it('uses the default for non-finite or sub-one values', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(15) }, + ); + for (const maxAddresses of [ + NaN, + Infinity, + -1, + 0, + 0.4, + '20' as unknown as number, + ]) { + const result = extractSignatureAddresses(data, { maxAddresses }); + expect(result.addresses).toHaveLength(DEFAULT_MAX_SIGNATURE_ADDRESSES); + expect(result.overflow).toBe(true); + expect(result.maxAddresses).toBe(DEFAULT_MAX_SIGNATURE_ADDRESSES); + } + }); + + it('floors a fractional override', () => { + const data = build( + 'Airdrop', + { Airdrop: [{ name: 'recipients', type: 'address[]' }] }, + { recipients: nAddresses(15) }, + ); + const result = extractSignatureAddresses(data, { maxAddresses: 12.9 }); + expect(result.addresses).toHaveLength(12); + expect(result.overflow).toBe(true); + expect(result.maxAddresses).toBe(12); + }); + + it('does not overflow a 10-token PermitBatch when spender is excluded', () => { + const result = extractSignatureAddresses(permitBatch(10), { + excludeFields: ['spender'], + }); + expect(result.addresses).toHaveLength(10); + expect(result.overflow).toBe(false); + }); + + it('overflows an 11-token PermitBatch when spender is excluded', () => { + const result = extractSignatureAddresses(permitBatch(11), { + excludeFields: ['spender'], + }); + expect(result.addresses).toHaveLength(10); + expect(result.overflow).toBe(true); + }); + + it('overflows a 10-token PermitBatch when spender is not excluded', () => { + const result = extractSignatureAddresses(permitBatch(10)); + expect(result.addresses).toHaveLength(10); + expect(result.overflow).toBe(true); + }); + + it('returns the resolved cap for an unwalkable payload', () => { + expect( + extractSignatureAddresses(null, { maxAddresses: 25 }), + ).toStrictEqual({ + addresses: [], + fields: {}, + overflow: false, + maxAddresses: 25, + }); + }); + }); + + it('flags overflow when the work budget truncates the walk', () => { + // A long run of non-address nodes ahead of a trailing address exhausts the + // node budget, so the address is never reached. + const pad = Array.from({ length: 6000 }, (_, i) => i); + const data = build( + 'Batch', + { + Batch: [ + { name: 'pad', type: 'uint256[]' }, + { name: 'evil', type: 'address' }, + ], + }, + { pad, evil: ADDR_A }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toStrictEqual([]); + expect(result.overflow).toBe(true); + }); + + it('flags overflow when nesting exceeds the depth limit', () => { + const depth = 14; + const types: Record = {}; + for (let i = 0; i < depth; i++) { + types[`L${i}`] = [ + i < depth - 1 + ? { name: 'next', type: `L${i + 1}` } + : { name: 'addr', type: 'address' }, + ]; + } + let message: Record = { addr: ADDR_A }; + for (let i = depth - 2; i >= 0; i--) { + message = { next: message }; + } + const result = extractSignatureAddresses(build('L0', types, message)); + expect(result.addresses).toStrictEqual([]); + expect(result.overflow).toBe(true); + }); + + it('reports the field name each address was found under', () => { + const data = build( + 'T', + { + T: [ + { name: 'to', type: 'address' }, + { name: 'spender', type: 'address' }, + ], + }, + { to: ADDR_A, spender: ADDR_B }, + ); + expect(extractSignatureAddresses(data).fields).toStrictEqual({ + [ADDR_A]: 'to', + [ADDR_B]: 'spender', + }); + }); + + it('returns the full result shape with defaults for a benign payload', () => { + const data = build( + 'T', + { T: [{ name: 'to', type: 'address' }] }, + { to: ADDR_A }, + ); + expect(extractSignatureAddresses(data)).toStrictEqual({ + addresses: [ADDR_A], + fields: { [ADDR_A]: 'to' }, + overflow: false, + maxAddresses: DEFAULT_MAX_SIGNATURE_ADDRESSES, + }); + }); + + it('flags overflow when an address-bearing struct field value is not an object', () => { + const data = build( + 'Order', + { + Order: [ + { name: 'inner', type: 'Inner' }, + { name: 'to', type: 'address' }, + ], + Inner: [{ name: 'addr', type: 'address' }], + }, + { inner: 'not-an-object', to: ADDR_A }, + ); + expect(addressesOf(data)).toStrictEqual([ADDR_A]); + expect(extractSignatureAddresses(data).overflow).toBe(true); + }); + + it('does not flag overflow for a cyclic non-address struct whose value is not an object', () => { + const data = build( + 'Order', + { + Order: [ + { name: 'inner', type: 'Loop' }, + { name: 'to', type: 'address' }, + ], + Loop: [{ name: 'next', type: 'Loop' }], + }, + { inner: 'not-an-object', to: ADDR_A }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toStrictEqual([ADDR_A]); + expect(result.overflow).toBe(false); + }); + + it('does not flag overflow when a non-address struct value is not an object', () => { + const data = build( + 'Order', + { + Order: [ + { name: 'inner', type: 'Inner' }, + { name: 'to', type: 'address' }, + ], + Inner: [{ name: 'amount', type: 'uint256' }], + }, + { inner: 'not-an-object', to: ADDR_A }, + ); + const result = extractSignatureAddresses(data); + expect(result.addresses).toStrictEqual([ADDR_A]); + expect(result.overflow).toBe(false); + }); + + it('returns [] for nullish payloads, missing types, or unknown primaryType', () => { + expect(addressesOf(undefined)).toStrictEqual([]); + expect(addressesOf(null)).toStrictEqual([]); + expect( + addressesOf({ primaryType: 'X', message: { to: ADDR_A } }), + ).toStrictEqual([]); + expect( + addressesOf({ + types: { Y: [{ name: 'to', type: 'address' }] }, + primaryType: 'X', + message: { to: ADDR_A }, + }), + ).toStrictEqual([]); + // primaryType present, types present, but message missing/invalid. + expect( + addressesOf({ + types: { X: [{ name: 'to', type: 'address' }] }, + primaryType: 'X', + message: null, + }), + ).toStrictEqual([]); + }); +}); diff --git a/packages/phishing-controller/src/signature-address-extraction.ts b/packages/phishing-controller/src/signature-address-extraction.ts new file mode 100644 index 00000000000..de014c7a6a7 --- /dev/null +++ b/packages/phishing-controller/src/signature-address-extraction.ts @@ -0,0 +1,379 @@ +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +const HEX_STRING_REGEX = /^0x[0-9a-fA-F]+$/u; +const DECIMAL_STRING_REGEX = /^[0-9]+$/u; + +export const DEFAULT_MAX_SIGNATURE_ADDRESSES = 10; + +export const MAX_SIGNATURE_ADDRESSES_CEILING = 50; + +// Limit recursion depth when walking nested types. +const MAX_TRAVERSAL_DEPTH = 12; + +// Limit total nodes walked so a large or highly-repetitive payload cannot stall +// traversal, independent of how many distinct addresses are found. +const MAX_TRAVERSAL_NODES = 5000; + +type Eip712Field = { name: string; type: string }; +type Eip712Types = Record; + +/** + * The result of walking an EIP-712 typed-data message for `address`-typed + * values. + */ +export type ExtractedSignatureAddresses = { + /** + * Distinct canonical addresses to scan, capped at the effective `maxAddresses`. + */ + addresses: string[]; + /** + * Canonical address -> the field name it was first found under, so a caller + * can name the specific field in an alert. + */ + fields: Record; + /** + * True when the message could not be fully walked: more distinct addresses + * than the cap, traversal stopped by the depth or work budget, or an + * address-bearing type could not be walked (array type with a non-array + * value, or struct type with a non-object value). Some addresses may be + * unscanned, so the caller should surface a caution. + */ + overflow: boolean; + /** + * Effective address cap after applying the default and ceiling. + */ + maxAddresses: number; +}; + +/** + * Options for {@link extractSignatureAddresses}. + */ +export type ExtractSignatureAddressesOptions = { + /** + * Addresses to skip (e.g. the signer). The zero address is always excluded. + */ + exclude?: string[]; + /** + * Top-level field names to skip, used to avoid a duplicate scan/alert for a + * field already handled elsewhere (e.g. permit `spender`). Names must match + * the declared EIP-712 field exactly. Only applied to the primary type + * (depth 0), not nested structs. + */ + excludeFields?: string[]; + /** + * Distinct-address cap for this call. Defaults to + * {@link DEFAULT_MAX_SIGNATURE_ADDRESSES}. Clamped to + * {@link MAX_SIGNATURE_ADDRESSES_CEILING}. Invalid values use the default. + */ + maxAddresses?: number; +}; + +function resolveMaxAddresses(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_MAX_SIGNATURE_ADDRESSES; + } + const floored = Math.floor(value); + if (floored < 1) { + return DEFAULT_MAX_SIGNATURE_ADDRESSES; + } + return Math.min(floored, MAX_SIGNATURE_ADDRESSES_CEILING); +} + +/** + * Encode a non-negative integer as big-endian hex (even length) and take the + * leading 20 bytes. + * + * @param numeric - A non-negative integer. + * @returns Canonical lower-case 20-byte address. + */ +function leadingTwentyBytesFromInteger(numeric: bigint): string { + let digits = numeric.toString(16); + if (digits.length % 2 === 1) { + digits = `0${digits}`; + } + return `0x${digits.slice(0, 40).padStart(40, '0')}`; +} + +/** + * Reduce an `address`-typed value to canonical 20-byte hex. + * + * The signer accepts more than canonical hex for an `address` field (hex of any + * length, or a decimal string) and takes the high / leading 20 bytes of the + * big-endian encoding (`reallyStrangeAddressToBytes(value).subarray(0, 20)` / + * `hexToBytes(value).subarray(0, 20)` in `@metamask/eth-sig-util`), so matching + * only `0x` + 40 hex would miss an address encoded in another form. + * + * @param value - The raw field value from the message. + * @returns Canonical lower-case address, or undefined if not address-like. + */ +function normalizeAddress(value: unknown): string | undefined { + if (typeof value === 'string') { + const trimmed = value.trim(); + if (HEX_STRING_REGEX.test(trimmed)) { + let digits = trimmed.slice(2); + if (digits.length % 2 === 1) { + digits = `0${digits}`; + } + return `0x${digits.slice(0, 40).padStart(40, '0').toLowerCase()}`; + } + if (DECIMAL_STRING_REGEX.test(trimmed)) { + return leadingTwentyBytesFromInteger(BigInt(trimmed)); + } + return undefined; + } + + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) { + return leadingTwentyBytesFromInteger(BigInt(value)); + } + + return undefined; +} + +/** + * Collect every `address`-typed value in an EIP-712 message. + * + * Walks the `types` schema from `primaryType` and returns the value of each + * field declared as `address` or `address[]`, recursing into nested structs and + * arrays. Matching on the declared type rather than the field name means custom + * and unknown message shapes are covered without per-protocol handling. + * + * Type dispatch matches the signer: a custom struct in `types` is walked first + * (even if its name looks like `address` or `address[]`), then `address`, then + * types whose name ends in `]` as arrays. + * + * `domain` is not traversed; its `verifyingContract` is expected to be scanned + * separately by the caller. + * + * @param typedData - Parsed EIP-712 payload (`types`, `primaryType`, `message`). + * @param options - Optional configuration. + * @param options.exclude - Addresses to skip (e.g. the signer). The zero + * address is always excluded. + * @param options.excludeFields - Top-level field names to skip. Names must + * match the declared EIP-712 field exactly. Only applied to the primary type + * (depth 0), not nested structs. + * @param options.maxAddresses - Distinct-address cap. Defaults to + * {@link DEFAULT_MAX_SIGNATURE_ADDRESSES} and is clamped to + * {@link MAX_SIGNATURE_ADDRESSES_CEILING}. + * @returns Up to `maxAddresses` distinct canonical addresses, the field each + * was found under, whether the message could not be fully walked, and the + * effective cap. + */ +export function extractSignatureAddresses( + typedData: + | { types?: unknown; primaryType?: unknown; message?: unknown } + | null + | undefined, + options: ExtractSignatureAddressesOptions = {}, +): ExtractedSignatureAddresses { + const maxAddresses = resolveMaxAddresses(options.maxAddresses); + const types = typedData?.types as Eip712Types | undefined; + const primaryType = typedData?.primaryType as string | undefined; + const { message } = typedData ?? {}; + + if ( + !types || + typeof types !== 'object' || + !primaryType || + !Array.isArray(types[primaryType]) || + !message || + typeof message !== 'object' + ) { + return { addresses: [], fields: {}, overflow: false, maxAddresses }; + } + + // Narrowed alias so the hoisted helpers below see a defined `types`. + const schema = types; + + // ZERO_ADDRESS is already canonical (lower-case, 20 bytes), so it is added + // directly rather than round-tripped through `normalizeAddress`. + const excluded = new Set([ZERO_ADDRESS]); + for (const address of options.exclude ?? []) { + const normalized = normalizeAddress(address); + if (normalized) { + excluded.add(normalized); + } + } + + const excludedFields = new Set(options.excludeFields ?? []); + + // Canonical address -> the field name it was first found under. + const found = new Map(); + + // Set when the message could not be fully walked, so some addresses may be + // unscanned: the address cap, the depth limit, the work budget, or an + // address-bearing type whose value could not be walked. + let overflow = false; + + // Total nodes walked, bounded by MAX_TRAVERSAL_NODES. + let visited = 0; + + // Stopping the walk (depth or work budget) leaves later fields unscanned, so + // it is treated as overflow the same way the distinct-address cap is. + const truncated = (depth: number): boolean => { + if (depth > MAX_TRAVERSAL_DEPTH || visited >= MAX_TRAVERSAL_NODES) { + overflow = true; + return true; + } + return false; + }; + + /** + * Whether `type` can contain `address` values: the `address` primitive, an + * array of an address-bearing type, or a custom struct that contains one. + * + * @param type - The declared EIP-712 type. + * @param seen - Types already inspected, to break recursive structs. + * @returns True when walking this type can yield addresses. + */ + function isAddressBearing( + type: string, + seen: Set = new Set(), + ): boolean { + if (seen.has(type)) { + return false; + } + seen.add(type); + + const structFields = schema[type]; + if (Array.isArray(structFields)) { + return structFields.some( + (field) => + Boolean(field) && + typeof field.type === 'string' && + isAddressBearing(field.type, seen), + ); + } + + if (type === 'address') { + return true; + } + + if (type.endsWith(']')) { + return isAddressBearing(type.slice(0, type.lastIndexOf('[')), seen); + } + + return false; + } + + /** + * Record a candidate address value under a field name, applying exclusions, + * de-duplication, and the distinct-address cap. + * + * @param field - The field name the value was found under. + * @param value - The raw field value to normalize and collect. + */ + function collect(field: string, value: unknown): void { + const address = normalizeAddress(value); + if (!address || excluded.has(address) || found.has(address)) { + return; + } + if (found.size >= maxAddresses) { + overflow = true; + return; + } + found.set(address, field); + } + + /** + * Walk the fields of a struct type, recursing per field. + * + * @param structName - The name of the struct type in the schema. + * @param value - The message object corresponding to the struct. + * @param depth - The current traversal depth. + */ + function visitStruct( + structName: string, + value: unknown, + depth: number, + ): void { + if (truncated(depth)) { + return; + } + const structFields = schema[structName]; + if (!Array.isArray(structFields) || !value || typeof value !== 'object') { + if (Array.isArray(structFields) && isAddressBearing(structName)) { + overflow = true; + } + return; + } + for (const field of structFields) { + if (truncated(depth)) { + return; + } + if ( + !field || + typeof field.name !== 'string' || + typeof field.type !== 'string' || + // Field exclusions only apply to the primary type (depth 0), matching + // the top-level field a dedicated caller already covers. Names must + // match the declared EIP-712 field exactly. + (depth === 0 && excludedFields.has(field.name)) + ) { + continue; + } + visitField( + field.name, + field.type, + (value as Record)[field.name], + depth, + ); + } + } + + /** + * Walk a single field value, handling custom structs, `address`, and arrays. + * + * Precedence matches `@metamask/eth-sig-util` `encodeField`: a type present + * in the schema is a struct first; otherwise `address`; otherwise a name + * ending in `]` is treated as an array (`type.slice(0, lastIndexOf('['))`). + * + * @param field - The field name. + * @param type - The declared EIP-712 type of the field. + * @param value - The field value from the message. + * @param depth - The current traversal depth. + */ + function visitField( + field: string, + type: string, + value: unknown, + depth: number, + ): void { + visited += 1; + if (truncated(depth)) { + return; + } + + if (Array.isArray(schema[type])) { + visitStruct(type, value, depth + 1); + return; + } + + if (type === 'address') { + collect(field, value); + return; + } + + if (type.endsWith(']')) { + if (Array.isArray(value)) { + const innerType = type.slice(0, type.lastIndexOf('[')); + for (const item of value) { + if (truncated(depth)) { + return; + } + visitField(field, innerType, item, depth + 1); + } + } else if (isAddressBearing(type)) { + overflow = true; + } + } + } + + visitStruct(primaryType, message, 0); + + return { + addresses: Array.from(found.keys()), + fields: Object.fromEntries(found), + overflow, + maxAddresses, + }; +} diff --git a/yarn.lock b/yarn.lock index 6f35e6397a7..f7ef9413a87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8321,6 +8321,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/eth-sig-util": "npm:^9.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/transaction-controller": "npm:^69.8.1" "@noble/hashes": "npm:^1.8.0"