fix(shielded-pool)!: reject non-canonical and zero commitments and nullifiers - #125
Merged
Merged
Conversation
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.
What this fixes
Commitments and nullifiers are stored as raw bytes and used as raw
StorageMapkeys, while byte-to-field conversion reduces modulo the BN254 primep. Without a canonicity check,nandn + pare two distinct keys for the same field element — a nullifier presented twice under different bytes, and one note spent twice.Nothing reached storage before this. The verifier passes public inputs through
to_field_elements, which re-encodes and compares byte for byte, so a non-canonical value already failed verification. But that was a single check on a single path: any future route that records a nullifier without full verification — a relayer pre-check, a mempool dedup cache, a runtime API — would have lost the protection silently.Commitment::is_canonicalandNullifier::is_canonicalnow guard every write path: shield, where the depositor picks the bytes with no proof constraining them and which is therefore the least guarded way into the tree; private_transfer, for both nullifiers and output commitments; and unshield, for the nullifier and the change commitment.The helper this delegates to (
FieldElement::is_canonical_leinzk-core) already existed, with a doc saying callers should reject non-canonical input at the trust boundary. It had no callers.Canonicity is kept separate from
validate()/is_valid(): an all-zero nullifier marks a dummy input slot and is itself canonical, so folding the two together would have rejected legitimate transfers.The gap the dev-node probe found
The first adversarial run came back 17/18. The failure was real, not a bad test.
A zero commitment was accepted. Zero is canonical, so the canonicity check alone let it through. It has to be refused separately: the tree represents an absent leaf with
[0u8; 32]— literallyget_zero_hash_cached(0)— so a stored zero leaf is indistinguishable from an empty slot whensubtree_rootrebuilds a pruned level, which is exactly the path sealed-tree pruning introduced. Nobody can prove a preimage for it either, so it would sit in the tree permanently as dead weight.Commitment::is_valid()existed for precisely this. Nothing called it.shieldandprivate_transfernow check it alongsideis_canonical();unshieldalready skipped a zero change commitment through itshas_changebranch.Breaking change
A client generating raw 32-byte values without reducing mod
pis now rejected withInvalidPublicSignals. Nothing the chain produces is affected — commitments and nullifiers come out of Poseidon and are always canonical.Ten of this repo's own tests were submitting exactly that, using fillers like
[0xC1; 32]and[0x31; 32]. Both sit abovep, whose top byte is0x30, so they were exercising a shape the chain never produces.[0x31; 32]is the instructive one: it fails by a single byte over the line. All moved onto canonical values through documented helpers.One of them held its value in a loop variable rather than a literal, so it survived a pattern search and only surfaced when the tests ran.
Verification
pallet-shielded-poolpallet-evm-precompile-shielded-pool-D warningsThe decisive unit test builds
nandn + p, asserts they are different bytes but the same field element, and requires that only the canonical one is accepted. Without that second assertion it would pass against a guard that rejected everything.The dev-node run submits the same pair on-chain and confirms only one ends up in the reverse index — storage as ground truth, not the dispatch result. It also checks that
pitself and all-ones are refused whilep - 1, the largest canonical value, is accepted; that a zero commitment is refused despite being canonical; and that 25 rejected shields in a burst grow the tree by nothing. Block height is verified after each batch, so a rejection that wedged block production would show up as a failure rather than as silence.Clippy caught a duplicated
#[test]attribute that an insertion left behind. The tests passed either way —rustctolerates it — but it failed under-D warnings.Versions
pallet-shielded-pool0.15.0 → 0.16.0. Minor rather than patch: the change rejects input it previously accepted. 0.15.0 is already merged, so this opens a new section rather than amending it.