Skip to content

fix(soroban): bind attestation digest to the host network (FIND-001) - #69

Merged
alex-predicate merged 4 commits into
mainfrom
alex-predicate/soroban-registry-report-fixes
Aug 14, 2026
Merged

alex-predicate merged 4 commits into
mainfrom
alex-predicate/soroban-registry-report-fixes

Conversation

@alex-predicate

@alex-predicate alex-predicate commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Audit finding FIND-001 — Attestation digest is not bound to the host chain or registry instance (Low).

The problem

compute_hash took the domain separator as a parameter:

pub fn compute_hash(e: &Env, statement: &Statement, network: &String) -> BytesN<32> {
    payload.append(&network.clone().to_xdr(e));   // caller-supplied
    payload.append(&statement.clone().to_xdr(e));
    ...

Whoever called hash_statement / validate_attestation chose the domain the attester's signature was verified against. The digest was not bound to the chain executing the contract, so one valid signature could pass in domains the attester never bound. This does not let anyone forge signatures — a registered attester's signature and caller.require_auth() are still required.

The EVM registry has never had this hole: hashStatementWithExpiry and hashStatementSafe both mix in block.chainid (src/PredicateRegistry.sol:187,213). This brings Soroban to parity.

The fix

Read the domain from the host:

sha256( XDR(ScVal::Bytes(ledger.network_id)) ++ XDR(statement) )

network_id is sha256(network_passphrase). It is no longer a parameter, so there is nothing for a caller to choose. (e.ledger().network_passphrase() does not exist in soroban-sdk 23.5.3, as the audit notes — network_id() at ledger.rs:102 is the available primitive.)

Two deliberate deviations from the recommended remediation

1. No version tag. The audit suggested adding "PredicateRegistry:v1". XDR already makes layouts unconfusable, so a tag would only restate what the encoding guarantees:

first 4 bytes
old preimage 0000000e ScVal::String (passphrase)
new preimage 0000000d ScVal::Bytes (network_id)

Nothing signed for the old layout validates under the new one, or vice versa, without a tag saying so. The same holds for future changes: a statement is an ScVal::Map (00000011) and an address an ScVal::Address (00000012), so appending or reordering fields cannot yield a byte string valid under two layouts. Cross-chain replay against Solana's ed25519 digests would require a hash collision, not domain separation — Solana's own tag earns its place because its digest is raw byte concatenation with no type information, which XDR is not. Set against zero security benefit, a tag is one more constant that two codebases must keep byte-identical forever.

2. No registry address. Also recommended, also omitted:

  • Cross-instance replay is already constrained. validate substitutes the caller for statement.target, so exploiting it needs one integrating contract wired to two registries of the same preimage layout.
  • It would push a new requirement onto every attester. The API builds this digest locally from the chain id alone (predicate-avs → stmapiv2/internal/domain/types.go, stellarDigestBytes); Task carries no registry field. Adding the address means new config plumbing to protect a configuration we do not run.
  • No other chain binds it. EVM includes no address(this) and no EIP-712 domain separator; Solana's digest binds neither program id nor chain id.
  • Only one registry per network is deployed, and v1 is deprecated.

compute_hash documents both decisions and the trigger to revisit the address.

Breaking changes

The redundant network parameter is gone throughout:

  • hash_statement(statement) — was (statement, network)
  • validate_attestation(statement, attestation, caller) — was (statement, attestation, network, caller)
  • predicate_client::authorize_transaction drops network
  • example-compliant-token: the network constructor arg and its NETWORK instance-storage entry are removed (dead once the registry derives the network itself)
  • deploy-compliant-token.sh: drops the constructor's network argument

Rollout — requires coordination

  1. The registry upgrade and the API signing change must ship together. There is no version negotiation; attestations signed under the old layout stop validating the moment the registry is upgraded.
  2. API change in stellarDigestBytes — one line. Replace the passphrase ScVal with the network id; nothing is prepended:
    netID := sha256.Sum256([]byte(networkPassphrase))
    netIDXDR, err := stellarBytesScVal(netID[:]).MarshalBinary()
    // payload = netIDXDR ++ statementXDR
    stellarStatementScVal and its map ordering are untouched — the golden-vector test just needs its expected digest regenerated.
  3. The example tokens must be redeployed, not re-pointed — REGISTRY is constructor-only with no setter, and the constructor signature changed. That is also the moment nothing references the deprecated v1 registry anymore.

Tests

46 pass; cargo fmt --check clean; cargo doc warning-free; both wasm32-unknown-unknown and wasm32v1-none build.

Two new cases pin the network binding — the digest changes under set_network_id, and an attestation signed on one network is rejected on another. Both were verified to fail when the network_id line is removed, so they are not vacuous.

Signature-rejection tests now assert on Error(Crypto, InvalidInput) instead of a bare #[should_panic]. A bare one also swallows assertion failures — the first draft of these tests passed against the unfixed code for exactly that reason.

🤖 Generated with Claude Code

Comment thread soroban/predicate-registry/src/validation.rs Outdated
Comment thread soroban/predicate-registry/src/validation.rs
`compute_hash` derived its domain separator from a `network: String`
parameter supplied by whoever called `hash_statement` /
`validate_attestation`. The digest was therefore not bound to the chain
executing the contract: an integration (or anyone calling the registry
directly) chose the domain the attester's signature was checked against,
so one valid signature could pass in domains the attester never bound.
The EVM registry has never had this hole — `hashStatementWithExpiry` and
`hashStatementSafe` both mix in `block.chainid` (src/PredicateRegistry.sol).

Read the domain from the host instead. The preimage is now the XDR of
`e.ledger().network_id()` followed by the statement. The separator is no
longer a parameter, so there is nothing for a caller to choose.

No version tag accompanies the change. XDR is self-describing and
length-prefixed, so layouts cannot be confused for one another: this
preimage opens with `ScVal::Bytes` where the old one (a passphrase) opened
with `ScVal::String`, and a statement is an `ScVal::Map` that no appended
field could impersonate. Nothing signed for the old layout validates here,
and vice versa, without a tag needing to say so.

The registry's own contract address is deliberately left out, though the
audit recommended including it. Cross-instance replay is already
constrained by the existing caller binding (validate substitutes the
caller for `statement.target`), so it would take one integrating contract
wired to two registries of the same preimage layout. Against that,
including the address forces every attester to know which registry it is
signing for: the API builds this digest locally from chain id alone
(predicate-avs stmapiv2 `stellarDigestBytes`), EVM and Solana bind no
registry/program address either, and only one registry per network is
deployed. `compute_hash` documents the trigger to revisit.

Breaking ABI change, since the redundant `network` parameter is gone:

- registry: `hash_statement(statement)`,
  `validate_attestation(statement, attestation, caller)`
- predicate-client: `authorize_transaction` drops `network`
- example-compliant-token: the `network` constructor arg and its
  `NETWORK` instance-storage entry are gone — dead weight once the
  registry derives the network itself
- deploy-compliant-token.sh: drops the constructor's network argument

The registry upgrade and the API signing change have to ship together;
there is no version negotiation, so attestations signed under the old
layout stop validating at the upgrade. The example tokens must be
redeployed rather than re-pointed, as `REGISTRY` is constructor-only.

Tests: two new cases pin the network binding — the digest changes with
`set_network_id`, and an attestation signed on one network is rejected on
another. Both fail if the `network_id` line is removed. Signature-rejection
tests now assert on `Error(Crypto, InvalidInput)` rather than using a bare
`should_panic`, which would also swallow an assertion failure and pass
vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
alex-predicate and others added 2 commits August 13, 2026 15:28
Every existing digest test asks the contract for the hash and then signs
what it got back, so the contract is only ever checked against itself. Two
refactors slip straight through that: swapping the order of the appends in
`compute_hash`, and renaming a `Statement` field — `#[contracttype]` uses
field names as ScMap keys, so a rename changes the wire format. Either one
invalidates every attestation the API has already signed while the whole
suite stays green.

Pin the digest to a constant instead. `test_golden_vector_digest` hashes a
fixed statement on a fixed network and asserts an exact hex digest;
`test_golden_vector_signature` feeds the same vector through
`validate_attestation` with an externally produced ed25519 signature, which
covers the verification path rather than just the hashing. Its `caller` is
the statement's own target, so hashStatementSafe substitutes like for like
and the digest under test is the pinned one.

The constants come from scripts/golden-vector.js, a third implementation
hand-rolled from the XDR spec that shares no code with the contract or with
the Go signer. Writing it caught a real encoding subtlety: ScAddress::Account
wraps AccountId -> PublicKey, a union whose own discriminant precedes the
key, where ScAddress::Contract wraps a bare Hash. The first draft omitted
those 4 bytes and disagreed with the contract — which is the point of not
deriving the expected value from the implementation under test.

Both tests were confirmed to fail under each refactor above, while the
pre-existing network-binding tests pass under both. Pinning the same vector
in the Go signer's test locks the two sides to one value instead of each to
itself; the script prints the constants for that side too.

No change to contract behaviour — the built wasm hash is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The golden vector added in 06c4b06 was asserted to catch wire-format
changes, but that claim rested on mutations run by hand once and thrown
away — no better evidenced than the self-referential tests it replaced.
Two additions make it checkable, and close the hole underneath it.

scripts/golden-vector.js gains --check, wired into CI. A pinned constant
alone only survives until someone hits a failing golden test and pastes in
whatever digest the code now produces; the suite goes green and the
protection is gone with no diff that looks alarming. --check recomputes the
vector from this script's independent model and fails if the constants in
predicate-registry drift from it, so that shortcut no longer works —
changing the format now requires editing the model too, which is visible in
review. It also fails when a constant cannot be found at all, since a
vector that quietly stops being read is the same no-op by another route.
Verified against both: a doctored digest and a renamed constant each exit 1.

scripts/verify-golden-vector-detects-drift.sh runs the demonstration on
demand: it reorders the preimage, renames a Statement field, and confirms
the golden tests reject each one. Sources are copied aside and restored by
an EXIT trap, so an interrupted run leaves the tree untouched, and a
mutation whose pattern fails to match is a hard error rather than a silent
pass — otherwise the script would report success while testing nothing.

Not wired into CI: it rewrites tracked sources and runs the suite per
mutation. The two are complementary — the script proves the tests detect
format changes today, --check keeps them able to.

GV_SIGNATURE is now one string literal rather than concat!, so --check can
read it back with a regex.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@douglasmakey

Copy link
Copy Markdown

The new test looks good, and with that, at least we have an extra precaution. Can we make CI run Clippy and enforce it? That would be nice.

Review request on #69: the soroban job built, tested and checked formatting
but ran no linter, and clippy exits 0 even when it has complaints, so it
only counts with -D warnings.

Adds the step and fixes what it found — three warnings across the whole
workspace, none of them in the golden-vector tests that prompted the ask:

- predicate-client `authorize_transaction` trips too_many_arguments (8/7).
  Allowed rather than restructured. The argument list mirrors the Statement
  fields deliberately, and a params struct would make it natural to build one
  value and reuse it, when every field has to be re-derived from the call
  being authorized — a stale field authorizes an action other than the one
  executing. The count is already down from 9 since the network parameter
  went away earlier in this branch.
- Two `mismatched_lifetime_syntaxes` on test helpers returning a contract
  client, in predicate-registry and test-stablecoin. Both take `<'_>`.

The second and third are rustc warnings rather than clippy lints; -D warnings
denies those too, which is the intent. --all-targets is what brings test code
into scope — without it the tests under review would be exempt, which would
rather miss the point.

test-stablecoin is otherwise untouched by this branch; its one-line fix is
here because the gate is workspace-wide and would fail without it.

Verified the gate bites: reinstating either lifetime turns it into
`error: could not compile`, exit 101.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alex-predicate
alex-predicate merged commit 43ed4e9 into main Aug 14, 2026
4 checks passed
@alex-predicate
alex-predicate deleted the alex-predicate/soroban-registry-report-fixes branch August 14, 2026 12:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants