fix(soroban): bind attestation digest to the host network (FIND-001) - #69
Merged
alex-predicate merged 4 commits intoAug 14, 2026
Merged
Conversation
penDerGraft
approved these changes
Aug 12, 2026
`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
force-pushed
the
alex-predicate/soroban-registry-report-fixes
branch
from
August 13, 2026 09:39
208c398 to
0c24e7e
Compare
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>
|
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. |
douglasmakey
approved these changes
Aug 13, 2026
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
deleted the
alex-predicate/soroban-registry-report-fixes
branch
August 14, 2026 12:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Audit finding FIND-001 — Attestation digest is not bound to the host chain or registry instance (Low).
The problem
compute_hashtook the domain separator as a parameter:Whoever called
hash_statement/validate_attestationchose 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 andcaller.require_auth()are still required.The EVM registry has never had this hole:
hashStatementWithExpiryandhashStatementSafeboth mix inblock.chainid(src/PredicateRegistry.sol:187,213). This brings Soroban to parity.The fix
Read the domain from the host:
network_idissha256(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()atledger.rs:102is 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:0000000eScVal::String(passphrase)0000000dScVal::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 anScVal::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:
validatesubstitutes the caller forstatement.target, so exploiting it needs one integrating contract wired to two registries of the same preimage layout.predicate-avs→stmapiv2/internal/domain/types.go,stellarDigestBytes);Taskcarries no registry field. Adding the address means new config plumbing to protect a configuration we do not run.address(this)and no EIP-712 domain separator; Solana's digest binds neither program id nor chain id.compute_hashdocuments both decisions and the trigger to revisit the address.Breaking changes
The redundant
networkparameter is gone throughout:hash_statement(statement)— was(statement, network)validate_attestation(statement, attestation, caller)— was(statement, attestation, network, caller)predicate_client::authorize_transactiondropsnetworkexample-compliant-token: thenetworkconstructor arg and itsNETWORKinstance-storage entry are removed (dead once the registry derives the network itself)deploy-compliant-token.sh: drops the constructor's network argumentRollout — requires coordination
stellarDigestBytes— one line. Replace the passphrase ScVal with the network id; nothing is prepended:stellarStatementScValand its map ordering are untouched — the golden-vector test just needs its expected digest regenerated.REGISTRYis 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 --checkclean;cargo docwarning-free; bothwasm32-unknown-unknownandwasm32v1-nonebuild.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 thenetwork_idline 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