From b63dcdbcfddc3c0f7c71f4075944254d61e40b49 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:35:28 -0700 Subject: [PATCH] Add verifyOffchainMessage helper for verifying signed v1 offchain messages Adds a `verifyOffchainMessage` helper to `@solana/offchain-messages` that verifies a signed version 1 offchain message returned by an untrusted signer (eg. a wallet) in a single call. Verifying the returned signature alone is insufficient, because a compromised wallet can hand back a valid signature over data unrelated to what the dApp requested. The helper encodes the message the caller expected the signer to sign, asserts that it matches the bytes the signer reports having signed, and then asserts that the signature is a valid Ed25519 signature of those bytes by the signer. It supports version 1 messages only, matching the v1-only `solana:signOffchainMessage` wallet-standard feature. A new `SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED` error code is thrown when the signed bytes do not match the expected message. Because encoding the expected message already enforces the spec's structural rules (non-empty and duplicate-free required signatories serialized in sorted order, and non-empty content), the helper does not re-implement those checks and instead surfaces the encoder's errors. --- .changeset/real-flowers-lie.md | 6 + packages/errors/src/codes.ts | 2 + packages/errors/src/messages.ts | 4 + packages/offchain-messages/README.md | 41 +++ .../src/__tests__/signatures-test.ts | 269 ++++++++++++++++++ packages/offchain-messages/src/signatures.ts | 107 ++++++- 6 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 .changeset/real-flowers-lie.md diff --git a/.changeset/real-flowers-lie.md b/.changeset/real-flowers-lie.md new file mode 100644 index 000000000..a230318e5 --- /dev/null +++ b/.changeset/real-flowers-lie.md @@ -0,0 +1,6 @@ +--- +'@solana/offchain-messages': minor +'@solana/errors': minor +--- + +Add a `verifyOffchainMessage` helper that verifies a signed version 1 offchain message returned by an untrusted signer (eg. a wallet) in a single call. From the `message` and `requiredSigners` you requested, it reconstructs the version 1 message you expected the signer to sign, asserts that it matches the bytes the signer reports having signed, and then asserts that the signature is a valid Ed25519 signature of those bytes by the signer. This closes the gap where verifying the signature alone would let a compromised signer return a valid signature over data unrelated to what was requested. It supports version 1 messages, matching the `solana:signOffchainMessage` wallet feature. For the common case of a connected wallet signing a message only it needs to sign, `requiredSigners` is optional and defaults to `[signer]`. A new `SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED` error code is thrown when the signed bytes do not match the expected message. diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts index 812bca834..03f0835db 100644 --- a/packages/errors/src/codes.ts +++ b/packages/errors/src/codes.ts @@ -221,6 +221,7 @@ export const SOLANA_ERROR__OFFCHAIN_MESSAGE__UNEXPECTED_VERSION = 5607014; export const SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATORIES_MUST_BE_SORTED = 5607015; export const SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATORIES_MUST_BE_UNIQUE = 5607016; export const SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURE_VERIFICATION_FAILURE = 5607017; +export const SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED = 5607018; // Transaction-related errors. // Reserve error codes in the range [5663000-5663999]. @@ -606,6 +607,7 @@ export type SolanaErrorCode = | typeof SOLANA_ERROR__NONCE_ACCOUNT_NOT_FOUND | typeof SOLANA_ERROR__OFFCHAIN_MESSAGE__ADDRESSES_CANNOT_SIGN_OFFCHAIN_MESSAGE | typeof SOLANA_ERROR__OFFCHAIN_MESSAGE__APPLICATION_DOMAIN_STRING_LENGTH_OUT_OF_RANGE + | typeof SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED | typeof SOLANA_ERROR__OFFCHAIN_MESSAGE__ENVELOPE_SIGNERS_MISMATCH | typeof SOLANA_ERROR__OFFCHAIN_MESSAGE__INVALID_APPLICATION_DOMAIN_BYTE_LENGTH | typeof SOLANA_ERROR__OFFCHAIN_MESSAGE__MAXIMUM_LENGTH_EXCEEDED diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index e630a6c38..af47debc8 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -186,6 +186,7 @@ import { SOLANA_ERROR__NONCE_ACCOUNT_NOT_FOUND, SOLANA_ERROR__OFFCHAIN_MESSAGE__ADDRESSES_CANNOT_SIGN_OFFCHAIN_MESSAGE, SOLANA_ERROR__OFFCHAIN_MESSAGE__APPLICATION_DOMAIN_STRING_LENGTH_OUT_OF_RANGE, + SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED, SOLANA_ERROR__OFFCHAIN_MESSAGE__ENVELOPE_SIGNERS_MISMATCH, SOLANA_ERROR__OFFCHAIN_MESSAGE__INVALID_APPLICATION_DOMAIN_BYTE_LENGTH, SOLANA_ERROR__OFFCHAIN_MESSAGE__MAXIMUM_LENGTH_EXCEEDED, @@ -617,6 +618,9 @@ export const SolanaErrorMessages: Readonly<{ 'Attempted to sign an offchain message with an address that is not a signer for it', [SOLANA_ERROR__OFFCHAIN_MESSAGE__APPLICATION_DOMAIN_STRING_LENGTH_OUT_OF_RANGE]: 'Expected base58-encoded application domain string of length in the range [32, 44]. Actual length: $actualLength.', + [SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED]: + 'The signed offchain message does not match the message that was expected. The signer may ' + + 'have signed different data than was requested; do not trust its signature.', [SOLANA_ERROR__OFFCHAIN_MESSAGE__ENVELOPE_SIGNERS_MISMATCH]: 'The signer addresses in this offchain message envelope do not match the list of ' + 'required signers in the message preamble. These unexpected signers were present in the ' + diff --git a/packages/offchain-messages/README.md b/packages/offchain-messages/README.md index 1f4bca471..59093f615 100644 --- a/packages/offchain-messages/README.md +++ b/packages/offchain-messages/README.md @@ -12,3 +12,44 @@ # @solana/offchain-messages This package contains utilities for encoding and decoding messages according to the offchain message [specification](https://github.com/solana-foundation/SRFCs/discussions/3). It can be used standalone, but it is also exported as part of Kit [`@solana/kit`](https://github.com/anza-xyz/kit/tree/main/packages/kit). + +## Verifying a signed offchain message + +When you ask a signer (eg. a wallet) to sign an offchain message, it typically returns the message bytes it signed along with a signature. Checking that signature in isolation is **not** enough to trust the result: a compromised signer could hand back a perfectly valid signature over data that has nothing to do with what you asked it to sign. To trust the signature, you must _also_ confirm that the bytes the signer signed are the bytes you intended it to sign. + +The `verifyOffchainMessage` helper performs both checks in a single call. From the `message` and required signers you requested, it reconstructs and encodes the version 1 offchain message you expected the signer to sign, asserts that it matches the bytes the signer reports having signed, and then asserts that the signature is a valid Ed25519 signature of those bytes by the signer. It supports version 1 offchain messages — the only version the `solana:signOffchainMessage` wallet feature produces. + +For the common case — a connected wallet signing a message only it needs to sign — you just pass the `message`, the wallet's `signer`, and the `signature` + `signedMessageBytes` it returned. `requiredSigners` is optional and defaults to `[signer]`. You never construct a fully-formed offchain message yourself, because reconstructing the expected bytes already enforces the spec's structural rules (non-empty and duplicate-free signers, serialized in the spec-mandated order, and non-empty content), so a malformed request throws before any comparison happens. + +```ts +import { address } from '@solana/addresses'; +import { + isSolanaError, + SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED, + SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURE_VERIFICATION_FAILURE, +} from '@solana/errors'; +import { verifyOffchainMessage } from '@solana/offchain-messages'; + +// `message` is what you asked the wallet to sign. +// `signedOffchainMessage` and `signature` came back from the wallet. +try { + await verifyOffchainMessage({ + message, + signature, + signedMessageBytes: signedOffchainMessage, + signer: address(account.address), + }); + // The wallet signed exactly the message you expected, and the signature is valid. +} catch (e) { + if (isSolanaError(e, SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED)) { + // The signer signed something other than what you asked for. Do not trust it. + } else if (isSolanaError(e, SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURE_VERIFICATION_FAILURE)) { + // The signature is not valid for the signed bytes and signer. + } + throw e; +} +``` + +When a message requires more than one signer, every signer signs the same bytes and the wallet returns one signature per signer. Pass the full `requiredSigners` set (so the expected bytes can be reconstructed) and call `verifyOffchainMessage` once per signature you collect, each time with the `signer` that produced it. + +If you instead hold a fully assembled [`OffchainMessageEnvelope`](./src/envelope.ts) (eg. you signed it yourself with `signOffchainMessageEnvelope`), use `verifyOffchainMessageEnvelope` to assert that it is signed by all of its required signatories. diff --git a/packages/offchain-messages/src/__tests__/signatures-test.ts b/packages/offchain-messages/src/__tests__/signatures-test.ts index 16c766d21..cc99341bf 100644 --- a/packages/offchain-messages/src/__tests__/signatures-test.ts +++ b/packages/offchain-messages/src/__tests__/signatures-test.ts @@ -5,19 +5,26 @@ import { ReadonlyUint8Array } from '@solana/codecs-core'; import { SOLANA_ERROR__CODECS__INVALID_CONSTANT, SOLANA_ERROR__OFFCHAIN_MESSAGE__ADDRESSES_CANNOT_SIGN_OFFCHAIN_MESSAGE, + SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED, + SOLANA_ERROR__OFFCHAIN_MESSAGE__MESSAGE_MUST_BE_NON_EMPTY, + SOLANA_ERROR__OFFCHAIN_MESSAGE__NUM_REQUIRED_SIGNERS_CANNOT_BE_ZERO, + SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATORIES_MUST_BE_UNIQUE, SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURE_VERIFICATION_FAILURE, SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURES_MISSING, SolanaError, } from '@solana/errors'; import { SignatureBytes, signBytes, verifySignature } from '@solana/keys'; +import { getOffchainMessageV1Encoder } from '../codecs/message-v1'; import { OffchainMessageEnvelope } from '../envelope'; import { OffchainMessageBytes } from '../message'; +import { OffchainMessageV1 } from '../message-v1'; import { assertIsFullySignedOffchainMessageEnvelope, isFullySignedOffchainMessageEnvelope, partiallySignOffchainMessageEnvelope, signOffchainMessageEnvelope, + verifyOffchainMessage, verifyOffchainMessageEnvelope, } from '../signatures'; @@ -741,3 +748,265 @@ describe('verifyOffchainMessageEnvelope', () => { }); }); }); + +describe('verifyOffchainMessage', () => { + const mockPublicKeyAddressA = + 'signerAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as Address<'signerAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'>; + const mockPublicKeyAddressB = + 'signerBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' as Address<'signerBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'>; + const mockKeyPairA = { privateKey: {} as CryptoKey, publicKey: {} as CryptoKey } as CryptoKeyPair; + const mockKeyPairB = { privateKey: {} as CryptoKey, publicKey: {} as CryptoKey } as CryptoKeyPair; + const mockValidSignatureA = new Uint8Array(Array(64).fill(1)) as SignatureBytes; + const mockValidSignatureB = new Uint8Array(Array(64).fill(2)) as SignatureBytes; + const mockInvalidSignature = new Uint8Array(Array(64).fill(0xff)) as SignatureBytes; + const MESSAGE = 'Hello world'; + // `mockPublicKeyAddressA` sorts before `mockPublicKeyAddressB` by their decoded bytes. + const SIGNERS = [mockPublicKeyAddressA, mockPublicKeyAddressB]; + // The bytes a well-behaved signer would have signed, given `MESSAGE` and `SIGNERS`. + const signedMessageBytes = getOffchainMessageV1Encoder().encode({ + content: MESSAGE, + requiredSignatories: SIGNERS.map(address => ({ address })), + version: 1, + } satisfies OffchainMessageV1); + // The bytes for a message that only signer A is required to sign. + const singleSignerMessageBytes = getOffchainMessageV1Encoder().encode({ + content: MESSAGE, + requiredSignatories: [{ address: mockPublicKeyAddressA }], + version: 1, + } satisfies OffchainMessageV1); + beforeEach(() => { + (getPublicKeyFromAddress as jest.Mock).mockImplementation(address => { + switch (address) { + case mockPublicKeyAddressA: + return mockKeyPairA.publicKey; + case mockPublicKeyAddressB: + return mockKeyPairB.publicKey; + default: + return { privateKey: {} as CryptoKey, publicKey: {} as CryptoKey } as CryptoKeyPair; + } + }); + (verifySignature as jest.Mock).mockImplementation((publicKey, signature) => { + return ( + (publicKey === mockKeyPairA.publicKey && signature === mockValidSignatureA) || + (publicKey === mockKeyPairB.publicKey && signature === mockValidSignatureB) + ); + }); + }); + it('resolves when the signed bytes match the expected message and the signature is valid', async () => { + expect.assertions(1); + const result = verifyOffchainMessage({ + message: MESSAGE, + requiredSigners: SIGNERS, + signature: mockValidSignatureA, + signedMessageBytes, + signer: mockPublicKeyAddressA, + }); + await expect(result).resolves.toBeUndefined(); + }); + it('defaults the required signers to the lone signer when `requiredSigners` is omitted', async () => { + expect.assertions(1); + const result = verifyOffchainMessage({ + message: MESSAGE, + signature: mockValidSignatureA, + // The message only required signer A; we omit `requiredSigners` entirely. + signedMessageBytes: singleSignerMessageBytes, + signer: mockPublicKeyAddressA, + }); + await expect(result).resolves.toBeUndefined(); + }); + it('throws when `requiredSigners` is omitted but the message required more than one signer', async () => { + expect.assertions(1); + const result = verifyOffchainMessage({ + message: MESSAGE, + signature: mockValidSignatureA, + // These bytes require both A and B, but omitting `requiredSigners` only expects [signer]. + signedMessageBytes, + signer: mockPublicKeyAddressA, + }); + await expect(result).rejects.toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED), + ); + }); + it('throws when the signed bytes do not match the expected message', async () => { + expect.assertions(1); + const result = verifyOffchainMessage({ + // Signer signed a different message than the one we expected. + message: 'Goodbye world', + requiredSigners: SIGNERS, + signature: mockValidSignatureA, + signedMessageBytes, + signer: mockPublicKeyAddressA, + }); + await expect(result).rejects.toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED), + ); + }); + it('does not verify the signature when the signed bytes do not match the expected message', async () => { + expect.assertions(1); + await verifyOffchainMessage({ + message: 'Goodbye world', + requiredSigners: SIGNERS, + signature: mockValidSignatureA, + signedMessageBytes, + signer: mockPublicKeyAddressA, + }).catch(() => {}); + expect(verifySignature as jest.Mock).not.toHaveBeenCalled(); + }); + it('throws when the signed bytes match but the signature is invalid', async () => { + expect.assertions(1); + const result = verifyOffchainMessage({ + message: MESSAGE, + requiredSigners: SIGNERS, + signature: mockInvalidSignature, + signedMessageBytes, + signer: mockPublicKeyAddressA, + }); + await expect(result).rejects.toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURE_VERIFICATION_FAILURE, { + signatoriesWithInvalidSignatures: [mockPublicKeyAddressA], + signatoriesWithMissingSignatures: [], + }), + ); + }); + it('throws when the signature is valid but for a different signer than expected', async () => { + expect.assertions(1); + const result = verifyOffchainMessage({ + message: MESSAGE, + requiredSigners: SIGNERS, + signature: mockValidSignatureA, + signedMessageBytes, + // `mockValidSignatureA` is only valid for signer A, not signer B. + signer: mockPublicKeyAddressB, + }); + await expect(result).rejects.toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURE_VERIFICATION_FAILURE, { + signatoriesWithInvalidSignatures: [mockPublicKeyAddressB], + signatoriesWithMissingSignatures: [], + }), + ); + }); + it('verifies a signature for any one of the required signers', async () => { + expect.assertions(1); + // Signer B is also a required signer; its signature should verify against the same bytes. + const result = verifyOffchainMessage({ + message: MESSAGE, + requiredSigners: SIGNERS, + signature: mockValidSignatureB, + signedMessageBytes, + signer: mockPublicKeyAddressB, + }); + await expect(result).resolves.toBeUndefined(); + }); + it('resolves when the signers are provided out of sorted order', async () => { + expect.assertions(1); + // Signers are serialized in sorted order, so reversing the input still matches the bytes. + const result = verifyOffchainMessage({ + message: MESSAGE, + requiredSigners: [mockPublicKeyAddressB, mockPublicKeyAddressA], + signature: mockValidSignatureA, + signedMessageBytes, + signer: mockPublicKeyAddressA, + }); + await expect(result).resolves.toBeUndefined(); + }); + it('throws when the requested signers omit a signer present in the signed bytes', async () => { + expect.assertions(1); + const result = verifyOffchainMessage({ + message: MESSAGE, + // Signed bytes require both A and B, but we only expected A to be a signer. + requiredSigners: [mockPublicKeyAddressA], + signature: mockValidSignatureA, + signedMessageBytes, + signer: mockPublicKeyAddressA, + }); + await expect(result).rejects.toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED), + ); + }); + it('throws when the signed bytes declare a different message version', async () => { + expect.assertions(1); + // Flip the version byte (offset 16, immediately after the 16-byte signing domain) of the + // bytes the signer returned, as if it had signed a different message version than expected. + const tamperedBytes = Uint8Array.from(signedMessageBytes); + tamperedBytes[16] = 0x00; + const result = verifyOffchainMessage({ + message: MESSAGE, + requiredSigners: SIGNERS, + signature: mockValidSignatureA, + signedMessageBytes: tamperedBytes, + signer: mockPublicKeyAddressA, + }); + await expect(result).rejects.toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED), + ); + }); + it('throws when there are no required signers', async () => { + expect.assertions(1); + const result = verifyOffchainMessage({ + message: MESSAGE, + requiredSigners: [], + signature: mockValidSignatureA, + signedMessageBytes, + signer: mockPublicKeyAddressA, + }); + await expect(result).rejects.toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__NUM_REQUIRED_SIGNERS_CANNOT_BE_ZERO), + ); + }); + it('throws when the requested signers contain a duplicate', async () => { + expect.assertions(1); + const result = verifyOffchainMessage({ + message: MESSAGE, + requiredSigners: [mockPublicKeyAddressA, mockPublicKeyAddressA], + signature: mockValidSignatureA, + signedMessageBytes, + signer: mockPublicKeyAddressA, + }); + await expect(result).rejects.toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATORIES_MUST_BE_UNIQUE), + ); + }); + it('throws when the message content is empty', async () => { + expect.assertions(1); + const result = verifyOffchainMessage({ + message: '', + requiredSigners: SIGNERS, + signature: mockValidSignatureA, + signedMessageBytes, + signer: mockPublicKeyAddressA, + }); + await expect(result).rejects.toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__MESSAGE_MUST_BE_NON_EMPTY), + ); + }); + it('throws when the signed bytes are a truncated version of the expected message', async () => { + expect.assertions(1); + const result = verifyOffchainMessage({ + message: MESSAGE, + requiredSigners: SIGNERS, + signature: mockValidSignatureA, + signedMessageBytes: signedMessageBytes.subarray(0, signedMessageBytes.length - 1), + signer: mockPublicKeyAddressA, + }); + await expect(result).rejects.toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED), + ); + }); + it('throws when the signer is not one of the required signers, even if its signature is otherwise valid', async () => { + expect.assertions(1); + // Signer B's signature is valid over these bytes, but B was never a required signer. + const result = verifyOffchainMessage({ + message: MESSAGE, + requiredSigners: [mockPublicKeyAddressA], + signature: mockValidSignatureB, + signedMessageBytes: singleSignerMessageBytes, + signer: mockPublicKeyAddressB, + }); + await expect(result).rejects.toThrow( + new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__ADDRESSES_CANNOT_SIGN_OFFCHAIN_MESSAGE, { + expectedAddresses: [mockPublicKeyAddressA], + unexpectedAddresses: [mockPublicKeyAddressB], + }), + ); + }); +}); diff --git a/packages/offchain-messages/src/signatures.ts b/packages/offchain-messages/src/signatures.ts index 8d46b8b9e..d903e5234 100644 --- a/packages/offchain-messages/src/signatures.ts +++ b/packages/offchain-messages/src/signatures.ts @@ -1,7 +1,8 @@ import { Address, getAddressFromPublicKey, getPublicKeyFromAddress } from '@solana/addresses'; -import { bytesEqual } from '@solana/codecs-core'; +import { bytesEqual, ReadonlyUint8Array } from '@solana/codecs-core'; import { SOLANA_ERROR__OFFCHAIN_MESSAGE__ADDRESSES_CANNOT_SIGN_OFFCHAIN_MESSAGE, + SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED, SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURE_VERIFICATION_FAILURE, SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURES_MISSING, SolanaError, @@ -9,8 +10,10 @@ import { import { SignatureBytes, signBytes, verifySignature } from '@solana/keys'; import { NominalType } from '@solana/nominal-types'; +import { getOffchainMessageV1Encoder } from './codecs/message-v1'; import { decodeRequiredSignatoryAddresses } from './codecs/preamble-common'; import { OffchainMessageEnvelope } from './envelope'; +import { OffchainMessageV1 } from './message-v1'; /** * Represents an offchain message envelope that is signed by all of its required signers. @@ -264,3 +267,105 @@ export async function verifyOffchainMessageEnvelope(offchainMessageEnvelope: Off throw new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURE_VERIFICATION_FAILURE, errorContext); } } + +/** + * The inputs required to verify a signed offchain message returned by a signer (eg. a wallet). + * + * @see {@link verifyOffchainMessage} + */ +export type VerifyOffchainMessageInput = Readonly<{ + /** The text content of the message you asked the signer to sign. */ + message: string; + /** + * The complete set of addresses you required to sign the message. You may list them in any + * order; they are reordered as the spec mandates when reconstructing the expected message. + * + * @defaultValue `[signer]` — the common case of a message that only the connected wallet needs + * to sign. + */ + requiredSigners?: readonly Address[]; + /** The 64-byte Ed25519 signature returned by the signer. */ + signature: SignatureBytes; + /** The bytes that the signer reports having signed. */ + signedMessageBytes: ReadonlyUint8Array; + /** The address whose private key is claimed to have produced {@link signature}. */ + signer: Address; +}>; + +/** + * Verifies a signed offchain message returned by an untrusted signer (eg. a wallet). + * + * Verifying the signature alone is not enough: a compromised signer could return a valid signature + * over data you never asked it to sign. From the `message` and `requiredSigners` you requested, + * this function reconstructs the version 1 offchain message you expected, asserts that it matches + * the bytes the signer reports having signed, then asserts that the signature is a valid Ed25519 + * signature of those bytes by the signer. + * + * Only version 1 offchain messages are supported, since that is the only version the + * `solana:signOffchainMessage` wallet feature produces. The signer returns one signature per + * signer, so call this once per signature you collect; `requiredSigners` defaults to `[signer]`. + * + * @throws A {@link SolanaError} with code + * {@link SOLANA_ERROR__OFFCHAIN_MESSAGE__ADDRESSES_CANNOT_SIGN_OFFCHAIN_MESSAGE} if `signer` is not + * one of the `requiredSigners`, {@link SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED} + * if the signed bytes do not match the expected message, or + * {@link SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURE_VERIFICATION_FAILURE} if the signature is + * invalid. It can also surface an error raised while encoding the expected message if `message` and + * `requiredSigners` do not form a structurally valid offchain message. + * + * @example + * ```ts + * import { address } from '@solana/addresses'; + * import { isSolanaError, SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED } from '@solana/errors'; + * import { verifyOffchainMessage } from '@solana/offchain-messages'; + * + * try { + * await verifyOffchainMessage({ + * message, + * signature, + * signedMessageBytes: signedOffchainMessage, + * signer: address(account.address), + * }); + * // The wallet signed exactly the message you expected, and the signature is valid. + * } catch (e) { + * if (isSolanaError(e, SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED)) { + * // The signer signed something other than what you asked for. + * } + * throw e; + * } + * ``` + * + * @see {@link verifyOffchainMessageEnvelope} if you instead hold an {@link OffchainMessageEnvelope} + * and want to verify that it is signed by all of its required signatories. + */ +export async function verifyOffchainMessage({ + message, + requiredSigners, + signature, + signedMessageBytes, + signer, +}: VerifyOffchainMessageInput): Promise { + const resolvedRequiredSigners = requiredSigners ?? [signer]; + const expectedMessage: OffchainMessageV1 = { + content: message, + requiredSignatories: resolvedRequiredSigners.map(address => ({ address })), + version: 1, + }; + const expectedMessageBytes = getOffchainMessageV1Encoder().encode(expectedMessage); + if (!resolvedRequiredSigners.includes(signer)) { + throw new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__ADDRESSES_CANNOT_SIGN_OFFCHAIN_MESSAGE, { + expectedAddresses: resolvedRequiredSigners, + unexpectedAddresses: [signer], + }); + } + if (!bytesEqual(expectedMessageBytes, signedMessageBytes)) { + throw new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED); + } + const publicKey = await getPublicKeyFromAddress(signer); + if (!(await verifySignature(publicKey, signature, signedMessageBytes))) { + throw new SolanaError(SOLANA_ERROR__OFFCHAIN_MESSAGE__SIGNATURE_VERIFICATION_FAILURE, { + signatoriesWithInvalidSignatures: [signer], + signatoriesWithMissingSignatures: [], + }); + } +}