diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index e7746d57b968..302f73f31d8f 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1495,6 +1495,17 @@ "alertMessageSignInWrongAccount": { "message": "This site is asking you to sign in using the wrong account." }, + "alertMessageSignatureAddressMalicious": { + "message": "The $1 address $2 has been identified as malicious. If you confirm this request, you will probably lose your assets to a scammer.", + "description": "$1 is the message field name and $2 is the shortened address flagged as malicious." + }, + "alertMessageSignatureAddressScanIncomplete": { + "message": "Some addresses in this request couldn't be checked for known threats. Only continue if you trust this site." + }, + "alertMessageSignatureAddressWarning": { + "message": "The $1 address $2 can't be verified. Only continue if you trust the source.", + "description": "$1 is the message field name and $2 is the shortened address that needs review." + }, "alertMessageSuggestedGasFeeHigh": { "message": "This site is suggesting a higher network fee than necessary. Edit the network fee to pay less." }, @@ -1567,6 +1578,9 @@ "alertReasonSignIn": { "message": "Suspicious sign-in request" }, + "alertReasonSignatureAddressScanIncomplete": { + "message": "Addresses not fully checked" + }, "alertReasonSuggestedGasFeeHigh": { "message": "High site fee" }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index e7746d57b968..302f73f31d8f 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -1495,6 +1495,17 @@ "alertMessageSignInWrongAccount": { "message": "This site is asking you to sign in using the wrong account." }, + "alertMessageSignatureAddressMalicious": { + "message": "The $1 address $2 has been identified as malicious. If you confirm this request, you will probably lose your assets to a scammer.", + "description": "$1 is the message field name and $2 is the shortened address flagged as malicious." + }, + "alertMessageSignatureAddressScanIncomplete": { + "message": "Some addresses in this request couldn't be checked for known threats. Only continue if you trust this site." + }, + "alertMessageSignatureAddressWarning": { + "message": "The $1 address $2 can't be verified. Only continue if you trust the source.", + "description": "$1 is the message field name and $2 is the shortened address that needs review." + }, "alertMessageSuggestedGasFeeHigh": { "message": "This site is suggesting a higher network fee than necessary. Edit the network fee to pay less." }, @@ -1567,6 +1578,9 @@ "alertReasonSignIn": { "message": "Suspicious sign-in request" }, + "alertReasonSignatureAddressScanIncomplete": { + "message": "Addresses not fully checked" + }, "alertReasonSuggestedGasFeeHigh": { "message": "High site fee" }, diff --git a/app/scripts/lib/ppom/ppom-middleware.ts b/app/scripts/lib/ppom/ppom-middleware.ts index 72c81afa54c9..b9daa44149ca 100644 --- a/app/scripts/lib/ppom/ppom-middleware.ts +++ b/app/scripts/lib/ppom/ppom-middleware.ts @@ -18,7 +18,11 @@ import { SIGNING_METHODS } from '../../../../shared/constants/transaction'; import { PreferencesController } from '../../controllers/preferences-controller'; import { AppStateController } from '../../controllers/app-state-controller'; import { trace, TraceContext, TraceName } from '../../../../shared/lib/trace'; -import { LOADING_SECURITY_ALERT_RESPONSE } from '../../../../shared/constants/security-provider'; +import { + BlockaidResultType, + LOADING_SECURITY_ALERT_RESPONSE, +} from '../../../../shared/constants/security-provider'; +import { scanUnvalidatedSignatureAddresses } from '../trust-signals/scan-unvalidated-signature'; import { generateSecurityAlertId, handlePPOMError, @@ -117,7 +121,7 @@ export function createPPOMMiddleware< const securityAlertId = generateSecurityAlertId(); - trace( + const validationPromise = trace( { name: TraceName.PPOMValidation, parentContext: req.traceContext }, () => validateRequestWithPPOM({ @@ -130,6 +134,26 @@ export function createPPOMMiddleware< }), ); + if (SIGNING_METHODS.includes(req.method)) { + Promise.resolve(validationPromise) + .then((securityAlertResponse) => { + const resultType = securityAlertResponse?.result_type; + const ppomFlagged = + resultType === BlockaidResultType.Malicious || + resultType === BlockaidResultType.Warning; + if (!ppomFlagged) { + scanUnvalidatedSignatureAddresses({ + request: req, + chainId: chainId as Hex, + appStateController, + }); + } + }) + .catch(() => { + // ignore — scan is fire-and-forget + }); + } + const securityAlertResponseLoading: SecurityAlertResponse = { ...LOADING_SECURITY_ALERT_RESPONSE, securityAlertId, diff --git a/app/scripts/lib/ppom/ppom-util.ts b/app/scripts/lib/ppom/ppom-util.ts index dda292c7fa66..7deaf62820ae 100644 --- a/app/scripts/lib/ppom/ppom-util.ts +++ b/app/scripts/lib/ppom/ppom-util.ts @@ -72,7 +72,7 @@ export async function validateRequestWithPPOM({ chainId: Hex; updateSecurityAlertResponse: UpdateSecurityAlertResponse; getSecurityAlertsConfig?: GetSecurityAlertsConfig; -}) { +}): Promise { try { const controllerObject = await updateSecurityResponse( request.method, @@ -98,14 +98,23 @@ export async function validateRequestWithPPOM({ ); await updateSecurityResponse(request.method, securityAlertId, ppomResponse); + + return ppomResponse; } catch (error: unknown) { log('Error', error); + const errorResponse = handlePPOMError( + error, + 'Error validating JSON RPC using PPOM: ', + ); + await updateSecurityResponse( request.method, securityAlertId, - handlePPOMError(error, 'Error validating JSON RPC using PPOM: '), + errorResponse, ); + + return errorResponse; } } diff --git a/app/scripts/lib/trust-signals/scan-unvalidated-signature.test.ts b/app/scripts/lib/trust-signals/scan-unvalidated-signature.test.ts new file mode 100644 index 000000000000..156cb1f67f36 --- /dev/null +++ b/app/scripts/lib/trust-signals/scan-unvalidated-signature.test.ts @@ -0,0 +1,219 @@ +import { scanUnvalidatedSignatureAddresses } from './scan-unvalidated-signature'; + +const MALICIOUS_ADDRESS = '0x0000000000000000000000000000000000000bad'; +const SIGNER_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const CHAIN_ID = '0x1'; + +const TYPED_DATA_V4 = { + types: { + Transfer: [{ name: 'recipient', type: 'address' }], + }, + primaryType: 'Transfer', + message: { recipient: MALICIOUS_ADDRESS }, +}; + +const makeRequest = ( + method: string, + signer: string, + data: unknown, +): { method: string; params: unknown[] } => ({ + method, + params: [signer, JSON.stringify(data)], +}); + +const makeCache = () => { + const cache: Record = {}; + return { + getAddressSecurityAlertResponse: (address: string) => cache[address], + addAddressSecurityAlertResponse: (address: string, response: unknown) => { + cache[address] = response; + }, + }; +}; + +jest.mock('../ppom/security-alerts-api', () => ({ + isSecurityAlertsAPIEnabled: jest.fn(), +})); + +jest.mock('../../../../shared/lib/trust-signals', () => ({ + mapChainIdToSupportedEVMChain: jest.fn(), +})); + +jest.mock('./security-alerts-api', () => ({ + scanAddressAndAddToCache: jest.fn().mockResolvedValue(undefined), +})); + +const mockIsSecurityAlertsAPIEnabled = jest.requireMock( + '../ppom/security-alerts-api', +).isSecurityAlertsAPIEnabled; + +const mockMapChainIdToSupportedEVMChain = jest.requireMock( + '../../../../shared/lib/trust-signals', +).mapChainIdToSupportedEVMChain; + +const mockScanAddressAndAddToCache = jest.requireMock( + './security-alerts-api', +).scanAddressAndAddToCache; + +describe('scanUnvalidatedSignatureAddresses', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsSecurityAlertsAPIEnabled.mockReturnValue(true); + mockMapChainIdToSupportedEVMChain.mockReturnValue('ethereum'); + }); + + it('scans extracted address fields after PPOM passes', () => { + const cache = makeCache(); + scanUnvalidatedSignatureAddresses({ + request: makeRequest( + 'eth_signTypedData_v4', + SIGNER_ADDRESS, + TYPED_DATA_V4, + ), + chainId: CHAIN_ID as `0x${string}`, + appStateController: cache, + }); + + expect(mockScanAddressAndAddToCache).toHaveBeenCalledWith( + MALICIOUS_ADDRESS, + cache.getAddressSecurityAlertResponse, + cache.addAddressSecurityAlertResponse, + 'ethereum', + ); + }); + + it('does nothing for non-typed-data methods', () => { + scanUnvalidatedSignatureAddresses({ + request: { method: 'personal_sign', params: [SIGNER_ADDRESS, '0xdeadbeef'] }, + chainId: CHAIN_ID as `0x${string}`, + appStateController: makeCache(), + }); + + expect(mockScanAddressAndAddToCache).not.toHaveBeenCalled(); + }); + + it('does nothing for v1 typed data', () => { + scanUnvalidatedSignatureAddresses({ + request: { method: 'eth_signTypedData', params: [SIGNER_ADDRESS, '{}'] }, + chainId: CHAIN_ID as `0x${string}`, + appStateController: makeCache(), + }); + + expect(mockScanAddressAndAddToCache).not.toHaveBeenCalled(); + }); + + it('does nothing when security alerts API is disabled', () => { + mockIsSecurityAlertsAPIEnabled.mockReturnValue(false); + + scanUnvalidatedSignatureAddresses({ + request: makeRequest( + 'eth_signTypedData_v4', + SIGNER_ADDRESS, + TYPED_DATA_V4, + ), + chainId: CHAIN_ID as `0x${string}`, + appStateController: makeCache(), + }); + + expect(mockScanAddressAndAddToCache).not.toHaveBeenCalled(); + }); + + it('does nothing on an unsupported chain', () => { + mockMapChainIdToSupportedEVMChain.mockReturnValue(null); + + scanUnvalidatedSignatureAddresses({ + request: makeRequest( + 'eth_signTypedData_v4', + SIGNER_ADDRESS, + TYPED_DATA_V4, + ), + chainId: '0x999' as `0x${string}`, + appStateController: makeCache(), + }); + + expect(mockScanAddressAndAddToCache).not.toHaveBeenCalled(); + }); + + it('excludes the signer address', () => { + const data = { + types: { T: [{ name: 'recipient', type: 'address' }] }, + primaryType: 'T', + message: { recipient: SIGNER_ADDRESS }, + }; + + scanUnvalidatedSignatureAddresses({ + request: makeRequest('eth_signTypedData_v4', SIGNER_ADDRESS, data), + chainId: CHAIN_ID as `0x${string}`, + appStateController: makeCache(), + }); + + expect(mockScanAddressAndAddToCache).not.toHaveBeenCalled(); + }); + + it('excludes permit spender when verifyingContract is present', () => { + const data = { + types: { Permit: [{ name: 'spender', type: 'address' }] }, + primaryType: 'Permit', + domain: { verifyingContract: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC' }, + message: { spender: MALICIOUS_ADDRESS }, + }; + + scanUnvalidatedSignatureAddresses({ + request: makeRequest('eth_signTypedData_v4', SIGNER_ADDRESS, data), + chainId: CHAIN_ID as `0x${string}`, + appStateController: makeCache(), + }); + + expect(mockScanAddressAndAddToCache).not.toHaveBeenCalled(); + }); + + it('does not exclude permit spender when verifyingContract is absent', () => { + const data = { + types: { Permit: [{ name: 'spender', type: 'address' }] }, + primaryType: 'Permit', + domain: {}, + message: { spender: MALICIOUS_ADDRESS }, + }; + + scanUnvalidatedSignatureAddresses({ + request: makeRequest('eth_signTypedData_v4', SIGNER_ADDRESS, data), + chainId: CHAIN_ID as `0x${string}`, + appStateController: makeCache(), + }); + + expect(mockScanAddressAndAddToCache).toHaveBeenCalledWith( + MALICIOUS_ADDRESS, + expect.any(Function), + expect.any(Function), + 'ethereum', + ); + }); + + it('handles malformed typed data without throwing', () => { + expect(() => + scanUnvalidatedSignatureAddresses({ + request: { method: 'eth_signTypedData_v4', params: [SIGNER_ADDRESS, 'not-json{'] }, + chainId: CHAIN_ID as `0x${string}`, + appStateController: makeCache(), + }), + ).not.toThrow(); + + expect(mockScanAddressAndAddToCache).not.toHaveBeenCalled(); + }); + + it('accepts typed data as an object in params[1]', () => { + const cache = makeCache(); + scanUnvalidatedSignatureAddresses({ + request: { method: 'eth_signTypedData_v4', params: [SIGNER_ADDRESS, TYPED_DATA_V4] }, + chainId: CHAIN_ID as `0x${string}`, + appStateController: cache, + }); + + expect(mockScanAddressAndAddToCache).toHaveBeenCalledWith( + MALICIOUS_ADDRESS, + expect.any(Function), + expect.any(Function), + 'ethereum', + ); + }); +}); diff --git a/app/scripts/lib/trust-signals/scan-unvalidated-signature.ts b/app/scripts/lib/trust-signals/scan-unvalidated-signature.ts new file mode 100644 index 000000000000..2a766580202b --- /dev/null +++ b/app/scripts/lib/trust-signals/scan-unvalidated-signature.ts @@ -0,0 +1,86 @@ +import type { Hex } from '@metamask/utils'; +import { extractSignatureAddresses } from '@metamask/phishing-controller'; +import type { AppStateController } from '../../controllers/app-state-controller'; +import { parseTypedDataMessage } from '../../../../shared/lib/transaction.utils'; +import { MESSAGE_TYPE } from '../../../../shared/constants/app'; +import { mapChainIdToSupportedEVMChain } from '../../../../shared/lib/trust-signals'; +import { PRIMARY_TYPES_PERMIT } from '../../../../shared/constants/signatures'; +import { isSecurityAlertsAPIEnabled } from '../ppom/security-alerts-api'; +import { scanAddressAndAddToCache } from './security-alerts-api'; + +type AppStateCache = Pick< + AppStateController, + 'getAddressSecurityAlertResponse' | 'addAddressSecurityAlertResponse' +>; + +/** + * Scan the address fields of a typed-data signature request against the + * real-time security-alerts API. Called after PPOM when PPOM has not flagged + * the request, since PPOM's threat data is refreshed on a delay. Results are + * cached so addresses scanned elsewhere are not re-requested. + */ +export function scanUnvalidatedSignatureAddresses({ + request, + chainId, + appStateController, +}: { + request: { method: string; params?: unknown }; + chainId: Hex; + appStateController: AppStateCache; +}): void { + if ( + request.method !== MESSAGE_TYPE.ETH_SIGN_TYPED_DATA_V3 && + request.method !== MESSAGE_TYPE.ETH_SIGN_TYPED_DATA_V4 + ) { + return; + } + + if (!isSecurityAlertsAPIEnabled()) { + return; + } + + const { params } = request; + if (!Array.isArray(params) || params[1] === undefined || params[1] === null) { + return; + } + + const supportedEVMChain = mapChainIdToSupportedEVMChain(chainId); + if (!supportedEVMChain) { + return; + } + + let typedDataMessage; + try { + typedDataMessage = parseTypedDataMessage( + typeof params[1] === 'string' ? params[1] : JSON.stringify(params[1]), + ); + } catch (error) { + console.error('Error parsing typed data for signature address scan', error); + return; + } + + const signerAddress = typeof params[0] === 'string' ? params[0] : undefined; + + const isPermit = PRIMARY_TYPES_PERMIT.some( + (type) => type === typedDataMessage.primaryType, + ); + const hasVerifyingContract = Boolean( + typedDataMessage.domain?.verifyingContract, + ); + + const { addresses } = extractSignatureAddresses(typedDataMessage, { + exclude: signerAddress ? [signerAddress] : [], + excludeFields: isPermit && hasVerifyingContract ? ['spender'] : [], + }); + + for (const address of addresses) { + scanAddressAndAddToCache( + address, + appStateController.getAddressSecurityAlertResponse, + appStateController.addAddressSecurityAlertResponse, + supportedEVMChain, + ).catch((error) => { + console.error('Error scanning signature address', error); + }); + } +} diff --git a/ui/pages/confirmations/hooks/alerts/useSignatureAddressAlerts.test.ts b/ui/pages/confirmations/hooks/alerts/useSignatureAddressAlerts.test.ts new file mode 100644 index 000000000000..4d8cfedbedfa --- /dev/null +++ b/ui/pages/confirmations/hooks/alerts/useSignatureAddressAlerts.test.ts @@ -0,0 +1,268 @@ +import { CHAIN_IDS } from '../../../../../shared/constants/network'; +import { Severity } from '../../../../helpers/constants/design-system'; +import { RowAlertKey } from '../../../../components/app/confirm/info/row/constants'; +import { TrustSignalDisplayState } from '../../../../hooks/useTrustSignals'; +import { + getMockTypedSignConfirmState, + getMockTypedSignConfirmStateForRequest, +} from '../../../../../test/data/confirmations/helper'; +import { + unapprovedTypedSignMsgV4, + rawMessageV4, +} from '../../../../../test/data/confirmations/typed_sign'; +import { renderHookWithConfirmContextProvider } from '../../../../../test/lib/confirmations/render-helpers'; +import { SignatureRequestType } from '../../types/confirm'; +import { useSignatureAddressAlerts } from './useSignatureAddressAlerts'; + +jest.mock('../../../../hooks/useTrustSignals', () => ({ + useTrustSignals: jest.fn(), + TrustSignalDisplayState: { + Malicious: 'malicious', + Warning: 'warning', + Unknown: 'unknown', + }, +})); + +jest.mock('../../../../../app/scripts/lib/ppom/security-alerts-api', () => ({ + isSecurityAlertsAPIEnabled: jest.fn(), +})); + +jest.mock('../../../../hooks/useI18nContext', () => ({ + useI18nContext: jest.fn(() => (key: string) => key), +})); + +const mockUseTrustSignals = jest.requireMock( + '../../../../hooks/useTrustSignals', +).useTrustSignals; + +const mockIsSecurityAlertsAPIEnabled = jest.requireMock( + '../../../../../app/scripts/lib/ppom/security-alerts-api', +).isSecurityAlertsAPIEnabled; + +const MALICIOUS_ADDRESS = '0x0000000000000000000000000000000000000bad'; +const WARNING_ADDRESS = '0x0000000000000000000000000000000000000001'; +const SIGNER_ADDRESS = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'; + +const makeTypedSignV4 = (messageOverrides: object) => + ({ + ...unapprovedTypedSignMsgV4, + msgParams: { + ...unapprovedTypedSignMsgV4.msgParams, + data: JSON.stringify({ + ...rawMessageV4, + ...messageOverrides, + }), + }, + }) as SignatureRequestType; + +describe('useSignatureAddressAlerts', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsSecurityAlertsAPIEnabled.mockReturnValue(true); + mockUseTrustSignals.mockReturnValue([]); + }); + + it('returns empty array when security alerts API is disabled', () => { + mockIsSecurityAlertsAPIEnabled.mockReturnValue(false); + + const { result } = renderHookWithConfirmContextProvider( + () => useSignatureAddressAlerts(), + getMockTypedSignConfirmState(), + ); + + expect(result.current).toEqual([]); + }); + + it('returns empty array when no message fields contain addresses', () => { + const request = makeTypedSignV4({ + types: { Greeting: [{ name: 'text', type: 'string' }] }, + primaryType: 'Greeting', + message: { text: 'Hello world' }, + }); + + const { result } = renderHookWithConfirmContextProvider( + () => useSignatureAddressAlerts(), + getMockTypedSignConfirmStateForRequest(request), + ); + + expect(result.current).toEqual([]); + }); + + it('returns a danger alert for a malicious address field', () => { + const request = makeTypedSignV4({ + types: { + Transfer: [{ name: 'recipient', type: 'address' }], + }, + primaryType: 'Transfer', + message: { recipient: MALICIOUS_ADDRESS }, + }); + + mockUseTrustSignals.mockReturnValue([ + { state: TrustSignalDisplayState.Malicious }, + ]); + + const { result } = renderHookWithConfirmContextProvider( + () => useSignatureAddressAlerts(), + getMockTypedSignConfirmStateForRequest(request), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toMatchObject({ + key: `signatureAddressTrustSignalMalicious_${MALICIOUS_ADDRESS}`, + severity: Severity.Danger, + field: RowAlertKey.InteractingWith, + isBlocking: false, + }); + }); + + it('returns a warning alert for a flagged address field', () => { + const request = makeTypedSignV4({ + types: { + Transfer: [{ name: 'recipient', type: 'address' }], + }, + primaryType: 'Transfer', + message: { recipient: WARNING_ADDRESS }, + }); + + mockUseTrustSignals.mockReturnValue([ + { state: TrustSignalDisplayState.Warning }, + ]); + + const { result } = renderHookWithConfirmContextProvider( + () => useSignatureAddressAlerts(), + getMockTypedSignConfirmStateForRequest(request), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toMatchObject({ + key: `signatureAddressTrustSignalWarning_${WARNING_ADDRESS}`, + severity: Severity.Warning, + field: RowAlertKey.InteractingWith, + }); + }); + + it('excludes the signer address from alerts', () => { + const request = makeTypedSignV4({ + types: { T: [{ name: 'addr', type: 'address' }] }, + primaryType: 'T', + message: { addr: SIGNER_ADDRESS }, + }); + + const { result } = renderHookWithConfirmContextProvider( + () => useSignatureAddressAlerts(), + getMockTypedSignConfirmStateForRequest(request), + ); + + expect(mockUseTrustSignals).toHaveBeenCalledWith([]); + expect(result.current).toEqual([]); + }); + + it('excludes permit spender field from scan', () => { + const request = makeTypedSignV4({ + types: { + Permit: [{ name: 'spender', type: 'address' }], + }, + domain: { + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + primaryType: 'Permit', + message: { spender: MALICIOUS_ADDRESS }, + }); + + const { result } = renderHookWithConfirmContextProvider( + () => useSignatureAddressAlerts(), + getMockTypedSignConfirmStateForRequest(request), + ); + + expect(mockUseTrustSignals).toHaveBeenCalledWith([]); + expect(result.current).toEqual([]); + }); + + it('returns overflow caution alert when address cap is exceeded', () => { + const addresses: Record = {}; + const types: { name: string; type: string }[] = []; + for (let i = 0; i < 12; i += 1) { + const name = `addr${i}`; + const addr = `0x${String(i).padStart(40, '0')}`; + addresses[name] = addr; + types.push({ name, type: 'address' }); + } + + const request = makeTypedSignV4({ + types: { Flood: types }, + primaryType: 'Flood', + message: addresses, + }); + + mockUseTrustSignals.mockReturnValue( + new Array(10).fill({ state: TrustSignalDisplayState.Unknown }), + ); + + const { result } = renderHookWithConfirmContextProvider( + () => useSignatureAddressAlerts(), + getMockTypedSignConfirmStateForRequest(request), + ); + + expect(result.current.some((a) => a.key === 'signatureAddressScanIncomplete')).toBe(true); + expect( + result.current.find((a) => a.key === 'signatureAddressScanIncomplete'), + ).toMatchObject({ + severity: Severity.Warning, + field: RowAlertKey.InteractingWith, + }); + }); + + it('handles malformed typed data without throwing', () => { + const request = { + ...unapprovedTypedSignMsgV4, + msgParams: { + ...unapprovedTypedSignMsgV4.msgParams, + data: 'not-valid-json{', + }, + } as SignatureRequestType; + + const { result } = renderHookWithConfirmContextProvider( + () => useSignatureAddressAlerts(), + getMockTypedSignConfirmStateForRequest(request), + ); + + expect(result.current).toEqual([]); + }); + + it('returns no alerts for unknown trust signal state', () => { + const request = makeTypedSignV4({ + types: { + Transfer: [{ name: 'recipient', type: 'address' }], + }, + primaryType: 'Transfer', + message: { recipient: '0x0000000000000000000000000000000000000002' }, + }); + + mockUseTrustSignals.mockReturnValue([ + { state: TrustSignalDisplayState.Unknown }, + ]); + + const { result } = renderHookWithConfirmContextProvider( + () => useSignatureAddressAlerts(), + getMockTypedSignConfirmStateForRequest(request), + ); + + expect(result.current).toEqual([]); + }); + + it('passes all extracted addresses to useTrustSignals', () => { + const { result } = renderHookWithConfirmContextProvider( + () => useSignatureAddressAlerts(), + getMockTypedSignConfirmState(), + ); + + expect(mockUseTrustSignals).toHaveBeenCalled(); + const calls = mockUseTrustSignals.mock.calls[0][0]; + expect(calls.length).toBeGreaterThan(0); + calls.forEach((entry: { chainId: string }) => { + expect(entry.chainId).toBe(CHAIN_IDS.GOERLI); + }); + + expect(result.current).toEqual([]); + }); +}); diff --git a/ui/pages/confirmations/hooks/alerts/useSignatureAddressAlerts.ts b/ui/pages/confirmations/hooks/alerts/useSignatureAddressAlerts.ts new file mode 100644 index 000000000000..35dbc1b04768 --- /dev/null +++ b/ui/pages/confirmations/hooks/alerts/useSignatureAddressAlerts.ts @@ -0,0 +1,133 @@ +import { useMemo } from 'react'; +import { NameType } from '@metamask/name-controller'; +import { extractSignatureAddresses } from '@metamask/phishing-controller'; + +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { shortenAddress } from '../../../../helpers/utils/util'; +import { useConfirmContext } from '../../context/confirm'; +import { isSignatureTransactionType } from '../../utils'; +import { SignatureRequestType } from '../../types/confirm'; +import { parseTypedDataMessage } from '../../../../../shared/lib/transaction.utils'; +import { PRIMARY_TYPES_PERMIT } from '../../../../../shared/constants/signatures'; +import { Alert } from '../../../../ducks/confirm-alerts/confirm-alerts'; +import { RowAlertKey } from '../../../../components/app/confirm/info/row/constants'; +import { Severity } from '../../../../helpers/constants/design-system'; +import { + useTrustSignals, + TrustSignalDisplayState, +} from '../../../../hooks/useTrustSignals'; +// eslint-disable-next-line import-x/no-restricted-paths +import { isSecurityAlertsAPIEnabled } from '../../../../../app/scripts/lib/ppom/security-alerts-api'; + +/** + * Generate trust-signal alerts for the address fields of a typed-data + * signature. Every address-typed field is scanned; the top-level permit + * `spender` is left to `useSpenderAlerts` to avoid a duplicate alert. + */ +export function useSignatureAddressAlerts(): Alert[] { + const t = useI18nContext(); + const { currentConfirmation } = useConfirmContext(); + + const { + addresses: signatureAddresses, + fields, + overflow, + } = useMemo(() => { + const empty = { + addresses: [] as string[], + fields: {} as Record, + overflow: false, + }; + + if ( + !currentConfirmation || + !isSignatureTransactionType(currentConfirmation) || + currentConfirmation.type !== 'eth_signTypedData' || + !isSecurityAlertsAPIEnabled() + ) { + return empty; + } + + const signatureRequest = currentConfirmation as SignatureRequestType; + const msgData = signatureRequest.msgParams?.data as string; + if (!msgData) { + return empty; + } + + try { + const parsed = parseTypedDataMessage(msgData); + const signer = signatureRequest.msgParams?.from as string | undefined; + const isPermit = PRIMARY_TYPES_PERMIT.some( + (type) => type === parsed.primaryType, + ); + return extractSignatureAddresses(parsed, { + exclude: signer ? [signer] : [], + excludeFields: isPermit ? ['spender'] : [], + }); + } catch { + return empty; + } + }, [currentConfirmation]); + + const trustSignals = useTrustSignals( + signatureAddresses.map((value) => ({ + value, + type: NameType.ETHEREUM_ADDRESS, + chainId: currentConfirmation?.chainId, + })), + ); + + return useMemo(() => { + const alerts: Alert[] = []; + + if (overflow) { + alerts.push({ + actions: [], + field: RowAlertKey.InteractingWith, + isBlocking: false, + key: 'signatureAddressScanIncomplete', + message: t('alertMessageSignatureAddressScanIncomplete'), + reason: t('alertReasonSignatureAddressScanIncomplete'), + severity: Severity.Warning, + }); + } + + if (signatureAddresses.length === 0) { + return alerts; + } + + trustSignals.forEach(({ state }, index) => { + const address = signatureAddresses[index]; + + if (state === TrustSignalDisplayState.Malicious) { + alerts.push({ + actions: [], + field: RowAlertKey.InteractingWith, + isBlocking: false, + key: `signatureAddressTrustSignalMalicious_${address}`, + message: t('alertMessageSignatureAddressMalicious', [ + fields[address], + shortenAddress(address), + ]), + reason: t('nameModalTitleMalicious'), + severity: Severity.Danger, + }); + } else if (state === TrustSignalDisplayState.Warning) { + alerts.push({ + actions: [], + field: RowAlertKey.InteractingWith, + isBlocking: false, + key: `signatureAddressTrustSignalWarning_${address}`, + message: t('alertMessageSignatureAddressWarning', [ + fields[address], + shortenAddress(address), + ]), + reason: t('nameModalTitleWarning'), + severity: Severity.Warning, + }); + } + }); + + return alerts; + }, [signatureAddresses, fields, overflow, trustSignals, t]); +} diff --git a/ui/pages/confirmations/hooks/useConfirmationAlerts.ts b/ui/pages/confirmations/hooks/useConfirmationAlerts.ts index ca404e5985f4..24a3ac35dfdb 100644 --- a/ui/pages/confirmations/hooks/useConfirmationAlerts.ts +++ b/ui/pages/confirmations/hooks/useConfirmationAlerts.ts @@ -27,6 +27,7 @@ import { useNetworkAndOriginSwitchingAlerts } from './alerts/useNetworkAndOrigin import { useSelectedAccountAlerts } from './alerts/useSelectedAccountAlerts'; import { useAddressTrustSignalAlerts } from './alerts/useAddressTrustSignalAlerts'; import { useOriginTrustSignalAlerts } from './alerts/useOriginTrustSignalAlerts'; +import { useSignatureAddressAlerts } from './alerts/useSignatureAddressAlerts'; import { useSpenderAlerts } from './alerts/useSpenderAlerts'; import { useTokenTrustSignalAlerts } from './alerts/useTokenTrustSignalAlerts'; import { useShieldCoverageAlert } from './alerts/useShieldCoverageAlert'; @@ -135,6 +136,7 @@ export default function useConfirmationAlerts(): Alert[] { const addressTrustSignalAlerts = useAddressTrustSignalAlerts(); const originTrustSignalAlerts = useOriginTrustSignalAlerts(); const spenderAlerts = useSpenderAlerts(); + const signatureAddressAlerts = useSignatureAddressAlerts(); const addEthereumChainAlerts = useAddEthereumChainAlerts(); return useMemo( @@ -148,6 +150,7 @@ export default function useConfirmationAlerts(): Alert[] { ...addressTrustSignalAlerts, ...originTrustSignalAlerts, ...spenderAlerts, + ...signatureAddressAlerts, ...addEthereumChainAlerts, ], [ @@ -160,6 +163,7 @@ export default function useConfirmationAlerts(): Alert[] { addressTrustSignalAlerts, originTrustSignalAlerts, spenderAlerts, + signatureAddressAlerts, addEthereumChainAlerts, ], );