fix(types)!: reject malformed SignedOrder JSON at deserialize time#237
fix(types)!: reject malformed SignedOrder JSON at deserialize time#237prestwich wants to merge 4 commits into
Conversation
`SignedOrder::order_hash()` previously panicked on a `SignedOrder` deserialized from JSON when the `signature` field was not exactly 65 bytes, via `Signature::from_raw(...).unwrap()`. The same type is used for both ABI-decoded chain events (where the 65-byte invariant is enforced by the contract) and serde-decoded JSON (where any byte string is accepted), so the unwrap was unsound at the off-chain trust boundary. This change: - Adds `signet_zenith::serde_helpers` with `deserialize_signature_bytes` and `deserialize_non_empty_vec` for use as `#[serde(deserialize_with)]` on sol-shaped types received at JSON boundaries. - Replaces `SignedOrder`'s derived `Deserialize` with a custom impl backed by private repr types that enforce a 65-byte signature, a non-empty `permitted` list, and a non-empty `outputs` list. Malformed inputs now surface as `serde_json::Error`. - Renames `SignedOrder::new` to `new_unchecked` (the structural invariants are now documented on the constructor). - Renames `SignedOrder::validate` to `validate_at` and adds `is_valid_at`, clarifying that this checks time/state validity only. - Documents the `order_hash` `.unwrap()` as sound under the type-level invariant, with a `// SAFETY:` comment. - Adds proptests in `signet-test-utils` proving the deserialize path never panics on arbitrary input. - Adds `// SAFETY:` annotations to two related audit findings in `agg/fill.rs` and `agg/order.rs` whose unwraps/casts are sound under caller-side or protocol invariants. Refs ENG-2288. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Breaking changes in this release: - `SignedOrder::new` renamed to `new_unchecked` - `SignedOrder::validate` renamed to `validate_at` - `<SignedOrder as Deserialize>` now rejects malformed signatures, empty `permitted`, and empty `outputs` Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fraser999
left a comment
There was a problem hiding this comment.
One blocker on the issue in checked_remove_aggregate.
…ing recipient check_aggregate accepted a zero-amount output to a recipient absent from self.fills (filled() collapses missing-recipient and zero-balance into U256::ZERO, so 0 < 0 is false), then checked_remove_aggregate panicked unwrapping the inner get_mut. Guard the inner lookup with `if let Some` so the absent case becomes a no-op; check_aggregate semantics are unchanged. Refactor the two check_filled methods into one private helper that takes a pre-resolved balance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test asserts that an empty vec is rejected; the previous name read as the opposite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| if v.is_empty() { | ||
| return Err(D::Error::invalid_value(Unexpected::Seq, &"at least one element")); | ||
| } |
There was a problem hiding this comment.
[Claude Code]
Length alone isn't a sufficient invariant for compute_order_hash_pre_image's Signature::from_raw(...).unwrap(). from_raw also rejects 65-byte payloads whose v byte falls outside {0, 1, 27, 28, 35..} (alloy normalize_v), so the original ENG-2288 panic is still reachable via a 65-byte signature with v = 5 (or any other invalid parity):
Suggesting to drop the manual length check and delegate everything to Signature::from_raw, which checks both length and parity in one place:
| if v.is_empty() { | |
| return Err(D::Error::invalid_value(Unexpected::Seq, &"at least one element")); | |
| } | |
| Signature::from_raw(&bytes).map_err(D::Error::custom)?; |
This keeps the helper's promise (post-deserialize bytes are safe to feed straight into Signature::from_raw) as a single source of truth against the downstream consumer, instead of two checks that can drift. The "expected exactly 65 bytes" substring is still present in the length-error path, so the existing assertion tests pass without changes.
The current proptest doesn't catch this because sig_bytes in 0..130 only produces a 65-byte input ~0.77% of the time - under default 256 cases, expected hits with len==65 ∧ invalid v is ≈0.24, so a green run is mostly luck. Worth adding a dedicated 65-byte-with-arbitrary-v strategy and a hard-coded v=5 regression test once the helper is tightened.
Evalir
left a comment
There was a problem hiding this comment.
Three points from review — flagging inline.
| @@ -61,25 +61,6 @@ impl AggregateFills { | |||
| self.fills.get(output_asset).and_then(|m| m.get(&recipient)).copied().unwrap_or_default() | |||
| } | |||
There was a problem hiding this comment.
Undisclosed breaking change (review issue 1): the sibling pub fn check_filled on AggregateFills is removed in this PR (replaced by a private module-level helper). That's a public-API removal in signet-types, but the PR description's Breaking changes section lists only the SignedOrder renames. Worth adding here so the 0.19 → 0.20 bump's changelog is complete and downstream consumers aren't surprised. (The analogous removal on CombinedContext::check_filled is fine — that one was private.)
| buf.extend_from_slice(keccak256(self.outputs.abi_encode()).as_slice()); | ||
|
|
||
| // Normalize the signature. | ||
| // SAFETY: `self.permit.signature` is exactly 65 bytes by the |
There was a problem hiding this comment.
Style nit (review issue 2): // SAFETY: is the established Rust convention for justifying unsafe blocks. The reasoning here is load-bearing — a non-unsafe invariant explanation tied to new_unchecked's contract — but using the SAFETY keyword on safe code blurs the convention and can confuse readers grepping for actual unsafe usage. Consider // INVARIANT: or just a plain comment lead-in. The content of the comment is excellent; only the keyword is the nit. Same applies to the // SAFETY: notes in agg/fill.rs and agg/order.rs.
| self.check_aggregate(aggregate)?; | ||
|
|
||
| for (output_asset, recipients) in aggregate.outputs.iter() { | ||
| // SAFETY: `check_aggregate` above guarantees that, for every |
There was a problem hiding this comment.
Style nit (review issue 2, cont.): same // SAFETY: → // INVARIANT: (or plain comment) suggestion as in signing/order.rs. The reasoning itself is correct and well-written — the 0 < 0 == false argument for the absent-inner-entry case is the right justification.
| token: *token, | ||
| amount: U256::from(*amount), | ||
| recipient: *recipient, | ||
| // SAFETY: signet chain IDs are protocol-defined to |
There was a problem hiding this comment.
Style nit (review issue 2, cont.): // SAFETY: → // INVARIANT: or plain comment, same reasoning as the other two sites.
Separately (optional, non-blocking): ru_chain_id as u32 will silently truncate if a future protocol change ever lifts the uint32 constraint on Output.chainId. u32::try_from(ru_chain_id).expect("signet chain IDs fit in u32 — see protocol invariant") would panic loudly at the boundary instead of producing a wrong chain ID. The comment already flags this as the thing to revisit, so it's a matter of taste whether to encode the check in code too.
| /// `initiatePermit2` contract entrypoint: a 65-byte signature, a non-empty | ||
| /// `permitted` list, and a non-empty `outputs` list. | ||
| #[derive(Deserialize)] | ||
| struct SignedOrderRepr { |
There was a problem hiding this comment.
Maintainer note worth adding (review issue 3): SignedOrder now has a hand-rolled Deserialize (via this SignedOrderRepr) but a derived Serialize. That asymmetry is fine today and the roundtrip tests catch divergence — but if a sol-emitted field is ever renamed/added inside Permit2Batch, TokenPermissions, or Output, the derived Serialize will silently follow the change while SignedOrderRepr will not, and the only signal will be a roundtrip-test failure.
A short comment on SignedOrderRepr pointing future maintainers at this coupling — "keep field shape in sync with the derived Serialize on SignedOrder; roundtrip tests will catch drift" — would save the next person a debugging session.
There was a problem hiding this comment.
no field will ever be added, as the contracts are finalized
|
these comments feel super pedantic and not really worth addressing |
Summary
SignedOrder::order_hash()panicked on aSignedOrderdeserialized from untrusted JSON whosesignaturewas not exactly 65 bytes — an asymmetric DoS vector against any consumer of the tx-cache order feed.permitted, non-emptyoutputs) to type-system guarantees enforced at deserialize time, soorder_hash'sSignature::from_raw(...).unwrap()becomes sound by construction.signet_zenith::serde_helpersexposesdeserialize_signature_bytesanddeserialize_non_empty_vecfor other sol-shaped types that need the same treatment at JSON boundaries.SignedOrder::new→new_unchecked(documenting the invariants the caller must uphold) andSignedOrder::validate→validate_at, plus a newis_valid_at, drawing a clear line between structural validity (enforced at construction) and time/state validity (checked at call sites).signet-test-utils/tests/deserialization_fuzz.rsprove theSignedOrderdeserialize path is total — every input either deserializes into a value satisfying all three invariants, or errors via serde; never panics.// SAFETY:annotations on two audit findings (agg/fill.rs:209,agg/order.rs) whose unwraps/casts are sound under caller-side or protocol invariants.Breaking changes
SignedOrder::new(...)→SignedOrder::new_unchecked(...)SignedOrder::validate(...)→SignedOrder::validate_at(...)<SignedOrder as Deserialize>now rejects inputs that previously deserialized successfully (i.e. the bug).Minor-version bump recommended on next release.
Reporter
This bug was reported externally by Cinder Circuit / @achimala under the Signet bug bounty.
Test plan
cargo t -p signet-types -p signet-zenith -p signet-orders -p signet-test-utils— all passcargo clippy --workspace --all-targets --all-features -- -D warningscargo clippy --workspace --all-targets --no-default-features -- -D warningsRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-depscargo +nightly fmt -- --check#[should_panic]to asserting a serdeErrSignedOrdertotality, round-trip stability, garbage-string safety🤖 Generated with Claude Code
Fixes ENG-2288