You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
InclusionProofV2.Verify never binds the state ID to the shard that certified it, so a leaf certified by the wrong shard verifies as valid. In a multi-shard deployment this is cross-shard double-spend exposure.
Found while auditing the implementation against the yellowpaper. The RSMT core itself conforms byte-for-byte; this is a missing check in the public proof verifier.
What the spec requires
platform.tex types VerifyInclusionProof as IP × CD × UB → bool — the partition description CD_β, carrying the network id α and the sharding scheme SH, is an input. Before any tree check it mandates:
ensure(UC.C^r.α = T.α)
ensure(CD_β.α = T.α ∧ H(CD_β) = UC.C^uni.dhash)
σ ← UC.C^shard.σ
ensure(σ ∈ CD_β.SH)
ensure(f_{CD_β.SH}(sid) = σ) // Proof comes from the right shard
f_SH derives the expected shard from the key itself. The paper explicitly forecloses delegating this to certificate verification:
VerifyUnicityCert "does not, by itself, prove that a particular state identifier belongs to the shard named in C^shard; that binding is checked by the proof verification functions below."
What the code does
pkg/api/types.goInclusionProofV2.Verify implements the tree and certificate steps faithfully but not the binding. VerifierContext replaces CD_β with a caller-supplied PartitionID + ExpectedShardID (types.go:378-383), and the only shard test is inside uc.Verify(..., vctx.ExpectedShardID, ...) at types.go:452, which reaches bft-go-base types/shard_certificate.go and does cert.Shard.Equal(shardID) — equality with the caller's own value, never f_SH(sid) = σ.
The network id is also unchecked: UnicitySeal.Verify never compares seal.NetworkID against tb.GetNetworkID(), so a UC sealed for testnet verifies against a mainnet trust base.
Impact
Reproduced during the audit: in a two-shard scheme, a leaf with sid = 0x1111… (which routes to shard "0") committed in shard 1's SMT, under shard 1's fully signed UC, returns nil from Verify. Shard B can certify a spend of a state owned by shard A and verifiers accept it. Uniqueness is enforced per-tree, so nothing else catches it.
Two things make this worse in practice:
test/integration/bft_sharding_e2e_test.go:141-151 derives ExpectedShardID from which endpoint served the proof — exactly the pattern that accepts a foreign-shard leaf. Anything modelled on it inherits the hole.
Note there are currently no non-test callers of InclusionProofV2.Verify; production paths go through internal/proofverify/local.go, which by its own contract does no shard binding, no network check and no UC verification. So the present blast radius is external consumers of pkg/api, not this service's own hot path — but this is the verifier we publish for other implementations to use.
Suggested fix
f_SH already exists in-repo and is applied correctly at admission — internal/signing/certification_request_validator.go:194 (bftShardID.Comparator()) and :202 (api.MatchesShardPrefixFromHex). The verifier needs the same thing:
VerifierContext carries the sharding scheme (or the full partition descriptor) instead of a caller-chosen ExpectedShardID.
Verify computes σ_expected = f_SH(v2.StateID) itself and requires σ_expected = UC.C^shard.σ, plus σ ∈ SH.
Add the network check UC.C^r.α = T.α.
Update the e2e test to stop deriving the expected shard from the serving endpoint.
This is a breaking change to a public API, which is why it is filed rather than patched inline.
Partly out of scope:H(CD_β) = UC.C^uni.dhash cannot be done here — the bft-go-base UnicityTreeCertificate has no dhash field on the wire, so platform.tex's leaf format x_i ← H(IH[C_i] ‖ H(CD[C_i])) is missing from the UC encoding itself. That half is BFT-Core-owned. The α check and the f_SH binding are squarely this repo's.
Related
Same audit also found: parent mode certifies a non-canonical RSMT root (empty subtrees committed as all-zero digests rather than compressed, so IR.h is not reproducible externally); one of eight predicate types implemented with τ never reaching the validator; the state-ID preimage inlining a tagged predicate where appendix-token.tex specifies a byte string; and non-inclusion certificates unimplemented with the wire field order inverted relative to appendix-hashtrees.tex. Happy to file those separately.
InclusionProofV2.Verifynever binds the state ID to the shard that certified it, so a leaf certified by the wrong shard verifies as valid. In a multi-shard deployment this is cross-shard double-spend exposure.Found while auditing the implementation against the yellowpaper. The RSMT core itself conforms byte-for-byte; this is a missing check in the public proof verifier.
What the spec requires
platform.textypesVerifyInclusionProofasIP × CD × UB → bool— the partition descriptionCD_β, carrying the network idαand the sharding schemeSH, is an input. Before any tree check it mandates:f_SHderives the expected shard from the key itself. The paper explicitly forecloses delegating this to certificate verification:What the code does
pkg/api/types.goInclusionProofV2.Verifyimplements the tree and certificate steps faithfully but not the binding.VerifierContextreplacesCD_βwith a caller-suppliedPartitionID+ExpectedShardID(types.go:378-383), and the only shard test is insideuc.Verify(..., vctx.ExpectedShardID, ...)attypes.go:452, which reaches bft-go-basetypes/shard_certificate.goand doescert.Shard.Equal(shardID)— equality with the caller's own value, neverf_SH(sid) = σ.The network id is also unchecked:
UnicitySeal.Verifynever comparesseal.NetworkIDagainsttb.GetNetworkID(), so a UC sealed for testnet verifies against a mainnet trust base.Impact
Reproduced during the audit: in a two-shard scheme, a leaf with
sid = 0x1111…(which routes to shard"0") committed in shard 1's SMT, under shard 1's fully signed UC, returnsnilfromVerify. Shard B can certify a spend of a state owned by shard A and verifiers accept it. Uniqueness is enforced per-tree, so nothing else catches it.Two things make this worse in practice:
test/integration/bft_sharding_e2e_test.go:141-151derivesExpectedShardIDfrom which endpoint served the proof — exactly the pattern that accepts a foreign-shard leaf. Anything modelled on it inherits the hole.docs/inclusion-proof-wire.mdpreviously told integrators to "deriveExpectedShardIDfrom configuration". That is not the specified check and does not close the gap — the spec derives the expected shard from the key, not from deployment config. (Corrected in fix(api): bind inclusion proofs to the certified shard and network; return referenceTime on block records #182.)Note there are currently no non-test callers of
InclusionProofV2.Verify; production paths go throughinternal/proofverify/local.go, which by its own contract does no shard binding, no network check and no UC verification. So the present blast radius is external consumers ofpkg/api, not this service's own hot path — but this is the verifier we publish for other implementations to use.Suggested fix
f_SHalready exists in-repo and is applied correctly at admission —internal/signing/certification_request_validator.go:194(bftShardID.Comparator()) and:202(api.MatchesShardPrefixFromHex). The verifier needs the same thing:VerifierContextcarries the sharding scheme (or the full partition descriptor) instead of a caller-chosenExpectedShardID.Verifycomputesσ_expected = f_SH(v2.StateID)itself and requiresσ_expected = UC.C^shard.σ, plusσ ∈ SH.UC.C^r.α = T.α.This is a breaking change to a public API, which is why it is filed rather than patched inline.
Partly out of scope:
H(CD_β) = UC.C^uni.dhashcannot be done here — the bft-go-baseUnicityTreeCertificatehas nodhashfield on the wire, soplatform.tex's leaf formatx_i ← H(IH[C_i] ‖ H(CD[C_i]))is missing from the UC encoding itself. That half is BFT-Core-owned. Theαcheck and thef_SHbinding are squarely this repo's.Related
Same audit also found: parent mode certifies a non-canonical RSMT root (empty subtrees committed as all-zero digests rather than compressed, so
IR.his not reproducible externally); one of eight predicate types implemented withτnever reaching the validator; the state-ID preimage inlining a tagged predicate whereappendix-token.texspecifies a byte string; and non-inclusion certificates unimplemented with the wire field order inverted relative toappendix-hashtrees.tex. Happy to file those separately.