From 934d1a659e21a737545e28281dd873c7cc4ea76e Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 14:30:48 -0400 Subject: [PATCH 1/2] hardening(frost/roast): require accuser quorum for exclusion; overflow can only park The NextAttempt exclusion policy permanently excluded members on unverifiable observer counters: reject/conflict threshold 1, overflow 4, summed across observers and categories so a single byzantine observer could fabricate evidence and grind honest members out of the included set toward ErrAttemptInfeasible -- inverting ROAST's robustness guarantee. Bundle evidence entries are observer-signed claims, not self-incriminating proofs, so the policy now refuses to take permanent action on any accusation that is not group-established: - ExclusionAccuserQuorum(groupSize, threshold) = f+1 distinct accusers, where f = groupSize - threshold is the byzantine tolerance. At f+1 at least one accuser is honest under the protocol's own t-of-n assumption. Real faults reach the quorum naturally because contributions are broadcast and every honest member observes them; fabricated ones cannot. - Accusers are counted distinctly (one per observer per accused per category); claimed count magnitudes are no longer summed into blame. Reject reasons no longer multiply accusers, and categories are tallied independently instead of summing into each other. - Only members of the previous IncludedSet are credible accusers; accusations against members outside the original signer set are ignored. - Established overflow accusations (transport-blamable) now park transiently instead of excluding permanently: transport pressure can never be made self-incriminating, so it may cost an attempt of liveness but not permanence. - Established reject/conflict accusations still exclude permanently. - Sub-quorum claims are ignored entirely so a single byzantine observer cannot even impose parking liveness costs. RFC-21 Layer B is updated to match, including the verifiability roadmap: once the wire format carries self-incriminating proof (the accused's own signed conflicting bytes; a re-checkable invalid contribution), single-proof exclusion becomes sound and the quorum gate can be relaxed per category. New regression coverage: quorum boundary at f vs f+1 for both permanent categories, fabricated-blame grinding across six attempts, count-magnitude fabrication, cross-category non-summing, non-credible accusers, non-original accused, established-overflow park-and- reinstate cycle, and the production-shape quorum pin (100, 51) = 50. Co-Authored-By: Claude Fable 5 --- ...dinator-retry-and-transition-evidence.adoc | 90 +++-- .../roast/multi_coordinator_soak_test.go | 18 +- pkg/frost/roast/next_attempt.go | 307 +++++++++----- .../roast/next_attempt_categories_test.go | 150 ++++++- pkg/frost/roast/next_attempt_test.go | 381 ++++++++++++++++-- 5 files changed, 762 insertions(+), 184 deletions(-) diff --git a/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc index f1ddcd5a73..73289cdf5a 100644 --- a/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc +++ b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc @@ -307,44 +307,70 @@ seed bytes must produce the same legacy `int64` on every honest signer. The bridge is named, isolated, and exhaustively tested so later edits cannot accidentally desynchronise it. -The exclusion policy is: - -. Senders with `OverflowCount >= overflowExclusionThreshold` during the - attempt window are moved to `ExcludedSet` (transport blamable). -. Senders with at least one confirmed reject event for non-transport - reasons are moved to `ExcludedSet` (validation blamable). +The exclusion policy (verifiable-blame revision) starts from one +observation: every evidence entry in the bundle is an observer-signed +*claim*, not a self-incriminating proof. Nothing in an overflow, +reject, or conflict counter lets a third party re-check that the +accused actually misbehaved. A policy that permanently excludes on +unverifiable counters inverts ROAST's robustness guarantee -- a single +byzantine observer could fabricate evidence against honest members +and grind the `IncludedSet` toward `ErrAttemptInfeasible`. Permanent +exclusion therefore requires the accusation to be *established*: +corroborated by a quorum of distinct accusers large enough that, under +the protocol's own t-of-n honesty assumption, at least one accuser is +honest. + +. Accusation tallying: each bundle snapshot is one observer's claim + set. Only observers in the previous attempt's `IncludedSet` are + credible; each observer counts at most once per accused member per + category regardless of the claimed count magnitude; accusations + against members outside the original signer set are ignored. An + accusation is established when at least + `ExclusionAccuserQuorum(originalGroupSize, threshold) + = originalGroupSize - threshold + 1` (that is, `f+1`) distinct + observers make it. A real fault is observed by every honest member + processing the broadcast, so established faults reach the quorum + naturally; fabricated ones cannot, because at most `f` members are + byzantine. +. Established reject or conflict accusations (validation / + equivocation blamable) move the accused to `ExcludedSet` + permanently. The categories are tallied independently and never + summed with each other. +. Established overflow accusations (transport blamable) move the + accused to the *parked* set for one attempt -- never to + `ExcludedSet`. Transport pressure is observable only at the + transport layer and can never be made self-incriminating, so + overflow may cost an attempt of liveness but not permanence. . Senders with deadline-expiry only -- silent peers -- are moved to a *parked* set that the next attempt skips but the attempt after that - retries (to tolerate transient outages). Silence parking is - *strictly transient*: a single attempt's worth of skip, no escalation. - A peer falsely labelled silent because their contribution arrived - late (or because a malicious coordinator censored it) is not - permanently penalised -- they are reinstated by the very next - attempt. Permanent exclusion only follows from overflow or non- - transport reject events, neither of which can fire on a slow-but- - honest peer. + retries (to tolerate transient outages). Parking is *strictly + transient*: a single attempt's worth of skip, no escalation. A peer + falsely labelled silent because their contribution arrived late (or + because a malicious coordinator censored it) is not permanently + penalised -- they are reinstated by the very next attempt. . If `IncludedSet` minus exclusions drops below the threshold `t`, the coordinator returns `ErrAttemptInfeasible` and the session is declared failed for this signer set. -The thresholds are *fixed constants* in the initial design, picked to -be evidently small relative to the per-attempt deadline and the -`expectedMessagesCount*4+1` channel capacity: - -[source,go] ----- -const ( - overflowExclusionThreshold = 4 // overflow events per attempt window - rejectExclusionThreshold = 1 // any confirmed non-transport reject - silenceParkingThreshold = 1 // any deadline expiry parks for 1 attempt -) ----- - -Making them constants up-front means honest signers do not need to -negotiate them. If production telemetry indicates a constant is wrong -for the attempt's wall-clock bound, the change is a routine code -update that ships through Phase 7's manifest gate -- not a runtime -parameter that drift can desynchronise. +The quorum is a *derived constant* of the key-group shape -- for the +production 100-of-51 group it is 50 -- so honest signers do not need +to negotiate it and drift cannot desynchronise it. Sub-quorum claims +are deliberately ignored rather than parked: acting on a single +unverifiable claim would let one byzantine observer cost any honest +member an attempt of liveness at will. + +*Verifiability roadmap.* Permanent exclusion on a *single* piece of +evidence becomes sound once the wire format carries +self-incriminating proof: for conflicts, the accused's own two +operator-signed payloads with identical (attempt, sender) and +different bytes; for rejects, the accused's operator-signed +contribution plus a deterministic validation failure any member can +re-run. When those land, the per-category quorum gate can be relaxed +to proof-verified entries. The cost of the interim quorum policy is +bounded: a fault observed by fewer than `f+1` honest members (for +example a targeted, per-recipient equivocation) is not permanently +excluded and instead burns retry attempts, which the serial-attempt +latency analysis already budgets for. === Layer C: Retry orchestration (M7) diff --git a/pkg/frost/roast/multi_coordinator_soak_test.go b/pkg/frost/roast/multi_coordinator_soak_test.go index cd7dbc9b95..14e5631a39 100644 --- a/pkg/frost/roast/multi_coordinator_soak_test.go +++ b/pkg/frost/roast/multi_coordinator_soak_test.go @@ -265,13 +265,15 @@ func TestSoak_CleanAttemptPreservesIncludedSet(t *testing.T) { } } -func TestSoak_OverflowEvidenceExcludesPermanently(t *testing.T) { +func TestSoak_EstablishedOverflowParksTransiently(t *testing.T) { members := []group.MemberIndex{1, 2, 3, 4, 5} nodes := newSoakHarness(t, members) prev := soakStartingContext(t, members) - // Four observers report 1 overflow each against member 3. - // Total 4 = OverflowExclusionThreshold. + // Four distinct observers report overflow against member 3: + // 4 >= ExclusionAccuserQuorum(5, 3) = 3, so the accusation is + // established -- but transport blame is unverifiable in + // principle, so it parks transiently instead of excluding. overflow := map[group.MemberIndex][]group.MemberIndex{ 1: {3}, 2: {3}, @@ -280,8 +282,14 @@ func TestSoak_OverflowEvidenceExcludesPermanently(t *testing.T) { } next, _ := soakAttempt(t, nodes, prev, nil, overflow, 3) - if !containsMember(next.ExcludedSet, 3) { - t.Fatalf("member 3 must be excluded; got %v", next.ExcludedSet) + if !containsMember(next.TransientlyParked, 3) { + t.Fatalf("member 3 must be parked; got %v", next.TransientlyParked) + } + if containsMember(next.ExcludedSet, 3) { + t.Fatalf( + "overflow must never permanently exclude; got %v", + next.ExcludedSet, + ) } if containsMember(next.IncludedSet, 3) { t.Fatal("member 3 must not be in next IncludedSet") diff --git a/pkg/frost/roast/next_attempt.go b/pkg/frost/roast/next_attempt.go index 4f896c6b23..7890c901bf 100644 --- a/pkg/frost/roast/next_attempt.go +++ b/pkg/frost/roast/next_attempt.go @@ -9,27 +9,41 @@ import ( "github.com/keep-network/keep-core/pkg/protocol/group" ) -// OverflowExclusionThreshold is the per-sender overflow-count -// threshold above which the NextAttempt policy permanently excludes -// the sender (transport-blamable). Matches the constant documented in -// RFC-21 Layer B. -const OverflowExclusionThreshold uint = 4 - -// RejectExclusionThreshold is the per-sender summed-reject-count -// threshold above which the NextAttempt policy permanently -// excludes the sender (validation-blamable). RFC-21 Layer B -// specifies any non-transport reject as sufficient cause, so the -// constant is 1. Reasons are not differentiated by the policy -// today; every reject category counts equally. -const RejectExclusionThreshold uint = 1 - -// ConflictExclusionThreshold is the per-sender summed-conflict- -// count threshold above which the NextAttempt policy permanently -// excludes the sender (equivocation-blamable). A single -// first-write-wins conflict is sufficient evidence: an honest -// peer retransmitting a contribution sends byte-identical bytes, -// so a conflict implies the peer changed its claim mid-attempt. -const ConflictExclusionThreshold uint = 1 +// ExclusionAccuserQuorum returns the number of distinct accusing +// observers required before the NextAttempt policy treats an evidence +// category as group-established against an accused member. +// +// The bundle's evidence entries are observer-signed *claims*, not +// self-incriminating proofs: nothing in an OverflowEntry, RejectEntry, +// or ConflictEntry lets a third party re-check that the accused +// actually misbehaved. A policy that permanently excludes on +// unverifiable claims inverts ROAST's robustness guarantee -- a single +// byzantine observer could fabricate evidence against honest members +// and grind the included set toward ErrAttemptInfeasible. +// +// Under the protocol's own trust assumption (at least threshold of the +// original groupSize members honest), at most +// f = groupSize - threshold members are byzantine. An accusation +// corroborated by at least f+1 distinct observers therefore contains +// at least one honest accuser and can be acted on as established. +// Below the quorum the policy takes no action on the accusation. +// +// A real fault is observed by every honest member processing the +// broadcast (contributions are broadcast, not point-to-point), so +// established faults reach the quorum naturally: with the production +// shape (n=100, t=51) a real fault gathers up to 51 honest accusers +// against a quorum of 50, while the 49 worst-case byzantine members +// can never reach 50 by fabrication. +// +// A zero threshold (used by policy-only tests) or a threshold larger +// than the group yields groupSize+1 -- deliberately unreachable, so no +// accusation-driven action can occur without a real threshold. +func ExclusionAccuserQuorum(groupSize, threshold uint) uint { + if threshold == 0 || threshold > groupSize { + return groupSize + 1 + } + return groupSize - threshold + 1 +} // ErrAttemptInfeasible is returned by NextAttempt when the next // attempt's IncludedSet would drop below the signing threshold t and @@ -51,36 +65,62 @@ var ErrAttemptInfeasible = errors.New( // NextAttempt. NextAttempt does not re-run the signature checks; it // assumes the bundle is verified and only applies the policy. // -// The policy (RFC-21 Layer B): +// The policy (RFC-21 Layer B, verifiable-blame revision): // -// 1. Permanent exclusion (transport-blamable): a sender whose total -// overflow count across the bundle is at least -// OverflowExclusionThreshold is added to ExcludedSet forever. +// 1. Accusation tallying: each bundle snapshot is one observer's +// claim set. Only observers in the previous attempt's IncludedSet +// are credible (they are the members that participated), each +// observer counts at most once per accused member per category +// regardless of the claimed Count, and only accusations against +// members of the original signer set are tallied. An accusation +// is *established* when at least +// ExclusionAccuserQuorum(originalGroupSize, threshold) distinct +// observers make it; below the quorum it is ignored, because a +// bare counter is not verifiable evidence (see +// ExclusionAccuserQuorum). // -// 2. Permanent exclusion (validation-blamable): senders with -// confirmed non-transport reject events. Phase 3.4 does not yet -// track reject events, so this is a no-op; the hook is in place -// for a later phase. +// 2. Permanent exclusion (validation- or equivocation-blamable): +// members with an established reject accusation or an established +// conflict accusation are added to ExcludedSet forever. The +// categories are tallied independently -- reject and conflict +// claims against the same member do not sum. // -// 3. Silence parking (strictly transient): a sender in the -// previous attempt's IncludedSet that does not appear in the -// bundle, and is not permanently excluded, is added to the next -// attempt's TransientlyParked set. The attempt after that -// automatically reinstates them, so a falsely-silenced honest -// peer recovers without intervention. +// 3. Transient parking (transport-blamable): members with an +// established overflow accusation are added to the next attempt's +// TransientlyParked set. Transport pressure is observable only at +// the transport layer and can never be made self-incriminating, +// so overflow can park -- cost one attempt of liveness -- but +// never permanently exclude. // -// 4. Reinstatement: members in the previous attempt's +// 4. Silence parking (strictly transient): a member in the previous +// attempt's IncludedSet that does not appear in the bundle, and +// is not permanently excluded, is added to the next attempt's +// TransientlyParked set. The attempt after that automatically +// reinstates them, so a falsely-silenced honest peer recovers +// without intervention. +// +// 5. Reinstatement: members in the previous attempt's // TransientlyParked set automatically rejoin the next attempt's -// IncludedSet (unless they are now permanently excluded for -// another reason). +// IncludedSet (unless they are now permanently excluded). // -// 5. Infeasibility: if the next attempt's IncludedSet would have +// 6. Infeasibility: if the next attempt's IncludedSet would have // fewer than threshold members, return ErrAttemptInfeasible. // +// Verifiability roadmap: permanent exclusion on a *single* piece of +// evidence becomes sound once the wire format carries +// self-incriminating proof -- for conflicts, the accused's own two +// operator-signed payloads with identical (attempt, sender) and +// different bytes; for rejects, the accused's operator-signed +// contribution plus a deterministic validation failure any member can +// re-run. When those land, the per-category quorum gate can be +// relaxed to proof-verified entries. Until then the quorum is the +// hard gate that keeps fabricated blame from compounding. +// // threshold is the FROST signing threshold t for the key group; it // is constant across attempts within a session. A threshold of zero -// disables the infeasibility check (useful in tests that exercise -// the policy independently from threshold semantics). +// disables the infeasibility check and (via ExclusionAccuserQuorum) +// all accusation-driven action -- useful in tests that exercise the +// silence/parking mechanics independently from threshold semantics. // // The caller is responsible for supplying the DKG group public key // from the same source the previous attempt used (the FFI signer @@ -120,31 +160,50 @@ func computeNextAttempt( threshold uint, dkgGroupPublicKey []byte, ) (attempt.AttemptContext, error) { - // (1) Permanent exclusion from overflow evidence (transport - // blamable). - overflowBlamed := overflowBlamedSenders(bundle, OverflowExclusionThreshold) + // Original signer set persists across transitions as + // IncludedSet ∪ ExcludedSet ∪ TransientlyParked. Its size (not + // the shrinking IncludedSet) anchors the accuser quorum so the + // bar cannot be lowered by excluding members first. + original := newMemberSet() + original.addAll(prev.IncludedSet) + original.addAll(prev.ExcludedSet) + original.addAll(prev.TransientlyParked) - // (2) Permanent exclusion from reject evidence (validation - // blamable). Counts across reasons are summed per-sender. - rejectBlamed := rejectBlamedSenders(bundle, RejectExclusionThreshold) + quorum := ExclusionAccuserQuorum(uint(original.size()), threshold) - // (3) Permanent exclusion from conflict evidence (equivocation - // blamable). First-write-wins disagreements by the same - // sender within an attempt are taken as proof of byzantine - // behaviour. - conflictBlamed := conflictBlamedSenders(bundle, ConflictExclusionThreshold) + credibleObservers := newMemberSet() + credibleObservers.addAll(prev.IncludedSet) + + // (1)+(2) Established reject/conflict accusations: permanent. + rejectBlamed := establishedAccused( + bundle, credibleObservers, original, quorum, snapshotRejectAccusations, + ) + conflictBlamed := establishedAccused( + bundle, credibleObservers, original, quorum, snapshotConflictAccusations, + ) + + // (1)+(3) Established overflow accusations: transient parking only. + overflowParked := establishedAccused( + bundle, credibleObservers, original, quorum, snapshotOverflowAccusations, + ) // Merge into permanent exclusion. exclSet := newMemberSet() exclSet.addAll(prev.ExcludedSet) - exclSet.addAll(overflowBlamed) exclSet.addAll(rejectBlamed) exclSet.addAll(conflictBlamed) - // (3) Silence parking: senders in prev.IncludedSet but not in - // bundle, that we are not now permanently excluding. + // (3)+(4) Parking: established overflow accusations plus senders + // in prev.IncludedSet missing from the bundle -- in both cases + // only when not permanently excluded (exclusion wins). bundleSenders := bundleSenderSet(bundle) parkSet := newMemberSet() + for _, m := range overflowParked { + if exclSet.contains(m) { + continue + } + parkSet.add(m) + } for _, m := range prev.IncludedSet { if bundleSenders.contains(m) { continue @@ -155,22 +214,13 @@ func computeNextAttempt( parkSet.add(m) } - // (4) Original signer set persists across transitions as - // IncludedSet ∪ ExcludedSet ∪ TransientlyParked. Reinstate - // previously parked members by re-including them - // (unless newly permanently excluded -- which they cannot be, - // since they could not have submitted overflow evidence - // this attempt). - original := newMemberSet() - original.addAll(prev.IncludedSet) - original.addAll(prev.ExcludedSet) - original.addAll(prev.TransientlyParked) - + // (5) Reinstate previously parked members by re-including them + // (unless newly permanently excluded or re-parked). included := original.sorted() included = filterOut(included, exclSet) included = filterOut(included, parkSet) - // (5) Infeasibility check. + // (6) Infeasibility check. if threshold > 0 && uint(len(included)) < threshold { return attempt.AttemptContext{}, fmt.Errorf( "%w: %d eligible, threshold %d", @@ -203,62 +253,109 @@ func computeNextAttempt( return next, nil } -// overflowBlamedSenders returns the senders whose total overflow -// count across every snapshot in the bundle is at least the -// supplied threshold. Counts are summed (not averaged) so a sender -// hitting the threshold from one observer alone is sufficient. -func overflowBlamedSenders( - bundle *TransitionMessage, - threshold uint, +// snapshotAccusations extracts the members one observer's snapshot +// accuses in a single evidence category. Implementations must return +// each accused member at most once per snapshot. +type snapshotAccusations func(snapshot *LocalEvidenceSnapshot) []group.MemberIndex + +// snapshotOverflowAccusations returns the members the snapshot accuses +// of transport overflow. The claimed Count magnitude is intentionally +// ignored beyond existence: an observer is one accuser regardless of +// how large a counter it reports. +func snapshotOverflowAccusations( + snapshot *LocalEvidenceSnapshot, ) []group.MemberIndex { - counts := map[group.MemberIndex]uint{} - for i := range bundle.Bundle { - for _, entry := range bundle.Bundle[i].Overflows { - counts[entry.Sender] += entry.Count + out := make([]group.MemberIndex, 0, len(snapshot.Overflows)) + for _, entry := range snapshot.Overflows { + if entry.Count == 0 { + continue } + out = append(out, entry.Sender) } - return blamedSenders(counts, threshold) + return out } -// rejectBlamedSenders returns the senders whose total reject count -// (summed across all observers AND across all rejection reasons) -// meets the supplied threshold. Reasons are not differentiated at -// the policy layer; the recorder bounds per-reason quotas -// separately so a peer cannot spam one reason to mask another. -func rejectBlamedSenders( - bundle *TransitionMessage, - threshold uint, +// snapshotRejectAccusations returns the members the snapshot accuses +// of validation rejects, deduplicated across reasons: one observer +// reporting three reject reasons against the same member is still a +// single accuser for that member. +func snapshotRejectAccusations( + snapshot *LocalEvidenceSnapshot, ) []group.MemberIndex { - counts := map[group.MemberIndex]uint{} - for i := range bundle.Bundle { - for _, entry := range bundle.Bundle[i].Rejects { - counts[entry.Sender] += entry.Count + seen := newMemberSet() + out := make([]group.MemberIndex, 0, len(snapshot.Rejects)) + for _, entry := range snapshot.Rejects { + if entry.Count == 0 { + continue + } + if seen.contains(entry.Sender) { + continue } + seen.add(entry.Sender) + out = append(out, entry.Sender) } - return blamedSenders(counts, threshold) + return out } -// conflictBlamedSenders returns the senders whose total -// first-write-wins-conflict count across the bundle meets the -// supplied threshold. A single conflict suffices under the -// default ConflictExclusionThreshold (= 1) because an honest peer -// retransmitting always sends byte-identical bytes. -func conflictBlamedSenders( +// snapshotConflictAccusations returns the members the snapshot accuses +// of first-write-wins conflicts. +func snapshotConflictAccusations( + snapshot *LocalEvidenceSnapshot, +) []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(snapshot.Conflicts)) + for _, entry := range snapshot.Conflicts { + if entry.Count == 0 { + continue + } + out = append(out, entry.Sender) + } + return out +} + +// establishedAccused tallies one evidence category across the bundle +// by *distinct credible accuser* and returns the deterministically +// sorted accused members whose accuser count meets the quorum. +// +// Bundle validity (one snapshot per sender, ascending) is enforced by +// TransitionMessage.Validate; the memberSet additionally makes +// re-counting harmless. +func establishedAccused( bundle *TransitionMessage, - threshold uint, + credibleObservers *memberSet, + original *memberSet, + quorum uint, + accusations snapshotAccusations, ) []group.MemberIndex { - counts := map[group.MemberIndex]uint{} + accusersByAccused := map[group.MemberIndex]*memberSet{} for i := range bundle.Bundle { - for _, entry := range bundle.Bundle[i].Conflicts { - counts[entry.Sender] += entry.Count + snapshot := &bundle.Bundle[i] + observer := snapshot.SenderID() + if !credibleObservers.contains(observer) { + continue + } + for _, accused := range accusations(snapshot) { + if !original.contains(accused) { + continue + } + accusers, ok := accusersByAccused[accused] + if !ok { + accusers = newMemberSet() + accusersByAccused[accused] = accusers + } + accusers.add(observer) } } - return blamedSenders(counts, threshold) + + counts := map[group.MemberIndex]uint{} + for accused, accusers := range accusersByAccused { + counts[accused] = uint(accusers.size()) + } + return blamedSenders(counts, quorum) } // blamedSenders extracts the deterministically-sorted list of // senders whose accumulated count meets the threshold. Factored -// out so the three category helpers share the same canonicalisation. +// out so the category helpers share the same canonicalisation. func blamedSenders( counts map[group.MemberIndex]uint, threshold uint, @@ -306,6 +403,8 @@ func (s *memberSet) addAll(members []group.MemberIndex) { } } +func (s *memberSet) size() int { return len(s.m) } + func (s *memberSet) sorted() []group.MemberIndex { out := make([]group.MemberIndex, 0, len(s.m)) for m := range s.m { diff --git a/pkg/frost/roast/next_attempt_categories_test.go b/pkg/frost/roast/next_attempt_categories_test.go index 0729ae13e6..d81faa0e0a 100644 --- a/pkg/frost/roast/next_attempt_categories_test.go +++ b/pkg/frost/roast/next_attempt_categories_test.go @@ -7,10 +7,13 @@ import ( "github.com/keep-network/keep-core/pkg/protocol/group" ) -// buildBundleWithCategories constructs a TransitionMessage where each -// observer contributes the same per-(category, sender) evidence -- one -// reject reason and one conflict per "blamed" sender per observer. -// Useful for verifying the cross-observer summing behaviour. +// buildBundleWithCategories constructs a TransitionMessage where +// every member of prev.IncludedSet acts as an observer contributing +// the same per-(category, sender) evidence -- one reject reason and +// one conflict per "blamed" sender per observer. With the full +// included set accusing, every accusation is established (accusers = +// n >= quorum), so these bundles exercise the established-blame +// paths. func buildBundleWithCategories( t *testing.T, prev attempt.AttemptContext, @@ -18,9 +21,36 @@ func buildBundleWithCategories( conflicts []group.MemberIndex, ) *TransitionMessage { t.Helper() + return buildBundleWithCategoriesFromAccusers( + t, prev, prev.IncludedSet, rejects, conflicts, + ) +} + +// buildBundleWithCategoriesFromAccusers is buildBundleWithCategories +// with an explicit accuser subset: every member of prev.IncludedSet +// submits a snapshot, but only the listed accusers carry the +// reject/conflict evidence. Lets tests pin the accuser-quorum +// boundary precisely. +func buildBundleWithCategoriesFromAccusers( + t *testing.T, + prev attempt.AttemptContext, + accusers []group.MemberIndex, + rejects map[group.MemberIndex][]string, + conflicts []group.MemberIndex, +) *TransitionMessage { + t.Helper() + accuserSet := newMemberSet() + accuserSet.addAll(accusers) prevHash := prev.Hash() bundle := make([]LocalEvidenceSnapshot, 0, len(prev.IncludedSet)) for _, sender := range prev.IncludedSet { + if !accuserSet.contains(sender) { + bundle = append(bundle, LocalEvidenceSnapshot{ + SenderIDValue: uint32(sender), + AttemptContextHash: append([]byte{}, prevHash[:]...), + }) + continue + } snap := LocalEvidenceSnapshot{ SenderIDValue: uint32(sender), AttemptContextHash: append([]byte{}, prevHash[:]...), @@ -67,12 +97,12 @@ func sortRejectEntriesForTest(entries []RejectEntry) { } } -func TestNextAttempt_SingleRejectExcludesPermanently(t *testing.T) { +func TestNextAttempt_EstablishedRejectExcludesPermanently(t *testing.T) { f := newNextAttemptFixture() prev := f.prev(t) - // Every observer reports one reject against sender 3 → total - // count is len(IncludedSet) = 5 across observers, summed by - // rejectBlamedSenders. + // Every observer reports one reject against sender 3: five + // distinct accusers >= quorum (3), so the accusation is + // established and the exclusion is permanent. bundle := buildBundleWithCategories( t, prev, @@ -92,7 +122,7 @@ func TestNextAttempt_SingleRejectExcludesPermanently(t *testing.T) { } } -func TestNextAttempt_SingleConflictExcludesPermanently(t *testing.T) { +func TestNextAttempt_EstablishedConflictExcludesPermanently(t *testing.T) { f := newNextAttemptFixture() prev := f.prev(t) bundle := buildBundleWithCategories( @@ -108,10 +138,81 @@ func TestNextAttempt_SingleConflictExcludesPermanently(t *testing.T) { } if !memberSliceContains(next.ExcludedSet, 3) { t.Fatalf( - "sender 3 must be excluded after a single conflict; got %v", + "sender 3 must be excluded on an established conflict; got %v", + next.ExcludedSet, + ) + } +} + +func TestNextAttempt_SubQuorumRejectAndConflictDoNotExclude(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + // Only two accusers (= f, the tolerated byzantine count) report + // the reject and the conflict: below the quorum (3), so no + // action -- the accused stays included and is not parked. + bundle := buildBundleWithCategoriesFromAccusers( + t, + prev, + []group.MemberIndex{1, 2}, + map[group.MemberIndex][]string{3: {"validation_gate_rejected"}}, + []group.MemberIndex{3}, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "sub-quorum accusations must not exclude; got %v", + next.ExcludedSet, + ) + } + if memberSliceContains(next.TransientlyParked, 3) { + t.Fatalf( + "sub-quorum accusations must not park; got %v", + next.TransientlyParked, + ) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 must remain included; got %v", next.IncludedSet) + } +} + +func TestNextAttempt_MultipleRejectReasonsAreOneAccuser(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + // Two observers each report three distinct reject reasons against + // sender 3. Reasons must not multiply accusers: 2 accusers < + // quorum (3), no action. Under summed counting this would have + // been 6 >= 1 and an immediate permanent exclusion. + bundle := buildBundleWithCategoriesFromAccusers( + t, + prev, + []group.MemberIndex{1, 2}, + map[group.MemberIndex][]string{ + 3: { + "validation_gate_rejected", + "share_verification_failed", + "commitment_malformed", + }, + }, + nil, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "multiple reasons from few observers must not exclude; got %v", next.ExcludedSet, ) } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 must remain included; got %v", next.IncludedSet) + } } func TestNextAttempt_RejectAndConflictBothExclude(t *testing.T) { @@ -149,17 +250,28 @@ func TestNextAttempt_EmptyRejectsAndConflicts_DoNotExclude(t *testing.T) { } } -func TestRejectAndConflictThresholds_MatchRFC(t *testing.T) { - if RejectExclusionThreshold != 1 { - t.Fatalf( - "RFC-21 Layer B specifies reject threshold = 1; constant is %d", - RejectExclusionThreshold, - ) +func TestNextAttempt_ExactQuorumOfHonestObserversExcludes(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + // Exactly quorum (3 = f+1) accusers: established. This is the + // boundary at which at least one accuser is guaranteed honest + // under the t-of-n assumption. + bundle := buildBundleWithCategoriesFromAccusers( + t, + prev, + []group.MemberIndex{1, 2, 4}, + map[group.MemberIndex][]string{3: {"validation_gate_rejected"}}, + nil, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) } - if ConflictExclusionThreshold != 1 { + if !memberSliceContains(next.ExcludedSet, 3) { t.Fatalf( - "single conflict is sufficient evidence; constant is %d", - ConflictExclusionThreshold, + "exact quorum must establish the exclusion; got %v", + next.ExcludedSet, ) } } diff --git a/pkg/frost/roast/next_attempt_test.go b/pkg/frost/roast/next_attempt_test.go index 47972dedd0..0f115295cd 100644 --- a/pkg/frost/roast/next_attempt_test.go +++ b/pkg/frost/roast/next_attempt_test.go @@ -10,13 +10,17 @@ import ( // nextAttemptFixture builds a previous AttemptContext and an // associated TransitionMessage for the NextAttempt-policy tests. -// Members 1..5 included; no excluded; no parking. By default every -// member submits a snapshot with no overflow events. +// Members 1..5 included; no excluded; no parking; threshold 3 (so +// ExclusionAccuserQuorum(5, 3) = 3). By default every member submits +// a snapshot with no evidence entries. The overflows/rejects/conflicts +// maps are keyed by observer, then by accused member. type nextAttemptFixture struct { included []group.MemberIndex excluded []group.MemberIndex parked []group.MemberIndex overflows map[group.MemberIndex]map[group.MemberIndex]uint + rejects map[group.MemberIndex]map[group.MemberIndex]uint + conflicts map[group.MemberIndex]map[group.MemberIndex]uint bundleSenders []group.MemberIndex // override default = included attemptNumber uint32 dkgGroupPublicKey []byte @@ -31,6 +35,8 @@ func newNextAttemptFixture() *nextAttemptFixture { excluded: nil, parked: nil, overflows: map[group.MemberIndex]map[group.MemberIndex]uint{}, + rejects: map[group.MemberIndex]map[group.MemberIndex]uint{}, + conflicts: map[group.MemberIndex]map[group.MemberIndex]uint{}, bundleSenders: nil, attemptNumber: 0, dkgGroupPublicKey: []byte{0x01, 0x02, 0x03}, @@ -79,6 +85,26 @@ func (f *nextAttemptFixture) bundle(t *testing.T) *TransitionMessage { } snap.Overflows = sortedOverflowEntries(ov) } + if entries, ok := f.rejects[s]; ok { + rj := make([]RejectEntry, 0, len(entries)) + for sender, count := range entries { + rj = append(rj, RejectEntry{ + Sender: sender, + Reason: "validation_gate_rejected", + Count: count, + }) + } + sortRejectEntriesForTest(rj) + snap.Rejects = rj + } + if entries, ok := f.conflicts[s]; ok { + cf := make([]ConflictEntry, 0, len(entries)) + for sender, count := range entries { + cf = append(cf, ConflictEntry{Sender: sender, Count: count}) + } + sortConflictEntriesForTest(cf) + snap.Conflicts = cf + } bundle = append(bundle, snap) } return &TransitionMessage{ @@ -99,6 +125,14 @@ func sortedOverflowEntries(in []OverflowEntry) []OverflowEntry { return out } +func sortConflictEntriesForTest(entries []ConflictEntry) { + for i := 1; i < len(entries); i++ { + for j := i; j > 0 && entries[j].Sender < entries[j-1].Sender; j-- { + entries[j], entries[j-1] = entries[j-1], entries[j] + } + } +} + func TestNextAttempt_NoEvidenceProducesIdenticalIncludedSet(t *testing.T) { f := newNextAttemptFixture() prev := f.prev(t) @@ -127,10 +161,12 @@ func TestNextAttempt_NoEvidenceProducesIdenticalIncludedSet(t *testing.T) { } } -func TestNextAttempt_OverflowThresholdTriggersPermanentExclusion(t *testing.T) { +func TestNextAttempt_EstablishedOverflowParksTransiently(t *testing.T) { f := newNextAttemptFixture() - // Members 2..5 all report 1 overflow event each against sender 3. - // 4 observers × 1 event = 4 total = OverflowExclusionThreshold. + // Members 2..5 all report overflow against sender 3: 4 distinct + // accusers >= quorum (3). Transport blame is unverifiable in + // principle, so even an established overflow accusation parks + // transiently and never permanently excludes. for observer := group.MemberIndex(2); observer <= 5; observer++ { f.overflows[observer] = map[group.MemberIndex]uint{3: 1} } @@ -139,18 +175,71 @@ func TestNextAttempt_OverflowThresholdTriggersPermanentExclusion(t *testing.T) { t.Fatalf("compute: %v", err) } if memberSliceContains(next.IncludedSet, 3) { - t.Fatalf("sender 3 should be excluded; got included %v", next.IncludedSet) + t.Fatalf("sender 3 should be parked; got included %v", next.IncludedSet) } - if !memberSliceContains(next.ExcludedSet, 3) { - t.Fatalf("sender 3 should appear in excluded set; got %v", next.ExcludedSet) + if !memberSliceContains(next.TransientlyParked, 3) { + t.Fatalf( + "sender 3 should appear in parked set; got %v", + next.TransientlyParked, + ) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "overflow must never permanently exclude; got excluded %v", + next.ExcludedSet, + ) } } -func TestNextAttempt_OverflowBelowThresholdDoesNotExclude(t *testing.T) { +func TestNextAttempt_EstablishedOverflowParkIsTransient(t *testing.T) { f := newNextAttemptFixture() - // Only 1 observer reports 1 overflow event against sender 3. - // 1 < threshold (4). - f.overflows[2] = map[group.MemberIndex]uint{3: 1} + // Attempt N: quorum overflow accusation against member 3 parks it. + for observer := group.MemberIndex(2); observer <= 5; observer++ { + f.overflows[observer] = map[group.MemberIndex]uint{3: 1} + } + attemptN1, err := computeNextAttempt( + f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey, + ) + if err != nil { + t.Fatalf("N -> N+1: %v", err) + } + if !memberSliceContains(attemptN1.TransientlyParked, 3) { + t.Fatalf("N+1 must park member 3; got %v", attemptN1.TransientlyParked) + } + + // Attempt N+1: parked member 3 cannot submit; no new accusations. + attemptN1Hash := attemptN1.Hash() + bundleN1 := &TransitionMessage{ + AttemptContextHash: append([]byte{}, attemptN1Hash[:]...), + CoordinatorIDValue: 1, + Bundle: []LocalEvidenceSnapshot{ + {SenderIDValue: 1, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 2, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 4, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 5, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + }, + } + attemptN2, err := computeNextAttempt( + attemptN1, bundleN1, f.threshold, f.dkgGroupPublicKey, + ) + if err != nil { + t.Fatalf("N+1 -> N+2: %v", err) + } + if !memberSliceContains(attemptN2.IncludedSet, 3) { + t.Fatalf( + "N+2 must reinstate overflow-parked member 3; got included %v", + attemptN2.IncludedSet, + ) + } +} + +func TestNextAttempt_SubQuorumOverflowHasNoEffect(t *testing.T) { + f := newNextAttemptFixture() + // Two observers (< quorum 3) report overflow against sender 3, + // with large claimed counts. Counts must not be summed into + // blame: an accusation below the accuser quorum is ignored. + f.overflows[2] = map[group.MemberIndex]uint{3: 100} + f.overflows[4] = map[group.MemberIndex]uint{3: 100} next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) if err != nil { t.Fatalf("compute: %v", err) @@ -158,6 +247,12 @@ func TestNextAttempt_OverflowBelowThresholdDoesNotExclude(t *testing.T) { if !memberSliceContains(next.IncludedSet, 3) { t.Fatalf("sender 3 should remain included; got %v", next.IncludedSet) } + if memberSliceContains(next.TransientlyParked, 3) { + t.Fatal("sub-quorum overflow must not park") + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatal("sub-quorum overflow must not exclude") + } } func TestNextAttempt_SilentMemberIsParkedTransiently(t *testing.T) { @@ -326,21 +421,246 @@ func TestNextAttempt_ThresholdZeroDisablesInfeasibilityCheck(t *testing.T) { } } -func TestNextAttempt_OverflowFromMultipleObserversIsSummed(t *testing.T) { +func TestNextAttempt_SingleObserverCountMagnitudeIsNotBlame(t *testing.T) { f := newNextAttemptFixture() - // 2 observers each report 2 overflow events = total 4 = threshold. - f.overflows[1] = map[group.MemberIndex]uint{3: 2} - f.overflows[2] = map[group.MemberIndex]uint{3: 2} + // One observer fabricating arbitrarily large counts in every + // category is still a single accuser, far below the quorum (3): + // the accused member must be completely unaffected. This is the + // counted-blame fabrication vector the quorum policy closes. + f.overflows[5] = map[group.MemberIndex]uint{3: 1000} + f.rejects[5] = map[group.MemberIndex]uint{3: 1000} + f.conflicts[5] = map[group.MemberIndex]uint{3: 1000} next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) if err != nil { t.Fatalf("compute: %v", err) } - if !memberSliceContains(next.ExcludedSet, 3) { + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 must remain included; got %v", next.IncludedSet) + } + if memberSliceContains(next.TransientlyParked, 3) { + t.Fatal("fabricated counts must not park") + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatal("fabricated counts must not exclude") + } +} + +func TestNextAttempt_QuorumBoundaryForRejectAndConflict(t *testing.T) { + // n=5, t=3 => f = 2 byzantine tolerated => quorum = 3 accusers. + for _, tc := range []struct { + name string + category func(f *nextAttemptFixture) map[group.MemberIndex]map[group.MemberIndex]uint + }{ + { + name: "rejects", + category: func(f *nextAttemptFixture) map[group.MemberIndex]map[group.MemberIndex]uint { + return f.rejects + }, + }, + { + name: "conflicts", + category: func(f *nextAttemptFixture) map[group.MemberIndex]map[group.MemberIndex]uint { + return f.conflicts + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + // f accusers (= 2, the max byzantine count): no action. + fixtureAtF := newNextAttemptFixture() + tc.category(fixtureAtF)[1] = map[group.MemberIndex]uint{3: 1} + tc.category(fixtureAtF)[2] = map[group.MemberIndex]uint{3: 1} + next, err := computeNextAttempt( + fixtureAtF.prev(t), + fixtureAtF.bundle(t), + fixtureAtF.threshold, + fixtureAtF.dkgGroupPublicKey, + ) + if err != nil { + t.Fatalf("compute at f accusers: %v", err) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf( + "f accusers must not move sender 3; got included %v", + next.IncludedSet, + ) + } + + // f+1 accusers (= 3): permanent exclusion. + fixtureAtQuorum := newNextAttemptFixture() + tc.category(fixtureAtQuorum)[1] = map[group.MemberIndex]uint{3: 1} + tc.category(fixtureAtQuorum)[2] = map[group.MemberIndex]uint{3: 1} + tc.category(fixtureAtQuorum)[4] = map[group.MemberIndex]uint{3: 1} + next, err = computeNextAttempt( + fixtureAtQuorum.prev(t), + fixtureAtQuorum.bundle(t), + fixtureAtQuorum.threshold, + fixtureAtQuorum.dkgGroupPublicKey, + ) + if err != nil { + t.Fatalf("compute at quorum: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "quorum accusers must exclude sender 3; got %v", + next.ExcludedSet, + ) + } + if memberSliceContains(next.IncludedSet, 3) { + t.Fatal("excluded sender 3 must not remain included") + } + }) + } +} + +func TestNextAttempt_CrossCategoryAccusationsDoNotSum(t *testing.T) { + f := newNextAttemptFixture() + // Two reject accusers plus two different conflict accusers against + // sender 3: each category is below the quorum (3) on its own. + // Categories must be tallied independently -- four observers + // spread across categories are not an established accusation. + f.rejects[1] = map[group.MemberIndex]uint{3: 1} + f.rejects[2] = map[group.MemberIndex]uint{3: 1} + f.conflicts[4] = map[group.MemberIndex]uint{3: 1} + f.conflicts[5] = map[group.MemberIndex]uint{3: 1} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "cross-category sub-quorum claims must not exclude; got %v", + next.ExcludedSet, + ) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 must remain included; got %v", next.IncludedSet) + } +} + +func TestNextAttempt_FabricatedBlameCannotGrindHonestMembers(t *testing.T) { + // Regression for the counted-blame grinding vector: a single + // byzantine member fabricates maximal evidence against every + // honest member on every attempt. Under the quorum policy the + // honest members must never be excluded or parked, and the + // session must never become infeasible. + f := newNextAttemptFixture() + byzantine := group.MemberIndex(5) + honest := []group.MemberIndex{1, 2, 3, 4} + + prev := f.prev(t) + for attemptIndex := 0; attemptIndex < 6; attemptIndex++ { + accusations := map[group.MemberIndex]uint{} + for _, target := range honest { + accusations[target] = 1000 + } + prevHash := prev.Hash() + bundle := make([]LocalEvidenceSnapshot, 0, len(prev.IncludedSet)) + for _, sender := range prev.IncludedSet { + snap := LocalEvidenceSnapshot{ + SenderIDValue: uint32(sender), + AttemptContextHash: append([]byte{}, prevHash[:]...), + } + if sender == byzantine { + overflows := make([]OverflowEntry, 0, len(honest)) + rejects := make([]RejectEntry, 0, len(honest)) + conflicts := make([]ConflictEntry, 0, len(honest)) + for _, target := range honest { + overflows = append(overflows, OverflowEntry{ + Sender: target, Count: accusations[target], + }) + rejects = append(rejects, RejectEntry{ + Sender: target, + Reason: "validation_gate_rejected", + Count: accusations[target], + }) + conflicts = append(conflicts, ConflictEntry{ + Sender: target, Count: accusations[target], + }) + } + snap.Overflows = overflows + snap.Rejects = rejects + snap.Conflicts = conflicts + } + bundle = append(bundle, snap) + } + msg := &TransitionMessage{ + AttemptContextHash: append([]byte{}, prevHash[:]...), + CoordinatorIDValue: 1, + Bundle: bundle, + } + + next, err := computeNextAttempt(prev, msg, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("attempt %d: %v", attemptIndex, err) + } + for _, member := range honest { + if !memberSliceContains(next.IncludedSet, member) { + t.Fatalf( + "attempt %d: honest member %d ground out; included %v, excluded %v, parked %v", + attemptIndex, + member, + next.IncludedSet, + next.ExcludedSet, + next.TransientlyParked, + ) + } + } + prev = next + } +} + +func TestNextAttempt_NonCredibleAccusersAreIgnored(t *testing.T) { + f := newNextAttemptFixture() + // Members 1..5 included; member 6 is outside the original signer + // set and member 7 likewise. Their snapshots appear in the bundle + // (a byzantine coordinator could aggregate anything), each + // accusing sender 3 of conflicts, alongside two credible + // accusers. Total claims = 4, but credible accusers = 2 < quorum: + // no action. + f.bundleSenders = []group.MemberIndex{1, 2, 3, 4, 5, 6, 7} + f.conflicts[1] = map[group.MemberIndex]uint{3: 1} + f.conflicts[2] = map[group.MemberIndex]uint{3: 1} + f.conflicts[6] = map[group.MemberIndex]uint{3: 1} + f.conflicts[7] = map[group.MemberIndex]uint{3: 1} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "non-credible accusers must not contribute to exclusion; got %v", + next.ExcludedSet, + ) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 must remain included; got %v", next.IncludedSet) + } +} + +func TestNextAttempt_AccusationsAgainstNonOriginalMembersIgnored(t *testing.T) { + f := newNextAttemptFixture() + // Quorum-many accusers blame member 9, which is not part of the + // original signer set. The policy must not act on it: the next + // context's excluded/parked sets stay within the original set. + f.conflicts[1] = map[group.MemberIndex]uint{9: 1} + f.conflicts[2] = map[group.MemberIndex]uint{9: 1} + f.conflicts[4] = map[group.MemberIndex]uint{9: 1} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.ExcludedSet, 9) { t.Fatalf( - "sender 3 should be excluded by summed overflow; got %v", + "non-original member must not enter excluded set; got %v", next.ExcludedSet, ) } + if memberSliceContains(next.TransientlyParked, 9) { + t.Fatalf( + "non-original member must not enter parked set; got %v", + next.TransientlyParked, + ) + } } func TestNextAttempt_NilBundleRejected(t *testing.T) { @@ -361,12 +681,25 @@ func TestNextAttempt_UnknownHandleRejected(t *testing.T) { } } -func TestOverflowExclusionThreshold_MatchesRFC(t *testing.T) { - if OverflowExclusionThreshold != 4 { - t.Fatalf( - "RFC-21 Layer B specifies overflow threshold = 4; constant is %d", - OverflowExclusionThreshold, - ) +func TestExclusionAccuserQuorum_MatchesRFC(t *testing.T) { + // Production shape: n=100, t=51 => f = 49 => quorum 50. The 49 + // worst-case byzantine members can never fabricate a quorum; the + // 51 honest members establish any fault they all observe. + if got := ExclusionAccuserQuorum(100, 51); got != 50 { + t.Fatalf("quorum(100, 51) = %d, want 50", got) + } + // Unit-test shape used by the fixtures in this file. + if got := ExclusionAccuserQuorum(5, 3); got != 3 { + t.Fatalf("quorum(5, 3) = %d, want 3", got) + } + // Zero threshold (policy-only test seam): quorum is deliberately + // unreachable so no accusation-driven action can occur. + if got := ExclusionAccuserQuorum(5, 0); got != 6 { + t.Fatalf("quorum(5, 0) = %d, want unreachable 6", got) + } + // Degenerate threshold larger than the group: also unreachable. + if got := ExclusionAccuserQuorum(3, 5); got != 4 { + t.Fatalf("quorum(3, 5) = %d, want unreachable 4", got) } } From e0ce74bea867edc1ad00b56421ec53ee1598cef9 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 17:49:28 -0400 Subject: [PATCH 2/2] docs(frost/roast): document quorum-gate boundary behaviour and parking scope Review follow-ups on the verifiable-blame revision: - Fix inverted group-shape wording (51-of-100, not 100-of-51). - State explicitly that near the assumption boundary the accuser quorum needs all but 2t-n-1 honest observers (50 of 51 at the production shape), so in the high-f regime the gate is a fabrication firewall rather than a working exclusion mechanism; proof-carrying blame restores exclusion there. - Document the n-of-n edge of ExclusionAccuserQuorum (quorum 1, consistent with f = 0 under that shape's own assumption). - Make precise that silence parking keys on bundle absence: a member that submits its evidence snapshot while withholding its signing contribution is not parked by this layer; that cost is bounded by the Annex B retry budget until t-of-included finalize. Co-Authored-By: Claude Fable 5 --- ...ordinator-retry-and-transition-evidence.adoc | 17 +++++++++++++++-- pkg/frost/roast/next_attempt.go | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc index 73289cdf5a..c970037a2a 100644 --- a/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc +++ b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc @@ -348,12 +348,18 @@ honest. falsely labelled silent because their contribution arrived late (or because a malicious coordinator censored it) is not permanently penalised -- they are reinstated by the very next attempt. + Silence is detected as *bundle absence* (the member submitted no + evidence snapshot for the transition). A member that submits its + snapshot while withholding its signing contribution is therefore + not parked by this policy: signing-silence is invisible to the + transition layer, costs the attempt, and is bounded only by the + retry budget (Annex B) until t-of-included finalize lands. . If `IncludedSet` minus exclusions drops below the threshold `t`, the coordinator returns `ErrAttemptInfeasible` and the session is declared failed for this signer set. The quorum is a *derived constant* of the key-group shape -- for the -production 100-of-51 group it is 50 -- so honest signers do not need +production 51-of-100 group it is 50 -- so honest signers do not need to negotiate it and drift cannot desynchronise it. Sub-quorum claims are deliberately ignored rather than parked: acting on a single unverifiable claim would let one byzantine observer cost any honest @@ -370,7 +376,14 @@ to proof-verified entries. The cost of the interim quorum policy is bounded: a fault observed by fewer than `f+1` honest members (for example a targeted, per-recipient equivocation) is not permanently excluded and instead burns retry attempts, which the serial-attempt -latency analysis already budgets for. +latency analysis already budgets for. Near the assumption boundary +the gate is intentionally demanding -- at worst-case `f` only `t` +honest observers exist, so establishment needs all but `2t-n-1` of +them (50 of 51 at the production shape) to have observed the fault +and landed snapshots in the bundle. In that regime the quorum acts +as a fabrication firewall rather than a working exclusion mechanism; +restoring per-category exclusion under heavy attack is exactly what +proof-carrying blame is for. === Layer C: Retry orchestration (M7) diff --git a/pkg/frost/roast/next_attempt.go b/pkg/frost/roast/next_attempt.go index 7890c901bf..16edb4144a 100644 --- a/pkg/frost/roast/next_attempt.go +++ b/pkg/frost/roast/next_attempt.go @@ -35,9 +35,23 @@ import ( // against a quorum of 50, while the 49 worst-case byzantine members // can never reach 50 by fabrication. // +// The gate is intentionally demanding near the assumption boundary: +// at worst-case f = groupSize - threshold, only threshold honest +// observers exist, so establishment needs all but 2t-n-1 of them +// (50 of 51 at the production shape) to have observed the fault and +// landed snapshots in the bundle. In that regime the quorum acts as +// a fabrication firewall rather than a working exclusion mechanism; +// real faults that fewer honest members observe burn retry attempts +// instead (budgeted in RFC-21 Annex B), and proof-carrying blame +// (see the roadmap in NextAttempt) is what restores per-category +// exclusion there. +// // A zero threshold (used by policy-only tests) or a threshold larger // than the group yields groupSize+1 -- deliberately unreachable, so no -// accusation-driven action can occur without a real threshold. +// accusation-driven action can occur without a real threshold. A +// threshold equal to groupSize (n-of-n) yields a quorum of 1: under +// that shape's own assumption every member is honest (f = 0), so a +// single accusation is established by definition. func ExclusionAccuserQuorum(groupSize, threshold uint) uint { if threshold == 0 || threshold > groupSize { return groupSize + 1