From bc68bce606e951b7cde77b95136a7ebc3b833e2b Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Fri, 23 Oct 2026 13:41:54 +0800 Subject: [PATCH 1/5] fix(solana): preserve original bytes when signing messages --- packages/solana-wallet-snap/CHANGELOG.md | 1 + .../services/wallet/WalletService.test.ts | 70 +++++++++++++++++++ .../src/core/services/wallet/WalletService.ts | 18 ++--- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index f1024790b..b1a5c2d2c 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Sign the original message bytes in single and batch message signing, preserving NUL bytes, invalid UTF-8 sequences, and byte order marks so signatures verify against the returned `signedMessage`. - Fix a race condition in the WebSocket connection recovery: the resync no longer derives the accounts to sync from the subscription list (which `#reSubscribe` concurrently deletes and re-creates during recovery β€” a read landing inside that window silently skipped the resync). ([#490](https://github.com/MetaMask/internal-snaps/pull/490)) - Accept scan responses that report the `simulation` or `validation` section as explicit `null` (which the API returns when only the other option is requested), instead of failing the response validation and dropping the scan result diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts index c7f509312..cc14d1d50 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts @@ -1,6 +1,7 @@ import { SolMethod, TransactionType } from '@metamask/keyring-api'; import { DEFAULT_METAMASK_ORIGIN } from '@metamask/snap-networks-utils'; import type { AnalyticsService } from '@metamask/snap-networks-utils'; +import { assert } from '@metamask/superstruct'; import { Network } from '../../constants/solana'; import { @@ -34,6 +35,7 @@ import { MOCK_SIGN_TRANSACTION_REQUEST, } from './mocks'; import type { SolanaWalletRequest } from './structs'; +import { SolanaSignMessageResponseStruct } from './structs'; import { WalletService } from './WalletService'; jest.mock('../../utils/getBip32Entropy', () => ({ @@ -768,10 +770,78 @@ describe('WalletService', () => { }, ); + describe('signMessage byte preservation', () => { + it.each<[string, Buffer]>([ + ['NUL bytes', Buffer.from([0, 65, 0, 66, 0])], + ['invalid UTF-8', Buffer.from([0xff, 0xfe, 0x80, 0xc3, 0x28])], + ['UTF-8 byte order mark', Buffer.from([0xef, 0xbb, 0xbf, 65])], + [ + 'all byte values', + Buffer.from(Array.from({ length: 256 }, (_, i) => i)), + ], + ['Unicode text', Buffer.from('Hello, δΈ–η•Œ πŸ‘‹', 'utf8')], + ])('signs the original bytes for %s', async (_description, bytes) => { + const account = MOCK_SOLANA_KEYRING_ACCOUNT_3; + const message = bytes.toString('base64'); + + const result = await service.signMessage(account, message); + + expect(result.signedMessage).toBe(message); + expect( + await service.verifySignature(account, result.signature, message), + ).toBe(true); + }); + + it('produces distinct signatures when only NUL byte positions differ', async () => { + const account = MOCK_SOLANA_KEYRING_ACCOUNT_3; + const message0 = Buffer.from([65, 0, 66]).toString('base64'); + const message1 = Buffer.from([65, 66, 0]).toString('base64'); + + const result0 = await service.signMessage(account, message0); + const result1 = await service.signMessage(account, message1); + + expect(result0.signature).not.toBe(result1.signature); + expect( + await service.verifySignature(account, result0.signature, message1), + ).toBe(false); + expect( + await service.verifySignature(account, result1.signature, message0), + ).toBe(false); + }); + }); + describe('signMessages', () => { const utf8ToBase64 = (utf8: string): string => Buffer.from(utf8, 'utf8').toString('base64'); + it('signs binary messages without changing their bytes in a batch', async () => { + const requests = [ + { + account: MOCK_SOLANA_KEYRING_ACCOUNT_0, + message: Buffer.from([0, 65, 0xff, 0x80, 66, 0]).toString('base64'), + }, + { + account: MOCK_SOLANA_KEYRING_ACCOUNT_1, + message: Buffer.from([0xef, 0xbb, 0xbf, 65]).toString('base64'), + }, + ]; + + const results = await service.signMessages(requests); + + expect(results).toHaveLength(requests.length); + for (const [index, { account, message }] of requests.entries()) { + const result = results[index]; + expect(result).toMatchObject({ + signedMessage: message, + signatureType: 'ed25519', + }); + assert(result, SolanaSignMessageResponseStruct); + expect( + await service.verifySignature(account, result.signature, message), + ).toBe(true); + } + }); + it('signs messages with one entropy fetch for accounts sharing an entropy source', async () => { const message0 = utf8ToBase64('proof message 0'); const message1 = utf8ToBase64('proof message 1'); diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts index 6fe80ca88..1070d284d 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -25,7 +25,6 @@ import { getBase64Codec, getSignatureFromTransaction, getUtf8Codec, - pipe, sendTransactionWithoutConfirmingFactory, verifySignature, } from '@solana/kit'; @@ -418,10 +417,9 @@ export class WalletService { * Signs the provided base64 encoded message using the provided account's * private key. * - * It DOES NOT decode the message to UTF-8 before signing, meaning that the - * signature must be verified using the base64 encoded message as well. - * - * You can then verify the signature with {@link WalletService.verifySignature}. + * Base64 is only the transport encoding. The signature covers the decoded + * bytes without converting them to text. Pass the base64-encoded bytes to + * {@link WalletService.verifySignature} to verify the signature. * * @param account - The account to sign the message. * @param message - The message to sign. @@ -525,12 +523,10 @@ export class WalletService { privateKeyBytes: Uint8Array, ): Promise { const addressAsAddress = asAddress(account.address); - const messageUtf8 = pipe( - message, - getBase64Codec().encode, - getUtf8Codec().decode, - ); - const signableMessage = createSignableMessage(messageUtf8); + // Text decoding can remove NUL bytes or replace invalid UTF-8 sequences. + // Sign the original bytes so the signature matches `signedMessage`. + const messageBytes = new Uint8Array(getBase64Codec().encode(message)); + const signableMessage = createSignableMessage(messageBytes); const signer = await createKeyPairSignerFromPrivateKeyBytes(privateKeyBytes); From 09c42dc43f1ccdc2a12784b9b1b58cbc2f279d2f Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Fri, 23 Oct 2026 13:42:26 +0800 Subject: [PATCH 2/5] docs(solana): link signing fix in changelog --- packages/solana-wallet-snap/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index b1a5c2d2c..f3a5221f3 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Sign the original message bytes in single and batch message signing, preserving NUL bytes, invalid UTF-8 sequences, and byte order marks so signatures verify against the returned `signedMessage`. +- Sign the original message bytes in single and batch message signing, preserving NUL bytes, invalid UTF-8 sequences, and byte order marks so signatures verify against the returned `signedMessage`. ([#507](https://github.com/MetaMask/internal-snaps/pull/507)) - Fix a race condition in the WebSocket connection recovery: the resync no longer derives the accounts to sync from the subscription list (which `#reSubscribe` concurrently deletes and re-creates during recovery β€” a read landing inside that window silently skipped the resync). ([#490](https://github.com/MetaMask/internal-snaps/pull/490)) - Accept scan responses that report the `simulation` or `validation` section as explicit `null` (which the API returns when only the other option is requested), instead of failing the response validation and dropping the scan result From c374e06b1a4406fc53e69a6d224f88ed3bee74b7 Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Sat, 24 Oct 2026 18:59:13 +0800 Subject: [PATCH 3/5] fix(solana): guard transaction bytes and display binary messages faithfully --- packages/solana-wallet-snap/CHANGELOG.md | 1 + packages/solana-wallet-snap/locales/en.json | 6 ++ packages/solana-wallet-snap/locales/es.json | 6 ++ packages/solana-wallet-snap/messages.json | 4 +- .../services/wallet/WalletService.test.ts | 35 ++++++++ .../src/core/services/wallet/WalletService.ts | 2 + .../src/core/utils/signMessage.test.ts | 87 +++++++++++++++++++ .../src/core/utils/signMessage.ts | 56 ++++++++++++ .../ConfirmSignMessage.test.tsx | 22 +++++ .../ConfirmSignMessage/ConfirmSignMessage.tsx | 13 ++- .../views/ConfirmSignMessage/render.tsx | 13 ++- 11 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 packages/solana-wallet-snap/src/core/utils/signMessage.test.ts create mode 100644 packages/solana-wallet-snap/src/core/utils/signMessage.ts diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index f3a5221f3..df3b32421 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Sign the original message bytes in single and batch message signing, preserving NUL bytes, invalid UTF-8 sequences, and byte order marks so signatures verify against the returned `signedMessage`. ([#507](https://github.com/MetaMask/internal-snaps/pull/507)) +- Reject compiled transaction messages in message-signing requests and show binary or hidden message data as labelled Base64 rather than lossy text. ([#507](https://github.com/MetaMask/internal-snaps/pull/507)) - Fix a race condition in the WebSocket connection recovery: the resync no longer derives the accounts to sync from the subscription list (which `#reSubscribe` concurrently deletes and re-creates during recovery β€” a read landing inside that window silently skipped the resync). ([#490](https://github.com/MetaMask/internal-snaps/pull/490)) - Accept scan responses that report the `simulation` or `validation` section as explicit `null` (which the API returns when only the other option is requested), instead of failing the response validation and dropping the scan result diff --git a/packages/solana-wallet-snap/locales/en.json b/packages/solana-wallet-snap/locales/en.json index 61a65287a..ed32c2951 100644 --- a/packages/solana-wallet-snap/locales/en.json +++ b/packages/solana-wallet-snap/locales/en.json @@ -291,6 +291,12 @@ }, "transactionScan.errors.unknownError": { "message": "An unknown error occurred." + }, + "confirmation.signMessage.binaryMessage": { + "message": "Binary message (Base64)" + }, + "confirmation.signMessage.binaryWarning": { + "message": "This message contains binary or hidden data. The full bytes are shown in Base64. Sign only if you trust the requesting site." } } } diff --git a/packages/solana-wallet-snap/locales/es.json b/packages/solana-wallet-snap/locales/es.json index 04a6fd488..b076dc4d3 100644 --- a/packages/solana-wallet-snap/locales/es.json +++ b/packages/solana-wallet-snap/locales/es.json @@ -270,6 +270,12 @@ }, "transactionScan.errors.unknownError": { "message": "An unknown error occurred." + }, + "confirmation.signMessage.binaryMessage": { + "message": "Mensaje binario (Base64)" + }, + "confirmation.signMessage.binaryWarning": { + "message": "Este mensaje contiene datos binarios u ocultos. Los bytes completos se muestran en Base64. Firma solo si confΓ­as en el sitio que lo solicita." } } } diff --git a/packages/solana-wallet-snap/messages.json b/packages/solana-wallet-snap/messages.json index 10e53b717..bff4b2df1 100644 --- a/packages/solana-wallet-snap/messages.json +++ b/packages/solana-wallet-snap/messages.json @@ -95,5 +95,7 @@ "transactionScan.errors.insufficientSol": "Account does not have enough SOL to perform the operation.", "transactionScan.errors.slippageToleranceExceeded": "The transaction was reverted because the slippage tolerance was exceeded.", "transactionScan.errors.transactionBlockhashExpired": "Please go back and try again", - "transactionScan.errors.unknownError": "An unknown error occurred." + "transactionScan.errors.unknownError": "An unknown error occurred.", + "confirmation.signMessage.binaryMessage": "Binary message (Base64)", + "confirmation.signMessage.binaryWarning": "This message contains binary or hidden data. The full bytes are shown in Base64. Sign only if you trust the requesting site." } diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts index cc14d1d50..378d8d3d5 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts @@ -2,6 +2,7 @@ import { SolMethod, TransactionType } from '@metamask/keyring-api'; import { DEFAULT_METAMASK_ORIGIN } from '@metamask/snap-networks-utils'; import type { AnalyticsService } from '@metamask/snap-networks-utils'; import { assert } from '@metamask/superstruct'; +import { address, getCompiledTransactionMessageEncoder } from '@solana/kit'; import { Network } from '../../constants/solana'; import { @@ -771,6 +772,40 @@ describe('WalletService', () => { ); describe('signMessage byte preservation', () => { + it.each(['legacy', 0] as const)( + 'rejects transaction messages through single and batch signing (%s)', + async (version) => { + const bytes = getCompiledTransactionMessageEncoder().encode({ + version, + header: { + numSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numReadonlyNonSignerAccounts: 0, + }, + staticAccounts: [address('11111111111111111111111111111111')], + lifetimeToken: '11111111111111111111111111111111', + instructions: [], + }); + const account = MOCK_SOLANA_KEYRING_ACCOUNT_3; + const message = Buffer.from(bytes).toString('base64'); + await expect(service.signMessage(account, message)).rejects.toThrow( + 'Cannot sign a transaction message using signMessage', + ); + const text = Buffer.from('Still an ordinary message').toString( + 'base64', + ); + const batch = await service.signMessages([ + { account, message }, + { account, message: text }, + ]); + expect(batch[0]).toStrictEqual({ + error: 'Cannot sign a transaction message using signMessage', + }); + expect(batch[1]).toMatchObject({ signedMessage: text }); + expect(mockSigner.partiallySignBase64String).not.toHaveBeenCalled(); + }, + ); + it.each<[string, Buffer]>([ ['NUL bytes', Buffer.from([0, 65, 0, 66, 0])], ['invalid UTF-8', Buffer.from([0xff, 0xfe, 0x80, 0xc3, 0x28])], diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts index 1070d284d..a76ab2b26 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -41,6 +41,7 @@ import { trackError } from '../../utils/errors'; import { getSolanaCoinTypeNode } from '../../utils/getBip32Entropy'; import { getSolanaExplorerUrl } from '../../utils/getSolanaExplorerUrl'; import logger from '../../utils/logger'; +import { assertIsNotTransactionMessage } from '../../utils/signMessage'; import { resolveTransactionType } from '../../utils/transactionType'; import { Base58Struct, Base64Struct } from '../../validation/structs'; import type { SolanaConnection } from '../connection'; @@ -526,6 +527,7 @@ export class WalletService { // Text decoding can remove NUL bytes or replace invalid UTF-8 sequences. // Sign the original bytes so the signature matches `signedMessage`. const messageBytes = new Uint8Array(getBase64Codec().encode(message)); + assertIsNotTransactionMessage(messageBytes); const signableMessage = createSignableMessage(messageBytes); const signer = diff --git a/packages/solana-wallet-snap/src/core/utils/signMessage.test.ts b/packages/solana-wallet-snap/src/core/utils/signMessage.test.ts new file mode 100644 index 000000000..9adedc830 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/utils/signMessage.test.ts @@ -0,0 +1,87 @@ +import { address, getCompiledTransactionMessageEncoder } from '@solana/kit'; + +import { + assertIsNotTransactionMessage, + formatMessageForDisplay, +} from './signMessage'; + +describe('message signing safety', () => { + it('rejects a compiled v1 transaction message supported by the installed codec', () => { + const bytes = getCompiledTransactionMessageEncoder().encode({ + version: 1, + header: { + numSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numReadonlyNonSignerAccounts: 0, + }, + staticAccounts: [address('11111111111111111111111111111111')], + lifetimeToken: '11111111111111111111111111111111', + configMask: 0, + configValues: [], + numStaticAccounts: 1, + numInstructions: 0, + instructionHeaders: [], + instructionPayloads: [], + }); + expect(() => assertIsNotTransactionMessage(new Uint8Array(bytes))).toThrow( + 'Cannot sign a transaction message using signMessage', + ); + }); + + it.each(['legacy', 0] as const)( + 'rejects a compiled %s transaction message, including a trailing payload', + (version) => { + const bytes = getCompiledTransactionMessageEncoder().encode({ + version, + header: { + numSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numReadonlyNonSignerAccounts: 0, + }, + staticAccounts: [address('11111111111111111111111111111111')], + lifetimeToken: '11111111111111111111111111111111', + instructions: [], + }); + expect(() => + assertIsNotTransactionMessage(new Uint8Array(bytes)), + ).toThrow('Cannot sign a transaction message using signMessage'); + expect(() => + assertIsNotTransactionMessage(new Uint8Array([...bytes, 42])), + ).toThrow('Cannot sign a transaction message using signMessage'); + }, + ); + + it.each([ + Buffer.from('Hello, δΈ–η•Œ πŸ‘‹\nSecond line', 'utf8'), + Buffer.from([0, 65, 0, 66, 0]), + Buffer.from([0xff, 0xfe, 0x80, 0xc3, 0x28]), + Buffer.from(Array.from({ length: 256 }, (_, i) => i)), + ])('allows non-transaction text and binary data', (bytes) => { + expect(() => assertIsNotTransactionMessage(bytes)).not.toThrow(); + }); + + it.each([ + Buffer.from([65, 0, 66]), + Buffer.from([65, 66, 0]), + Buffer.from([0xef, 0xbb, 0xbf, 65]), + Buffer.from([0xff, 0xfe, 0x80]), + Buffer.from('A\u202eB', 'utf8'), + Buffer.from('A\rB', 'utf8'), + ])( + 'shows hidden or lossy content as the complete Base64 payload', + (bytes) => { + expect(formatMessageForDisplay(bytes)).toStrictEqual({ + message: bytes.toString('base64'), + isBinary: true, + }); + }, + ); + + it('keeps valid Unicode and multiline messages readable', () => { + const message = 'Hello, δΈ–η•Œ πŸ‘‹\nSecond line'; + expect(formatMessageForDisplay(Buffer.from(message))).toStrictEqual({ + message, + isBinary: false, + }); + }); +}); diff --git a/packages/solana-wallet-snap/src/core/utils/signMessage.ts b/packages/solana-wallet-snap/src/core/utils/signMessage.ts new file mode 100644 index 000000000..48251f0b0 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/utils/signMessage.ts @@ -0,0 +1,56 @@ +import { + getBase64Codec, + getCompiledTransactionMessageDecoder, + getUtf8Codec, +} from '@solana/kit'; + +/** + * Keep transaction signing behind the transaction confirmation and scan flow. + * Reject any decodable transaction prefix, including messages with trailing data. + * + * @param bytes - Original bytes requested by the caller. + * @throws If the bytes decode as a compiled Solana transaction message. + */ +export function assertIsNotTransactionMessage(bytes: Uint8Array): void { + try { + const decoded = getCompiledTransactionMessageDecoder().decode(bytes); + // The codec also decodes impossible headers (for example, arbitrary binary + // beginning with zero signers). Those cannot authorize a transaction. + const { header, staticAccounts } = decoded; + if ( + header.numSignerAccounts === 0 || + header.numSignerAccounts > staticAccounts.length || + header.numReadonlySignerAccounts >= header.numSignerAccounts || + header.numReadonlyNonSignerAccounts > + staticAccounts.length - header.numSignerAccounts + ) { + return; + } + } catch { + return; + } + throw new Error('Cannot sign a transaction message using signMessage'); +} + +/** + * Display the complete bytes when text decoding would hide or change content. + * + * @param bytes - Original message bytes. + * @returns Readable UTF-8 text or an explicitly labelled Base64 representation. + */ +export function formatMessageForDisplay(bytes: Uint8Array): { + message: string; + isBinary: boolean; +} { + const codec = getUtf8Codec(); + const message = codec.decode(bytes); + const roundTrip = codec.encode(message); + const isBinary = + roundTrip.length !== bytes.length || + roundTrip.some((byte, index) => byte !== bytes[index]) || + /[\p{Cc}\p{Cf}]/u.test(message.replace(/\n/gu, '')); + return { + message: isBinary ? getBase64Codec().decode(bytes) : message, + isBinary, + }; +} diff --git a/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.test.tsx b/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.test.tsx index 9dd7ba91a..e40c8cc8b 100644 --- a/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.test.tsx +++ b/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.test.tsx @@ -5,6 +5,28 @@ import { Network } from '../../../../core/constants/solana'; import { ConfirmSignMessage } from './ConfirmSignMessage'; describe('ConfirmSignMessage', () => { + it('labels the complete binary representation and shows a warning', () => { + const serialized = JSON.stringify( + ConfirmSignMessage({ + message: 'QQBC', + isBinary: true, + account: { + address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', + } as ExtendedKeyringAccount, + accountDomain: null, + scope: Network.Mainnet, + locale: 'en', + networkImage: null, + origin: 'https://example.com', + }), + ); + expect(serialized).toContain('Binary message (Base64)'); + expect(serialized).toContain( + 'This message contains binary or hidden data.', + ); + expect(serialized).toContain('QQBC'); + }); + it('renders the shared header with the sign message title', () => { const serialized = JSON.stringify( ConfirmSignMessage({ diff --git a/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx b/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx index bca924938..b53bc3eab 100644 --- a/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx +++ b/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx @@ -26,6 +26,7 @@ import { ConfirmSignMessageFormNames } from './events'; export type ConfirmSignMessageProps = { message: string; + isBinary?: boolean; account: ExtendedKeyringAccount; accountDomain: string | null; scope: Network; @@ -36,6 +37,7 @@ export type ConfirmSignMessageProps = { export const ConfirmSignMessage: SnapComponent = ({ message, + isBinary = false, account, accountDomain, scope, @@ -56,9 +58,18 @@ export const ConfirmSignMessage: SnapComponent = ({
- {translate('confirmation.signMessage.message')} + {translate( + isBinary + ? 'confirmation.signMessage.binaryMessage' + : 'confirmation.signMessage.message', + )} + {isBinary ? ( + + {translate('confirmation.signMessage.binaryWarning')} + + ) : null} {message} diff --git a/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/render.tsx b/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/render.tsx index b25eb0593..ee1a5739c 100644 --- a/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/render.tsx +++ b/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/render.tsx @@ -1,11 +1,15 @@ import type { ExtendedKeyringAccount } from '@metamask/snap-networks-utils'; import { assert } from '@metamask/superstruct'; -import { getBase64Codec, getUtf8Codec } from '@solana/kit'; +import { getBase64Codec } from '@solana/kit'; import type { SolanaKeyringRequest } from '../../../../core/handlers/onKeyringRequest/structs'; import { SolanaSignMessageRequestStruct } from '../../../../core/services/wallet/structs'; import { SOL_IMAGE_SVG } from '../../../../core/test/mocks/solana-image-svg'; import { FALLBACK_LANGUAGE } from '../../../../core/utils/i18n'; +import { + assertIsNotTransactionMessage, + formatMessageForDisplay, +} from '../../../../core/utils/signMessage'; import { createInterface, getPreferences, @@ -35,8 +39,9 @@ export async function render( origin, } = request; - const messageBytes = getBase64Codec().encode(messageBase64); - const messageUtf8 = getUtf8Codec().decode(messageBytes); + const messageBytes = new Uint8Array(getBase64Codec().encode(messageBase64)); + assertIsNotTransactionMessage(messageBytes); + const displayedMessage = formatMessageForDisplay(messageBytes); const [locale, accountDomain] = await Promise.all([ getPreferences() @@ -47,7 +52,7 @@ export async function render( const id = await createInterface( Date: Sat, 24 Oct 2026 19:22:19 +0800 Subject: [PATCH 4/5] fix(solana): preserve readable previews and avoid impossible transaction matches --- packages/solana-wallet-snap/CHANGELOG.md | 7 +++- packages/solana-wallet-snap/locales/en.json | 3 ++ packages/solana-wallet-snap/locales/es.json | 3 ++ packages/solana-wallet-snap/messages.json | 3 +- .../src/core/utils/signMessage.test.ts | 33 ++++++++++++++- .../src/core/utils/signMessage.ts | 42 +++++++++++++++++-- .../ConfirmSignMessage.test.tsx | 2 + .../ConfirmSignMessage/ConfirmSignMessage.tsx | 10 +++++ 8 files changed, 94 insertions(+), 9 deletions(-) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index df3b32421..258b975d5 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Sign the original message bytes in single and batch message signing, preserving NUL bytes, invalid UTF-8 sequences, and byte order marks so signatures verify against the returned `signedMessage`. ([#507](https://github.com/MetaMask/internal-snaps/pull/507)) +- Reject compiled transaction messages in message-signing requests and show binary or hidden message data as labelled Base64 rather than lossy text. ([#507](https://github.com/MetaMask/internal-snaps/pull/507)) + ## [7.2.0] ### Added @@ -19,8 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Sign the original message bytes in single and batch message signing, preserving NUL bytes, invalid UTF-8 sequences, and byte order marks so signatures verify against the returned `signedMessage`. ([#507](https://github.com/MetaMask/internal-snaps/pull/507)) -- Reject compiled transaction messages in message-signing requests and show binary or hidden message data as labelled Base64 rather than lossy text. ([#507](https://github.com/MetaMask/internal-snaps/pull/507)) - Fix a race condition in the WebSocket connection recovery: the resync no longer derives the accounts to sync from the subscription list (which `#reSubscribe` concurrently deletes and re-creates during recovery β€” a read landing inside that window silently skipped the resync). ([#490](https://github.com/MetaMask/internal-snaps/pull/490)) - Accept scan responses that report the `simulation` or `validation` section as explicit `null` (which the API returns when only the other option is requested), instead of failing the response validation and dropping the scan result diff --git a/packages/solana-wallet-snap/locales/en.json b/packages/solana-wallet-snap/locales/en.json index ed32c2951..0c280593e 100644 --- a/packages/solana-wallet-snap/locales/en.json +++ b/packages/solana-wallet-snap/locales/en.json @@ -297,6 +297,9 @@ }, "confirmation.signMessage.binaryWarning": { "message": "This message contains binary or hidden data. The full bytes are shown in Base64. Sign only if you trust the requesting site." + }, + "confirmation.signMessage.textPreview": { + "message": "Text preview (escaped)" } } } diff --git a/packages/solana-wallet-snap/locales/es.json b/packages/solana-wallet-snap/locales/es.json index b076dc4d3..8e6f64223 100644 --- a/packages/solana-wallet-snap/locales/es.json +++ b/packages/solana-wallet-snap/locales/es.json @@ -276,6 +276,9 @@ }, "confirmation.signMessage.binaryWarning": { "message": "Este mensaje contiene datos binarios u ocultos. Los bytes completos se muestran en Base64. Firma solo si confΓ­as en el sitio que lo solicita." + }, + "confirmation.signMessage.textPreview": { + "message": "Vista previa de texto (con escapes)" } } } diff --git a/packages/solana-wallet-snap/messages.json b/packages/solana-wallet-snap/messages.json index bff4b2df1..1d56c4ae3 100644 --- a/packages/solana-wallet-snap/messages.json +++ b/packages/solana-wallet-snap/messages.json @@ -97,5 +97,6 @@ "transactionScan.errors.transactionBlockhashExpired": "Please go back and try again", "transactionScan.errors.unknownError": "An unknown error occurred.", "confirmation.signMessage.binaryMessage": "Binary message (Base64)", - "confirmation.signMessage.binaryWarning": "This message contains binary or hidden data. The full bytes are shown in Base64. Sign only if you trust the requesting site." + "confirmation.signMessage.binaryWarning": "This message contains binary or hidden data. The full bytes are shown in Base64. Sign only if you trust the requesting site.", + "confirmation.signMessage.textPreview": "Text preview (escaped)" } diff --git a/packages/solana-wallet-snap/src/core/utils/signMessage.test.ts b/packages/solana-wallet-snap/src/core/utils/signMessage.test.ts index 9adedc830..e4994807b 100644 --- a/packages/solana-wallet-snap/src/core/utils/signMessage.test.ts +++ b/packages/solana-wallet-snap/src/core/utils/signMessage.test.ts @@ -26,6 +26,18 @@ describe('message signing safety', () => { expect(() => assertIsNotTransactionMessage(new Uint8Array(bytes))).toThrow( 'Cannot sign a transaction message using signMessage', ); + // V1 has a larger packet limit than legacy/v0. Plausible messages above + // 1232 bytes still require the transaction signing flow. + expect(() => + assertIsNotTransactionMessage( + new Uint8Array([...bytes, ...new Uint8Array(2000)]), + ), + ).toThrow('Cannot sign a transaction message using signMessage'); + expect(() => + assertIsNotTransactionMessage( + new Uint8Array([...bytes, ...new Uint8Array(5000)]), + ), + ).not.toThrow(); }); it.each(['legacy', 0] as const)( @@ -65,8 +77,6 @@ describe('message signing safety', () => { Buffer.from([65, 66, 0]), Buffer.from([0xef, 0xbb, 0xbf, 65]), Buffer.from([0xff, 0xfe, 0x80]), - Buffer.from('A\u202eB', 'utf8'), - Buffer.from('A\rB', 'utf8'), ])( 'shows hidden or lossy content as the complete Base64 payload', (bytes) => { @@ -77,6 +87,25 @@ describe('message signing safety', () => { }, ); + it.each(['I, the', '10 i', '1. T'])( + 'allows large text starting with %s that cannot fit a transaction', + (prefix) => { + expect(() => + assertIsNotTransactionMessage(Buffer.from(prefix + 'a'.repeat(30000))), + ).not.toThrow(); + }, + ); + + it('shows a readable escaped preview alongside full bytes for emoji and CRLF', () => { + const message = 'Welcome πŸ‘©β€πŸ’»\r\nSign to log in'; + const bytes = Buffer.from(message); + expect(formatMessageForDisplay(bytes)).toStrictEqual({ + message: bytes.toString('base64'), + isBinary: true, + preview: '"Welcome πŸ‘©\\u200dπŸ’»\\r\\nSign to log in"', + }); + }); + it('keeps valid Unicode and multiline messages readable', () => { const message = 'Hello, δΈ–η•Œ πŸ‘‹\nSecond line'; expect(formatMessageForDisplay(Buffer.from(message))).toStrictEqual({ diff --git a/packages/solana-wallet-snap/src/core/utils/signMessage.ts b/packages/solana-wallet-snap/src/core/utils/signMessage.ts index 48251f0b0..42917e186 100644 --- a/packages/solana-wallet-snap/src/core/utils/signMessage.ts +++ b/packages/solana-wallet-snap/src/core/utils/signMessage.ts @@ -1,12 +1,13 @@ import { getBase64Codec, getCompiledTransactionMessageDecoder, + getTransactionMessageSizeLimit, getUtf8Codec, } from '@solana/kit'; /** * Keep transaction signing behind the transaction confirmation and scan flow. - * Reject any decodable transaction prefix, including messages with trailing data. + * Reject plausible transaction messages that fit the version's transaction limit. * * @param bytes - Original bytes requested by the caller. * @throws If the bytes decode as a compiled Solana transaction message. @@ -17,7 +18,9 @@ export function assertIsNotTransactionMessage(bytes: Uint8Array): void { // The codec also decodes impossible headers (for example, arbitrary binary // beginning with zero signers). Those cannot authorize a transaction. const { header, staticAccounts } = decoded; + const feePayer = staticAccounts[0]; if ( + !feePayer || header.numSignerAccounts === 0 || header.numSignerAccounts > staticAccounts.length || header.numReadonlySignerAccounts >= header.numSignerAccounts || @@ -26,6 +29,20 @@ export function assertIsNotTransactionMessage(bytes: Uint8Array): void { ) { return; } + const sizeLimit = getTransactionMessageSizeLimit({ + version: decoded.version, + feePayer: { address: feePayer }, + instructions: [], + }); + // V1 puts the signature count in its message header. Legacy/v0 add a + // shortU16 prefix; every count that fits the packet uses one prefix byte. + const signaturePrefixSize = decoded.version === 1 ? 0 : 1; + if ( + bytes.length + 64 * header.numSignerAccounts + signaturePrefixSize > + sizeLimit + ) { + return; + } } catch { return; } @@ -41,16 +58,33 @@ export function assertIsNotTransactionMessage(bytes: Uint8Array): void { export function formatMessageForDisplay(bytes: Uint8Array): { message: string; isBinary: boolean; + preview?: string; } { const codec = getUtf8Codec(); const message = codec.decode(bytes); const roundTrip = codec.encode(message); - const isBinary = + const isLossy = roundTrip.length !== bytes.length || - roundTrip.some((byte, index) => byte !== bytes[index]) || - /[\p{Cc}\p{Cf}]/u.test(message.replace(/\n/gu, '')); + roundTrip.some((byte, index) => byte !== bytes[index]); + const isBinary = + isLossy || /[\p{Cc}\p{Cf}]/u.test(message.replace(/\n/gu, '')); + // Keep legitimate emoji sequences and CRLF text readable without concealing + // control/format characters. JSON escapes controls; escape format units too. + const preview = + isBinary && !isLossy + ? JSON.stringify(message).replace(/\p{Cf}/gu, (character) => + character + .split('') + .map( + (unit) => + `\\u${unit.charCodeAt(0).toString(16).padStart(4, '0')}`, + ) + .join(''), + ) + : undefined; return { message: isBinary ? getBase64Codec().decode(bytes) : message, isBinary, + ...(preview ? { preview } : {}), }; } diff --git a/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.test.tsx b/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.test.tsx index e40c8cc8b..3e976cb5d 100644 --- a/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.test.tsx +++ b/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.test.tsx @@ -10,6 +10,7 @@ describe('ConfirmSignMessage', () => { ConfirmSignMessage({ message: 'QQBC', isBinary: true, + preview: '"A\\u0000B"', account: { address: 'BLw3RweJmfbTapJRgnPRvd962YDjFYAnVGd1p5hmZ5tP', } as ExtendedKeyringAccount, @@ -21,6 +22,7 @@ describe('ConfirmSignMessage', () => { }), ); expect(serialized).toContain('Binary message (Base64)'); + expect(serialized).toContain('Text preview (escaped)'); expect(serialized).toContain( 'This message contains binary or hidden data.', ); diff --git a/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx b/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx index b53bc3eab..527b9f9ea 100644 --- a/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx +++ b/packages/solana-wallet-snap/src/features/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx @@ -27,6 +27,7 @@ import { ConfirmSignMessageFormNames } from './events'; export type ConfirmSignMessageProps = { message: string; isBinary?: boolean; + preview?: string; account: ExtendedKeyringAccount; accountDomain: string | null; scope: Network; @@ -38,6 +39,7 @@ export type ConfirmSignMessageProps = { export const ConfirmSignMessage: SnapComponent = ({ message, isBinary = false, + preview, account, accountDomain, scope, @@ -56,6 +58,14 @@ export const ConfirmSignMessage: SnapComponent = ({
+ {preview ? ( + + + {translate('confirmation.signMessage.textPreview')} + + {preview} + + ) : null} {translate( From e04ff426488ef828167dfa5df4365c4a903b708d Mon Sep 17 00:00:00 2001 From: "Notorious D.E.V" Date: Sat, 24 Oct 2026 18:43:33 +0700 Subject: [PATCH 5/5] fix(solana): escape DEL and C1 characters in message previews --- .../src/core/utils/signMessage.test.ts | 15 +++++++++++++++ .../src/core/utils/signMessage.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/solana-wallet-snap/src/core/utils/signMessage.test.ts b/packages/solana-wallet-snap/src/core/utils/signMessage.test.ts index e4994807b..da417622a 100644 --- a/packages/solana-wallet-snap/src/core/utils/signMessage.test.ts +++ b/packages/solana-wallet-snap/src/core/utils/signMessage.test.ts @@ -113,4 +113,19 @@ describe('message signing safety', () => { isBinary: false, }); }); + + it.each([0x7f, 0x80, 0x85, 0x9f])( + 'visibly escapes remaining control U+%s', + (code) => { + const message = `Approve login${String.fromCharCode( + code, + )}Resources: example`; + const displayed = formatMessageForDisplay(Buffer.from(message)); + expect(displayed.preview).toContain( + `\\u${code.toString(16).padStart(4, '0')}`, + ); + expect(displayed.preview).not.toContain(String.fromCharCode(code)); + expect(JSON.parse(displayed.preview ?? 'null')).toBe(message); + }, + ); }); diff --git a/packages/solana-wallet-snap/src/core/utils/signMessage.ts b/packages/solana-wallet-snap/src/core/utils/signMessage.ts index 42917e186..d0d39032b 100644 --- a/packages/solana-wallet-snap/src/core/utils/signMessage.ts +++ b/packages/solana-wallet-snap/src/core/utils/signMessage.ts @@ -72,7 +72,7 @@ export function formatMessageForDisplay(bytes: Uint8Array): { // control/format characters. JSON escapes controls; escape format units too. const preview = isBinary && !isLossy - ? JSON.stringify(message).replace(/\p{Cf}/gu, (character) => + ? JSON.stringify(message).replace(/[\p{Cc}\p{Cf}]/gu, (character) => character .split('') .map(