Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/real-flowers-lie.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/errors/src/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/errors/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ' +
Expand Down
41 changes: 41 additions & 0 deletions packages/offchain-messages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading