diff --git a/.changeset/slow-cameras-serve.md b/.changeset/slow-cameras-serve.md new file mode 100644 index 000000000..1cde0b743 --- /dev/null +++ b/.changeset/slow-cameras-serve.md @@ -0,0 +1,5 @@ +--- +'@solana/offchain-messages': patch +--- + +Include the v0 preamble bytes when enforcing the 1232-byte limit for hardware-wallet-signable offchain messages. diff --git a/packages/offchain-messages/src/__tests__/message-v0-codec-test.ts b/packages/offchain-messages/src/__tests__/message-v0-codec-test.ts index 0821e9541..3759e44bc 100644 --- a/packages/offchain-messages/src/__tests__/message-v0-codec-test.ts +++ b/packages/offchain-messages/src/__tests__/message-v0-codec-test.ts @@ -52,6 +52,18 @@ const SIGNER_B_BYTES = new Uint8Array([ 0x94, 0x8f, 0xd8, 0xbb, 0x2c, 0x10, 0xa1, 0x01, 0x02, 0xce, 0x98, 0xb3, 0xa6, ]); +const PREAMBLE_LENGTH_WITH_TWO_SIGNERS_BYTES = + OFFCHAIN_MESSAGE_SIGNING_DOMAIN_BYTES.length + + 1 + // Version + APPLICATION_DOMAIN_BYTES.length + + 1 + // Message format + 1 + // Signer count + SIGNER_A_BYTES.length + + SIGNER_B_BYTES.length + + 2; // Message length +const MAX_BODY_LENGTH_WITH_TWO_SIGNERS_BYTES = 1232 - PREAMBLE_LENGTH_WITH_TWO_SIGNERS_BYTES; +const BODY_LENGTH_EXCEEDING_TWO_SIGNER_PREAMBLE_BYTES = MAX_BODY_LENGTH_WITH_TWO_SIGNERS_BYTES + 1; + describe('getOffchainMessageV0Decoder()', () => { let decoder: VariableSizeDecoder; beforeEach(() => { @@ -423,6 +435,39 @@ describe('getOffchainMessageV0Decoder()', () => { }), ); }); + it.each([ + [OffchainMessageContentFormat.RESTRICTED_ASCII_1232_BYTES_MAX, 'ASCII'], + [OffchainMessageContentFormat.UTF8_1232_BYTES_MAX, '1232-byte-max UTF-8'], + ])('throws when decoding a %s message whose preamble plus body is too long', messageFormat => { + const text = '!'.repeat(BODY_LENGTH_EXCEEDING_TWO_SIGNER_PREAMBLE_BYTES); + const encodedMessage = + // prettier-ignore + new Uint8Array([ + // Signing domain + ...OFFCHAIN_MESSAGE_SIGNING_DOMAIN_BYTES, + // Version + 0x00, + // Application domain + ...APPLICATION_DOMAIN_BYTES, + // Message format + messageFormat, + // Signer count + 0x02, + // Signer addresses + ...SIGNER_A_BYTES, + ...SIGNER_B_BYTES, + // Message length + text.length & 0xff, text.length >> 8, + // Message + ...Array.from(text, character => character.charCodeAt(0)), + ]); + expect(() => decoder.decode(encodedMessage)).toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__MAXIMUM_LENGTH_EXCEEDED, { + actualBytes: BODY_LENGTH_EXCEEDING_TWO_SIGNER_PREAMBLE_BYTES, + maxBytes: MAX_BODY_LENGTH_WITH_TWO_SIGNERS_BYTES, + }), + ); + }); it('throws when decoding a 1232-byte-max UTF-8 message whose message content does not match the length specified in the preamble', () => { const encodedMessage = // prettier-ignore @@ -792,6 +837,26 @@ describe('getOffchainMessageEncoder()', () => { }), ); }); + it.each([ + [OffchainMessageContentFormat.RESTRICTED_ASCII_1232_BYTES_MAX, 'ASCII'], + [OffchainMessageContentFormat.UTF8_1232_BYTES_MAX, '1232-byte-max UTF-8'], + ])('throws when encoding a %s message whose preamble plus body is too long', format => { + const offchainMessage = { + applicationDomain: APPLICATION_DOMAIN, + content: { + format, + text: '!'.repeat(BODY_LENGTH_EXCEEDING_TWO_SIGNER_PREAMBLE_BYTES), + }, + requiredSignatories: [{ address: SIGNER_A }, { address: SIGNER_B }], + version: 0, + } as unknown as OffchainMessageV0; + expect(() => encoder.encode(offchainMessage)).toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__MAXIMUM_LENGTH_EXCEEDED, { + actualBytes: BODY_LENGTH_EXCEEDING_TWO_SIGNER_PREAMBLE_BYTES, + maxBytes: MAX_BODY_LENGTH_WITH_TWO_SIGNERS_BYTES, + }), + ); + }); it('encodes a well-formed 65535-byte-max UTF-8 message according to spec', () => { const offchainMessage: OffchainMessageV0 = { applicationDomain: APPLICATION_DOMAIN, diff --git a/packages/offchain-messages/src/message-v0.ts b/packages/offchain-messages/src/message-v0.ts index 180ab8ff5..e780a5a66 100644 --- a/packages/offchain-messages/src/message-v0.ts +++ b/packages/offchain-messages/src/message-v0.ts @@ -1,3 +1,6 @@ +import { getUtf8Encoder } from '@solana/codecs-strings'; +import { SOLANA_ERROR__OFFCHAIN_MESSAGE__MAXIMUM_LENGTH_EXCEEDED, SolanaError } from '@solana/errors'; + import { assertIsOffchainMessageContentRestrictedAsciiOf1232BytesMax, assertIsOffchainMessageContentUtf8Of1232BytesMax, @@ -10,6 +13,15 @@ import { import { OffchainMessagePreambleV0 } from './preamble-v0'; import { OffchainMessageWithRequiredSignatories } from './signatures'; +const MAX_HARDWARE_WALLET_SIGNABLE_MESSAGE_BYTES = 1232; +const SIGNING_DOMAIN_LENGTH_BYTES = 16; +const VERSION_LENGTH_BYTES = 1; +const APPLICATION_DOMAIN_LENGTH_BYTES = 32; +const MESSAGE_FORMAT_LENGTH_BYTES = 1; +const SIGNER_COUNT_LENGTH_BYTES = 1; +const SIGNER_ADDRESS_LENGTH_BYTES = 32; +const MESSAGE_LENGTH_BYTES = 2; + export type BaseOffchainMessageV0 = Omit< OffchainMessagePreambleV0, 'messageFormat' | 'messageLength' | 'requiredSignatories' @@ -54,6 +66,39 @@ export type OffchainMessageV0 = BaseOffchainMessageV0 & OffchainMessageWithContent & OffchainMessageWithRequiredSignatories; +function getPreambleLengthForRequiredSignatories( + requiredSignatories: OffchainMessageV0['requiredSignatories'], +): number { + return ( + SIGNING_DOMAIN_LENGTH_BYTES + + VERSION_LENGTH_BYTES + + APPLICATION_DOMAIN_LENGTH_BYTES + + MESSAGE_FORMAT_LENGTH_BYTES + + SIGNER_COUNT_LENGTH_BYTES + + requiredSignatories.length * SIGNER_ADDRESS_LENGTH_BYTES + + MESSAGE_LENGTH_BYTES + ); +} + +function assertFitsHardwareWalletSignableMessageByteLimit( + putativeMessage: Pick & { + content: { + text: string; + }; + }, +) { + const messageBodyLength = getUtf8Encoder().getSizeFromValue(putativeMessage.content.text); + const maxBodyLength = + MAX_HARDWARE_WALLET_SIGNABLE_MESSAGE_BYTES - + getPreambleLengthForRequiredSignatories(putativeMessage.requiredSignatories); + if (messageBodyLength > maxBodyLength) { + throw new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__MAXIMUM_LENGTH_EXCEEDED, { + actualBytes: messageBodyLength, + maxBytes: maxBodyLength, + }); + } +} + /** * In the event that you receive a v0 offchain message from an untrusted source, use this function * to assert that it is one whose content conforms to the @@ -71,6 +116,7 @@ export function assertIsOffchainMessageRestrictedAsciiOf1232BytesMax, ): asserts putativeMessage is OffchainMessageWithRestrictedAsciiOf1232BytesMaxContent & Omit { assertIsOffchainMessageContentRestrictedAsciiOf1232BytesMax(putativeMessage.content); + assertFitsHardwareWalletSignableMessageByteLimit(putativeMessage); } /** @@ -91,6 +137,7 @@ export function assertIsOffchainMessageUtf8Of1232BytesMax, ): asserts putativeMessage is OffchainMessageWithUtf8Of1232BytesMaxContent & Omit { assertIsOffchainMessageContentUtf8Of1232BytesMax(putativeMessage.content); + assertFitsHardwareWalletSignableMessageByteLimit(putativeMessage); } /**