feat: add verifyOffchainMessage helper for signed v1 offchain messages - #1725
feat: add verifyOffchainMessage helper for signed v1 offchain messages#1725amilz wants to merge 1 commit into
Conversation
…sages 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 detectedLatest commit: b63dcdb The changes in this PR will be included in the next version bump. This PR includes changesets to release 47 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
In general I think this would be a good addition to Kit, various consumers of offchain message signing should be verifying that the message is as they expect.
I think this API is doing too much though, and in particular is tied to the v1 spec such that a hypothetical v2 would probably require a breaking change.
I think the easiest way to handle that is probably to make this v1 specific, and make it the responsibility of apps to call the appropriate version. That would move decoding to the app, but I think reducing the scope of this method would be good.
I'd then restructure it so that it has a signature like:
function assertV1OffchainMessagesEqual(
receivedMessage: OffchainMessageV1,
expectedMessage: OffchainMessageV1 // or omit `version`
)Then it would:
- check version of
receivedMessage, throw if not 1 - check content equal, throw otherwise
- check requiredSignatories equal, throw otherwise
I'd use different error codes for each check, with the precise expected/received value that mismatches in context. We should double check we don't consider that sensitive, but I think it'd make sense.
Then the consumer would:
await verifySignature(publicKey, signature, signedMessageBytes) // if it needs to check multiple signatures, the app can do so here
const receivedOffchainMessage = getOffchainMessageDecoder().decode(signedMessageBytes)
if (receivedOffchainMessage.version === 1) {
assertV1OffchainMessagesEqual(receivedOffchainMessage, myInputOffchainMessage)
} else {
// throw, not the version the app expected
}Now if the spec moves on and the dapp starts sending version 2 messages to the wallet and we add a v2 verify function, it just needs to change:
if (receivedOffchainMessage.version === 2) {
assertV2OffchainMessagesEqual(receivedOffchainMessage, myInputOffchainMessage)
} else {
// throw, not the version the app expected
}On another note, thinking about this API in the context of multiple requiredSigners makes the feature as proposed feel quite limited. An app would need to piece together the envelope with multiple signatures itself. We'll be able to hide that in the signer API (as we currently do with signMessage) though.
A wallet asked to sign an offchain message returns the bytes it signed alongside a signature. Verifying that signature proves only that the wallet produced it over those bytes; it says nothing about whether the bytes represent the message the app asked for. A compromised or buggy wallet can return a perfectly valid signature over something else entirely, so an app has to establish both. `assertOffchainMessageV1Equal` answers the first question and leaves the second to the existing `verifyOffchainMessageEnvelope`. It takes the version 1 message the caller decoded and the version 1 message it expected, and compares the content and the required signatories, giving each kind of mismatch its own error code so that the diagnosis is accurate. Order matters here — asserting before verifying means a wallet that signed the wrong message reports a content mismatch rather than a signature verification failure, which would blame the cryptography for a content problem. Both decoding and version dispatch stay with the caller, which keeps this function scoped to a single specification version and its parameter type honest about that. Taking the decoded `OffchainMessage` union and checking the version internally would save the caller a branch, but it costs more than it saves: apps get no build-time signal when a new version appears, and it is the irreversible choice, since a parameter can be widened later without breaking callers but never narrowed. As written, an app can switch exhaustively on `version` and have the compiler fail its build the day `OffchainMessageV2` joins the union. What to do about an unexpected version is app policy in any case, and the `solana:signOffchainMessage` feature already discriminates its input on `messageVersion`, so an app always knows which version it asked for. Required signatories are compared without regard to order, since the specification mandates lexicographic order on the wire while a caller-built message may be in any order. Order is the only thing ignored — the lists are otherwise compared element by element — and both are reported in sorted order in the error context so they can be compared by eye. Message content is deliberately kept out of the error context, as it can carry data better left out of logs and error reporting; its length is reported instead, and nothing is lost diagnostically since the caller holds both messages at the call site. This reworks the approach explored in draft PR #1725, which combined the content check and signature verification into one call keyed on a raw message string. That shape hard-coded version 1 without naming it, reported every content problem through a single error, and left multi-signer callers re-encoding the expected message once per signature.
|
dropping in lieu of #1888 |
|
Because there has been no activity on this PR for 14 days since it was merged, it has been automatically locked. Please open a new issue if it requires a follow up. |
Adds a
verifyOffchainMessagehelper to@solana/offchain-messagesthat verifies a signed version 1 offchain message returned by an untrusted signer (eg. a wallet) in a single call.When you ask a wallet to sign an offchain message, it hands back the bytes it signed plus a signature. Verifying the signature alone is not enough — a compromised signer could return a perfectly valid signature over data that has nothing to do with what you asked it to sign. From the
messageandrequiredSignersyou requested,verifyOffchainMessagereconstructs the version 1 message you expected the signer to sign, asserts it matches the bytes the signer reports having signed, then asserts the signature is a valid Ed25519 signature of those bytes by the signer.This is distinct from the existing
verifyOffchainMessageEnvelope, which trusts the envelope'scontentas-is and has no notion of an expected message to compare against.solana:signOffchainMessagewallet feature.requiredSignersis optional and defaults to[signer].SOLANA_ERROR__OFFCHAIN_MESSAGE__CONTENT_DOES_NOT_MATCH_EXPECTED(5607018), thrown when the signed bytes don't match the expected message.Test plan
verifyOffchainMessagetest suite covering: happy path,requiredSignersdefault, content mismatch, short-circuit before signature verification, invalid signature, signer-not-required guard, out-of-order signers, version tampering, truncated bytes, and the structural-validation cases.pnpm test:lint,test:typecheck,test:unit:node,test:unit:browserall pass.