ChainSafe / ChainSafe/canton-x402-sdk

fix(x402-core): the signature isn't bound to the prepared transaction — `preparedTransactionHash` is never verified

Open
#55 0 comments 0 reactions 0 assignees View on GitHub

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 partySignature over inner.preparedTransactionHash
  • step 9 decodes inner.preparedTransaction and 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_id replay guard from preparedTransactionHash
    (submissionIdFor). An attacker re-signing a fresh bogus H' gets a fresh
    submission_id for the same T, 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_id lives inside
    the signed Metadata.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:

  1. Cheap and immediate: derive identity from preparedTransaction rather than the
    hash — sha256 of 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.
  2. 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 describes submission_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 preparedTransactionHash does not correspond to
    preparedTransaction (or the field is gone and the hash is derived).
  • Parity test against a hash produced by a real Canton prepare call.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.