ChainSafe / ChainSafe/canton-x402-sdk
fix(x402-core): the signature isn't bound to the prepared transaction — `preparedTransactionHash` is never verified
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 3
- Forks
- 3
- Avg merge
- 14h 2m
- Merged PRs (30d)
- 3
Description
Summary
verifyExactCanton checks the signature and the transaction, but never checks that
they refer to each other. preparedTransactionHash is client-supplied and nothing
recomputes it from preparedTransaction, so the two fields can describe different
transactions and verification still passes.
Where
packages/x402-core/src/verify/exact-canton.ts:
- step 8 verifies
partySignatureoverinner.preparedTransactionHash - step 9 decodes
inner.preparedTransactionand binds sender/receiver/instrument/amount
Both read from the payload; no step asserts preparedTransactionHash == H(preparedTransaction).
#35 closed the gap between the transaction and the requirements; this is the
remaining gap between the transaction and the signature.
Why it matters
A payer can submit preparedTransaction = T (a genuine transfer that satisfies the
requirements, so step 9 passes) together with preparedTransactionHash = H' for an
arbitrary H', self-signed so step 8 passes. verify() returns valid.
Canton rejects at execute — it recomputes H(T) itself and the signature won't match —
so this does not settle a bad transaction. The impact is on identity, because the
hash is what consumers key off:
- The facilitator derives its
submission_idreplay guard frompreparedTransactionHash
(submissionIdFor). An attacker re-signing a fresh bogusH'gets a fresh
submission_idfor the sameT, so the guard is bypassed and every attempt is let
through to Canton. - What actually stops the replay is Canton's change-ID dedup (
command_idlives inside
the signedMetadata.SubmitterInfo). That window is finite and participant-configured.
Past it, the facilitator's own guard is the only defence — and it is exactly the one
this bypasses. - Unlimited distinct ids for one transaction also means unbounded pending rows.
So today submission_id is derived from a client claim we never bind to the bytes we
submit, rather than from the transaction itself.
Proposed fix
Drop preparedTransactionHash from the payload and have the verifier compute it from
preparedTransaction, honouring the payload's hashingSchemeVersion (V2/V3 are already
modelled in types/payment.ts). The hash stops being input and becomes derived, which
removes the class of bug rather than adding a check.
Feasibility caveat: this needs Canton's canonical hashing scheme
(HASHING_SCHEME_VERSION_V2) implemented in TS. There is no implementation in this repo
today, and none exported from the @canton-network/* packages I checked. It is a
structured hash over the whole PreparedTransaction protobuf, so it is materially more
work than the field-walking decoder in prepared-tx.ts, and it needs parity tests
against a real participant — a wrong implementation rejects every valid payment.
Note also that Canton hashes carry the 1220 multihash prefix (see
fingerprintForPublicKey), so any comparison has to normalise for that.
If that lands as one step, good. If not, two smaller ones are worth considering:
- Cheap and immediate: derive identity from
preparedTransactionrather than the
hash —sha256of the decoded blob commits to the exact bytes submitted, needs no
Canton-specific algorithm, and is not client-steerable. Doesn't fix the verifier, but
removes the consequence. - Keep the field until the hashing scheme exists, and document it explicitly as an
unverified client claim so no consumer keys identity off it. It is currently easy to
read as trustworthy — the facilitator's own schema comment describessubmission_id
as deriving "from the material the payer SIGNED", which is true but not the same as
being bound to the transaction executed.
Acceptance
- Verification fails when
preparedTransactionHashdoes not correspond to
preparedTransaction(or the field is gone and the hash is derived). - Parity test against a hash produced by a real Canton
preparecall.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/x402-core/src/verify/exact-canton.ts, tracing steps 8 and 9, then read packages/x402-core/src/types/payment.ts and prepared-tx.ts. Inspect submissionIdFor to understand the identity impact; done means mismatched hashes fail verification or the hash is derived, with parity tests against a real Canton prepare call.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend-api-design, payments, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100