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: [], + }); + } +}