Boole hardening: partial-sig size cap, checked runner assertions, role-mapper lockstep test (#2978 items 3, 4, 7) - #2989
Conversation
…item 3) Pre-fork, enforce the pre-boole envelope (1512 msgs, ~229 KB) instead of the post-fork AggregatorCommittee worst case (5048 msgs, ~763 KB), keeping the pre-fork decode DoS surface at its pre-boole size. The switch is wall-clock based (slot is unknown before decode) and flips one epoch early to avoid rejecting boundary messages. Drift-guarded in const_test.go against the spec v1.2.2 worst case.
…em 4) A CreateRunnerFn returning a mismatched runner type now surfaces as a descriptive error instead of a bare interface-conversion panic.
…2978 item 7) message.RunnerRoleToString and ssvtypes.RunnerRoleToString/utils.FormatRunnerRole must produce the same strings; the contract lived only in doc comments.
Codecov Report✅ All modified and coverable lines are covered by tests. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Greptile SummaryThe PR makes partial-signature validation choose a smaller pre-Boole encoded-data cap, replaces unchecked committee-runner assertions with descriptive errors, and adds a test keeping the two runner-role string mappers synchronized.
Confidence Score: 4/5The PR appears safe to merge, with only a non-blocking error-message capitalization issue in the checked runner assertions. The fork-aware cap, checked assertions, and mapper test have no established behavioral regression, while the two newly returned runner-type errors use formatting inconsistent with the repository convention. Files Needing Attention: protocol/v2/ssv/validator/committee.go
|
| Filename | Overview |
|---|---|
| message/validation/const.go | Adds a hand-derived pre-Boole partial-signature count and encoded-size cap, with no established correctness issue. |
| message/validation/const_test.go | Adds a hardcoded v1.2.2 size reference that guards the pre-fork cap from falling below the historical specification maximum. |
| message/validation/partial_validation.go | Applies the smaller cap before payload decoding and intentionally switches to the post-fork cap one epoch before activation. |
| observability/utils/format_test.go | Adds lockstep coverage for runner-role formatting across the role union supported by current forks. |
| protocol/v2/ssv/validator/committee.go | Replaces unchecked runner assertions with propagated errors; the new messages violate the repository's lowercase error convention. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Partial-signature gossip message] --> B[Decode signed SSV envelope]
B --> C{Boole active by next estimated epoch?}
C -->|No| D[Apply pre-fork encoded-data cap]
C -->|Yes| E[Apply post-fork encoded-data cap]
D --> F{Payload within cap?}
E --> F
F -->|No| G[Reject as data too big]
F -->|Yes| H[Decode PartialSignatureMessages]
H --> I[Validate message slot and fork-specific semantics]
Reviews (1): Last reviewed commit: "observability: lockstep test for the two..." | Re-trigger Greptile
- pin the fork gate of the partial-signature cap with a unit test (unscheduled / two-epochs-out / one-epoch-early flip / active) - extend the role-mapper lockstep test with a sweep over spec-known roles so a role added to only one mapper fails the test - guard createRunner against a nil runner returned without error - comment accuracy: the cap bounds the inner PartialSignatureMessages decode (outer decode is bounded by MaxEncodedMsgSize); note why the two drift guards compare against different spec constants; return r, nil explicitly
iurii-ssv
left a comment
There was a problem hiding this comment.
LGTM, just minor suggestions
ovidiu-ssv-labs
left a comment
There was a problem hiding this comment.
Solid, well-reasoned hardening. The size math is exact (verified 1512·144+16 = 217,744 against the v1.2.2 spec constant), the 1512 cap is strictly more permissive than the 1000 that actually shipped to mainnet, and the one-epoch-early flip is the right mitigation given ErrSSVDataTooBig is a reject (peer-score penalty), not an ignore. Four minor maintainability/robustness suggestions, none blocking. [verdict: yes]
ovidiu-ssv-labs
left a comment
There was a problem hiding this comment.
4 non blocking comments, decide if fixable.
…s prior window The pre-decode partial-signature cap hardcoded a one-epoch early flip as EstimatedCurrentEpoch() + 1, duplicating the SIP-43 prior-window width already named by networkconfig's boolePriorWindowEpochs. Expose the check as Network.BooleForkImminentOrActiveAtEpoch so a future widening of the prior window moves the cap flip with it.
The cap selector read the wall clock itself via EstimatedCurrentEpoch, so one message's validation made two time-based decisions from two different clock reads (validateSlotTime already keys off receivedAt, the single time.Now() sampled at the pubsub entry point). Thread receivedAt through instead. This also removes the dual-read nondeterminism from the fork-gate test: the fixtures and the gate now compute from the same fixed epoch, so an epoch boundary falling mid-test can no longer flip the expected cap.
The nil guard only caught an interface nil in both type and value; a CreateRunnerFn returning a typed-nil runner (var cr *runner.CommitteeRunner; return cr, nil) sailed past it and panicked two lines later inside SetQBFTRoundTimerF, a method promoted from the embedded *BaseRunner. Move the timer wiring after the checked type assertions and add explicit typed-nil checks, so no method is called on the runner before its concrete type and non-nilness are established.
…on path The fork-gate unit test exercised only the cap selector; nothing drove validatePartialSignatureMessage with a payload sized between the two caps, so the rejection branch (ErrSSVDataTooBig) was uncovered and a regression unwiring the fork-aware cap from the validation path would have passed. Add an end-to-end case: a between-caps payload is rejected against the pre-fork cap pre-fork, and passes the size gate (failing only at decode) once the fork is active.
…rip test The round-trip test iterated a hardcoded role list, so a role newly added to the spec could gain its (test-forced) RunnerRoleToString case while RunnerRoleFromString silently stayed behind — nothing would fail until CommitteeRunnerRoleFromString rejected the exporter's own emitted string at runtime. Mirror the observability lockstep sweep: every value the spec knows a name for must round-trip through both mappers.
… the panic asymmetry - format_test: say why the lockstep sweep stops at 15 (headroom over the spec's current max role value, roles are appended sequentially) - const.go: note the spec's 217748 includes the 4-byte SSZ offset of the dynamic Messages field that preForkMaxPartialSignatureMsgsSize (217744) deliberately omits - committee.go: say why the wrong-duty-type default stays a panic while the runner-type mismatches return errors (duty type is internally produced, CreateRunnerFn is injected)
5b5d257
|
I'm not sure the complexity of this PR and mechanism is really worth the technical overhead of more "fork-aware" code. The attack surface does grow ~3x, but that doesn't mean an attacker can now easily do something that was very hard before. Some numbers from benchmarking the decode path the cap gates (pinned spec,
Decode is linear (~2.4 GB/s), so the cost per attacker byte is identical under both caps — with the smaller cap an attacker just sends 3.3x more, smaller messages and induces the same total load (either way they'd need ~16 Gbps of sustained spam at a node to keep one core busy, and peer scoring greylists them long before that matters). The only thing the pre-fork cap really buys is less work per gossip scoring penalty (~360 µs vs ~100 µs per invalid message), which is negligible in absolute terms. So I'd suggest dropping item 3 and keeping the static post-fork cap — items 4 and 7 are fine as-is. |
…c post-fork one Decode cost is linear in payload bytes, so the smaller pre-fork cap never reduced the total load an attacker can induce per byte of spam — it only shrank the work per gossip-scoring penalty, which is negligible in absolute terms. Consensus messages on the same topics are capped at ~740 KB either way, so the tighter partial-signature lane never shrank the topic's per-message attack surface. Keep the post-fork cap (needed from activation anyway) as a static bound and remove the receivedAt-keyed switch, the pre-fork constants, and the now-unused prior-window epoch helper.
|
Agreed, the benchmarks settle it. One more point in the same direction: consensus messages on these topics are capped at ~740 KB ( Dropped in 83498dd: the cap is the static post-fork bound again, and the receivedAt switch, pre-fork constants, and prior-window helper are gone (144 lines removed). Items 4 and 7 untouched. |
Knocks out the three self-contained code items from #2978 (Boole convergence hardening follow-ups).
Item 3 — partial-signature size cap (resolved as static post-fork cap)
The PR initially made the cap fork-aware: the pre-Boole worst case (1512 messages, ~229 KB) enforced until one epoch before activation, the post-fork AggregatorCommittee worst case (~763 KB) after. After y0sher's benchmarks the mechanism was dropped in 83498dd: decode cost is linear in payload bytes, so the smaller cap never reduced the total load an attacker can induce per byte of spam, only the (negligible) work per gossip-scoring penalty. Consensus messages on the same topics are capped at ~740 KB either way, so the tighter partial-signature lane never shrank the topic's per-message attack surface.
What remains of item 3 is the static post-fork cap
maxEncodedPartialSignatureSize, drift-guarded against the pinned spec'sMaxSizeSSVMessageFromPartialSignatureMessagesinconst_test.go, plus a test driving the size gate throughvalidatePartialSignatureMessageitself (oversized payload rejected, at-cap payload passes the gate and fails at decode).Item 4 — checked type assertions in
createRunnerCommittee.createRunnerassertedr.(*runner.CommitteeRunner)/r.(*runner.AggregatorCommitteeRunner)unchecked. The invariant holds today, but a futureCreateRunnerFnreturning a mismatched type would crash with a bare interface-conversion panic. Both assertions are now checked and return a descriptive error instead.Item 7 — lockstep test for the runner-role string mappers
message.RunnerRoleToStringandssvtypes.RunnerRoleToString(viautils.FormatRunnerRole) are independent mappers that must produce identical strings, but the contract lived only in doc comments.TestRunnerRoleStringMappersLockstepnow asserts equality for every runner role valid in any fork, so drift fails CI instead of silently splitting duty IDs from exporter strings.What did NOT change
MaxEncodedMsgSize) is untouched — it stays at the post-fork maximum in all cases, as it must (a gossip message-size limit can't change at the fork without splitting the mesh).validatePartialSigMessagesByDutyLogicare unchanged.defaultbranch ofcreateRunnerkeeps its existinglogger.Panic.Closes nothing on its own — items tracked in #2978.
Testing
go test ./message/validation/ ./observability/utils/ ./protocol/v2/ssv/validator/...all pass, including the size-cap drift guard, the size-gate wiring test, and the lockstep test.