Skip to content

Boole hardening: partial-sig size cap, checked runner assertions, role-mapper lockstep test (#2978 items 3, 4, 7) - #2989

Merged
momosh-ssv merged 11 commits into
stagefrom
fix/2978-hardening-3-4-7
Aug 25, 2026
Merged

Boole hardening: partial-sig size cap, checked runner assertions, role-mapper lockstep test (#2978 items 3, 4, 7)#2989
momosh-ssv merged 11 commits into
stagefrom
fix/2978-hardening-3-4-7

Conversation

@momosh-ssv

@momosh-ssv momosh-ssv commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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's MaxSizeSSVMessageFromPartialSignatureMessages in const_test.go, plus a test driving the size gate through validatePartialSignatureMessage itself (oversized payload rejected, at-cap payload passes the gate and fails at decode).

Item 4 — checked type assertions in createRunner

Committee.createRunner asserted r.(*runner.CommitteeRunner) / r.(*runner.AggregatorCommitteeRunner) unchecked. The invariant holds today, but a future CreateRunnerFn returning 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.RunnerRoleToString and ssvtypes.RunnerRoleToString (via utils.FormatRunnerRole) are independent mappers that must produce identical strings, but the contract lived only in doc comments. TestRunnerRoleStringMappersLockstep now 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

  • The pubsub-level cap (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).
  • Validation behavior is identical pre- and post-fork; the partial-signature cap is the same static bound the Boole convergence introduced.
  • The per-cluster signature-count rules in validatePartialSigMessagesByDutyLogic are unchanged.
  • The default branch of createRunner keeps its existing logger.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.

…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.
@momosh-ssv
momosh-ssv requested review from a team as code owners August 11, 2026 14:24
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 65.3%. Comparing base (48d4f3a) to head (83498dd).
⚠️ Report is 17 commits behind head on stage.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The 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.

  • Adds pre-fork partial-signature size constants and a spec drift guard.
  • Selects the payload cap from the estimated epoch, switching one epoch before Boole activation.
  • Converts committee runner type mismatches from interface-conversion panics into returned errors.
  • Tests role-string equality across the union of roles supported by current forks.

Confidence Score: 4/5

The 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

Important Files Changed

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]
Loading

Reviews (1): Last reviewed commit: "observability: lockstep test for the two..." | Re-trigger Greptile

Comment thread protocol/v2/ssv/validator/committee.go
- 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
iurii-ssv previously approved these changes Aug 12, 2026

@iurii-ssv iurii-ssv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, just minor suggestions

Comment thread message/validation/partial_validation_test.go Outdated
Comment thread message/validation/partial_validation.go Outdated
Comment thread observability/utils/format_test.go
Comment thread message/validation/const.go Outdated
Comment thread protocol/v2/ssv/validator/committee.go

@ovidiu-ssv-labs ovidiu-ssv-labs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Comment thread message/validation/partial_validation.go Outdated
Comment thread message/validation/partial_validation.go Outdated
Comment thread observability/utils/format_test.go
Comment thread protocol/v2/ssv/validator/committee.go

@ovidiu-ssv-labs ovidiu-ssv-labs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
@y0sher

y0sher commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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, ssz-max 5048, M2 Pro):

Work per spam message Payload CPU Allocs
Inner decode at pre-fork cap (1512 sigs) 218 KB ~83 µs 3k / 278 KB
Inner decode at post-fork cap (5048 sigs) 727 KB ~296 µs 10k / 929 KB
Outer SignedSSVMessage decode (happens either way) 727 KB ~65 µs 5

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.
@momosh-ssv

Copy link
Copy Markdown
Contributor Author

Agreed, the benchmarks settle it. One more point in the same direction: consensus messages on these topics are capped at ~740 KB (maxEncodedConsensusMsgSize) pre-fork and post-fork alike, so the tighter partial-signature lane never shrank the topic's per-message attack surface to begin with.

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.

@momosh-ssv momosh-ssv changed the title Boole hardening: fork-aware partial-sig cap, checked runner assertions, role-mapper lockstep test (#2978 items 3, 4, 7) Boole hardening: partial-sig size cap, checked runner assertions, role-mapper lockstep test (#2978 items 3, 4, 7) Aug 21, 2026

@iurii-ssv iurii-ssv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good stuff!

@momosh-ssv
momosh-ssv merged commit 7a6c8e8 into stage Aug 25, 2026
10 checks passed
@momosh-ssv
momosh-ssv deleted the fix/2978-hardening-3-4-7 branch August 25, 2026 09:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants