Skip to content

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

Open
momosh-ssv wants to merge 11 commits into
stagefrom
fix/2978-hardening-3-4-7
Open

Boole hardening: partial-sig size cap, checked runner assertions, role-mapper lockstep test (#2978 items 3, 4, 7)#2989
momosh-ssv wants to merge 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

c.Runners[duty.DutySlot()] = r.(*runner.CommitteeRunner)
cr, ok := r.(*runner.CommitteeRunner)
if !ok {
return nil, fmt.Errorf("BUG: runner created for committee duty has type %T, expected *runner.CommitteeRunner", r)

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.

P2 Uppercase runner error prefix

The new type-mismatch errors begin with BUG:, contrary to the repository convention that error messages remain lowercase and concise; the aggregator mismatch at line 574 repeats the same formatting issue.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The BUG: prefix is deliberate here — it marks invariant violations and matches the existing logger.Panic("BUG: ...") in this same function (and elsewhere in the repo). Please remember: the lowercase convention applies to ordinary error messages, not to the BUG: invariant marker.

- 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

ssv.Forks = networkconfig.SSVForks{Boole: booleEpoch}
return &networkconfig.Network{Beacon: networkconfig.TestNetwork.Beacon, SSV: &ssv}
}
currentEpoch := networkconfig.TestNetwork.EstimatedCurrentEpoch()

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.

Minor (test robustness). currentEpoch is sampled from the wall clock here, but the helper samples EstimatedCurrentEpoch() again internally. If a real epoch boundary falls between the two reads, the "fork two epochs away" case flips to the post-fork cap and the assertion fails:

  • setup reads epoch Eboole = E+2, want = preFork
  • helper reads E+1BooleForkAtEpoch((E+1)+1) = (E+2) >= (E+2) = true → returns post-fork cap → mismatch

The window is ~microseconds within a multi-minute epoch, so this is astronomically rare rather than a real-world concern — but it is genuine nondeterminism. For full determinism, drive EstimatedCurrentEpoch() from a fixed/injected clock (or fixed genesis) instead of the live TestNetwork clock. The other three cases are immune.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9a706e9 — good catch, and Ovi's finding pointed at the same root: the cap is now keyed off receivedAt, and the test derives its timestamp from a fixed epoch, so the dual read (and the flake window with it) is gone entirely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update: this test is gone entirely now. The fork-aware cap was dropped for the static post-fork bound in 83498dd, per y0sher's benchmarks in the PR conversation.

ssvMessage := signedSSVMessage.SSVMessage

if len(ssvMessage.Data) > maxEncodedPartialSignatureSize {
if maxSize := mv.currentMaxEncodedPartialSignatureSize(); len(ssvMessage.Data) > maxSize {

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.

Test coverage. The codecov bot flags this file's uncovered lines; the untested one is this rejection branch (return nil, e for ErrSSVDataTooBig). TestCurrentMaxEncodedPartialSignatureSize exercises the cap selector, but nothing drives validatePartialSignatureMessage with a payload sized between the two caps. Consider a small end-to-end case: ssvMessage.Data in (preForkMaxEncodedPartialSignatureSize, maxEncodedPartialSignatureSize] is rejected pre-fork and accepted past the size gate post-fork. That closes the coverage gap and guards that the fork-aware cap stays wired into the validation path (not just the helper).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 723c907 — added the end-to-end case you suggested: a payload sized between the two caps is rejected against the pre-fork cap pre-fork (asserting the want field to pin which cap fired) and passes the size gate post-fork, failing only at decode.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update: the fork-aware cap was dropped in 83498dd (see PR conversation). The end-to-end case survives in trimmed form: an oversized payload is rejected, one at the cap passes the gate and fails at decode.

// spec does not know return "UNDEFINED" and are skipped: divergence on genuinely
// unknown values is intentional (the deprecated Alan roles also stringify to
// "UNDEFINED" in the spec, but they are covered by the explicit list above).
for i := 0; i <= 15; i++ {

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.

Nit. The sweep upper bound 15 is arbitrary — a spec role added at value ≥ 16 would slip past this drift guard. Enum values are sequential today (0–6), so it isn't a practical gap, but a one-line note on why 15 (headroom) — or deriving the bound — would make the intent explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5b5d257 — the comment now says why 15: headroom over the spec's current max role value (6), since roles are appended sequentially.

Comment thread message/validation/const.go Outdated

// preForkMaxPartialSignatureMessages is the pre-boole worst case (RoleCommittee,
// min(2*V, V+SYNC_COMMITTEE_SIZE) with the spec's V=1000 bound), matching pre-boole
// ssv-spec v1.2.2 maxmsgsize.maxSizePartialSignatureMessages (1512 messages, 217748

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.

Nit (doc clarity). The parenthetical "217748 bytes" is the spec's SSZ value (20 + 1512·144), whereas preForkMaxPartialSignatureMsgsSize on line 63 evaluates to 217744 — it omits the 4-byte SSZ offset for the dynamic Messages field. Harmless (the encoding-overhead margin absorbs it, and it matches how maxPartialSignatureMsgsSize is computed), but a reader diffing 217748 vs 217744 may pause. A half-sentence noting the local figure is pre-offset would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5b5d257 — the comment now notes the spec's 217748 includes the 4-byte SSZ offset of the dynamic Messages field, which the local pre-offset figure (217744) deliberately omits.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update: the pre-fork constants and this comment were removed in 83498dd. The fork-aware cap was dropped for the static post-fork bound (see PR conversation).

}
c.AggregatorRunners[duty.DutySlot()] = ar
default:
c.logger.Panic("BUG: attempt to create committee runner with non-committee duty type",

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.

Optional / non-blocking. The two runner type-mismatch cases above now return errors, but this default (wrong duty type) still logger.Panics. The distinction is defensible — duty type is internally controlled here, whereas the runner type comes from the injected CreateRunnerFn — but the asymmetry (BUG → return vs BUG → panic) is easy to trip over. A one-line comment on why this one stays a panic would preempt the question. Fine to leave as-is.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5b5d257.
Added the one-liner: duty type is produced internally, unlike the injected CreateRunnerFn, so the default stays a loud panic.

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

// SIP-43's one-epoch prior window) so that messages for post-fork slots arriving early
// (clock skew) are never rejected against the smaller cap.
func (mv *messageValidator) currentMaxEncodedPartialSignatureSize() int {
if mv.netCfg.BooleForkAtEpoch(mv.netCfg.EstimatedCurrentEpoch() + 1) {

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.

Finding 1 · [MINOR] Reuse networkconfig's prior-window constant instead of hardcoding + 1

The gate hardcodes a one-epoch lead — mv.netCfg.BooleForkAtEpoch(mv.netCfg.EstimatedCurrentEpoch() + 1) — justified as mirroring SIP-43's one-epoch prior window. That window is already a named constant in networkconfig, boolePriorWindowEpochs = phase0.Epoch(1). The two are exactly equivalent today, but the prior window is a network-wide protocol parameter, not a local constant: if it's ever widened, networkconfig changes and this cap silently doesn't, leaving a window where the node is subscribed to the post-fork topic set but still enforcing the pre-fork cap on messages arriving on it.

Since ErrSSVDataTooBig is reject:true, the failure mode is a gossipsub peer-score penalty against an honest peer, not a silently dropped message. This is exactly the duplicated-invariant-across-packages pattern that item 7 of this same PR adds a lockstep test to guard against elsewhere — the fix belongs in the same category of hardening.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0b6c0c5.
Added Network.BooleForkImminentOrActiveAtEpoch, which wraps boolePriorWindowEpochs, and the cap now calls that instead of the hardcoded + 1.

A future widening of the prior window moves the cap flip with it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update: 83498dd drops the fork-aware cap entirely (see PR conversation), so the wrapper helper is gone too.

// switch is wall-clock based — and flips one epoch before boole activation (mirroring
// SIP-43's one-epoch prior window) so that messages for post-fork slots arriving early
// (clock skew) are never rejected against the smaller cap.
func (mv *messageValidator) currentMaxEncodedPartialSignatureSize() int {

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.

Finding 2 · [MINOR] Use the receivedAt already threaded into the validator instead of a fresh wall-clock read

currentMaxEncodedPartialSignatureSize() takes no arguments and reads the clock itself via EstimatedCurrentEpoch(). Its only caller, validatePartialSignatureMessage, already receives an authoritative receivedAt timestamp — sourced from a single time.Now() at the pubsub entry point — that validateSlotTime uses for its own time-based decision further down the same function. So one message's validation now makes two time-based decisions keyed off two different clock reads. Harmless today, but avoidable, and it forces the new test to sample EstimatedCurrentEpoch() a second time to build fixtures — reintroducing the dual-read flake iurii-ssv already flagged as astronomically rare, rather than eliminating it at the source. This is now the first thing every partial-signature message does on the committee path, where the cost of a second clock read is negligible, but avoidable at no cost by threading the existing timestamp through.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9a706e9.
The selector is now maxEncodedPartialSignatureSizeAt(receivedAt), so one message's validation makes all its time-based decisions from the single time.Now() at the pubsub entry point.

The fork-gate test builds fixtures from a fixed epoch, which also eliminates the dual-read flake iurii flagged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update: 83498dd drops the fork-aware cap entirely (see PR conversation), so the selector is gone.

// spec does not know return "UNDEFINED" and are skipped: divergence on genuinely
// unknown values is intentional (the deprecated Alan roles also stringify to
// "UNDEFINED" in the spec, but they are covered by the explicit list above).
for i := 0; i <= 15; i++ {

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.

Finding 3 · [MINOR] The sibling RunnerRoleFromString round-trip test still can't catch the drift the new sweep will now force

The new lockstep sweep is the genuinely valuable half of item 7 — a role added to the spec now fails CI until someone adds it to message.RunnerRoleToString's switch. But protocol/v2/message has a third hand-written switch, RunnerRoleFromString, whose own drift guard is list-driven only, iterating a hardcoded slice of known roles.

The resulting hole: the spec adds RoleFoo. The new sweep fails; a developer adds a ToString case; the sweep passes. RunnerRoleFromString still has no case for the new role, and its round-trip test's hardcoded list doesn't mention it either, so that also passes — nothing fails anywhere. CommitteeRunnerRoleFromString then rejects the exporter's own emitted string with "unknown role", silently breaking the committee-traces role filter for that role. The PR closes the ToString-vs-ToString gap but leaves the ToString-vs-FromString gap open, and now more likely to be hit, since the new sweep actively nudges developers toward one switch without prompting about the other.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in cdedff8.
TestRunnerRoleFromString_ToString_RoundTrip now runs the same spec-known sweep, so a new spec role must gain a FromString case too or CI fails; the ToString-vs-FromString gap is closed alongside the ToString-vs-ToString one.

if err != nil {
return nil, fmt.Errorf("create committee runner: %w", err)
}
if r == nil {

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.

Finding 4 · [MINOR] The nil guard in createRunner doesn't cover a typed-nil runner, which still panics before the checked assertions

The added guard, if r == nil { return nil, fmt.Errorf(...) }, only catches an interface value that's nil in both type and value. A CreateRunnerFn that returns a typed nil (var cr *runner.CommitteeRunner; return cr, nil — the classic Go footgun, and a more likely mistake than a bare nil interface) sails past it, since the interface then carries a non-nil type word.

Execution reaches r.SetQBFTRoundTimerF(...) two lines later, a method promoted from the embedded *BaseRunner, which dereferences nil to reach the embedded field — a bare nil-pointer-dereference panic, just relocated two lines from the original interface-conversion panic site this change was meant to prevent. The stated goal — avoiding a bare panic from a mismatched CreateRunnerFn — is one step short of covering its own threat model, though likelihood is low since CreateRunnerFn is internal-only.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0a77e30.
The timer wiring moved after the checked type assertions, and each case now checks typed nil explicitly, so no method is called on the runner before its concrete type and non-nilness are established.

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

4 participants