Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/solana-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/solana-wallet-snap/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,15 @@
},
"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."
},
"confirmation.signMessage.textPreview": {
"message": "Text preview (escaped)"
}
}
}
9 changes: 9 additions & 0 deletions packages/solana-wallet-snap/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,15 @@
},
"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."
},
"confirmation.signMessage.textPreview": {
"message": "Vista previa de texto (con escapes)"
}
}
}
5 changes: 4 additions & 1 deletion packages/solana-wallet-snap/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,8 @@
"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.",
"confirmation.signMessage.textPreview": "Text preview (escaped)"
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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 {
Expand Down Expand Up @@ -34,6 +36,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', () => ({
Expand Down Expand Up @@ -768,10 +771,112 @@ 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])],
['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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
getBase64Codec,
getSignatureFromTransaction,
getUtf8Codec,
pipe,
sendTransactionWithoutConfirmingFactory,
verifySignature,
} from '@solana/kit';
Expand All @@ -42,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';
Expand Down Expand Up @@ -418,10 +418,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.
Expand Down Expand Up @@ -525,12 +524,11 @@ export class WalletService {
privateKeyBytes: Uint8Array,
): Promise<SolanaSignMessageResponse> {
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));
assertIsNotTransactionMessage(messageBytes);
const signableMessage = createSignableMessage(messageBytes);

const signer =
await createKeyPairSignerFromPrivateKeyBytes(privateKeyBytes);
Expand Down
131 changes: 131 additions & 0 deletions packages/solana-wallet-snap/src/core/utils/signMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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',
);
// 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)(
'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]),
])(
'shows hidden or lossy content as the complete Base64 payload',
(bytes) => {
expect(formatMessageForDisplay(bytes)).toStrictEqual({
message: bytes.toString('base64'),
isBinary: true,
});
},
);

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({
message,
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);
},
);
});
Loading