From ba612b2987501c3ec1ebc03bc8e9662d116926fc Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 22 May 2026 22:53:34 -0500 Subject: [PATCH] feat(frost/roast): close M4 -- reject + conflict evidence categories Closes the M4 gap from the original PR #3866 review by adding the two evidence categories the RFC-21 Phase-2 work left as future work: validation-rejection evidence and first-write-wins-conflict evidence. With this PR, the NextAttempt policy can permanently exclude misbehaving peers on all four ROAST blame channels -- transport-overflow, validation-reject, equivocation-conflict, and silence -- instead of just overflow + silence. Why this matters: a peer that only sends malformed messages (validation rejects, never overflows the channel) was previously indistinguishable from a silent peer. The transient silence- parking policy would bench-and-reinstate them indefinitely, never permanently excluding the malicious behaviour. Same for a peer equivocating mid-attempt: the existing first-write-wins assembly correctly dropped the conflicting retransmission but only logged the event -- the bundle carried no structured evidence the coordinator's policy could act on. * pkg/frost/roast/attempt/evidence_recorder.go - EvidenceRecorder interface gains RecordReject(sender, reason) and RecordConflict(sender). - RejectQuotaDefault = 8, ConflictQuotaDefault = 4 (matches categoryQuota in RFC-21 Layer A). - Evidence struct extended with Rejects (map[MemberIndex][]RejectEntry: per-(sender, reason)) and Conflicts (map[MemberIndex]uint). - boundedRecorder: per-reason quota counter keeps each reason bucket independent so a peer cannot saturate one reason to mask another. Conflicts counter saturates at the conflict quota. - noOpRecorder: every category discards. - NewBoundedRecorderWithQuotas(overflow, reject, conflict) constructor for tests; existing NewBoundedRecorderWithQuota preserved for backward compat (defaults reject + conflict quotas). * pkg/frost/roast/transition_message.go - RejectEntry (Sender + Reason + Count) and ConflictEntry (Sender + Count) wire types added. - LocalEvidenceSnapshot gains Rejects []RejectEntry and Conflicts []ConflictEntry, both omitempty. - NewLocalEvidenceSnapshot canonicalises into sorted slices: rejects ascending by Sender then by Reason; conflicts ascending by Sender. - Evidence() reconstructs the map form for downstream consumption. - Validate() enforces sorted-ascending invariants on both new slices. * pkg/frost/roast/next_attempt.go - RejectExclusionThreshold = 1; ConflictExclusionThreshold = 1 (per RFC-21 Layer B). - computeNextAttempt now consults rejectBlamedSenders and conflictBlamedSenders alongside the existing overflowBlamed set. All three feed into the permanent ExcludedSet. - blamedSenders helper factored to share the threshold-comparison + sort logic across the three category helpers. * pkg/frost/signing/native_frost_protocol_frost_native.go and * pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go - Three reject sites: in each of the three receive loops, the shouldAcceptNativeFROSTMessage failure path now calls evidence.RecordReject(senderID, "validation_gate_rejected") before returning. (Previously the message was just dropped.) - Three conflict sites: the first-write-wins assembly loop's "dropping conflicting" branch now calls evidence.RecordConflict(senderID) immediately before the existing log line. (Previously only the log line.) Tests (15 new cases): * pkg/frost/roast/attempt/evidence_recorder_categories_test.go (7) - RecordReject accumulates by reason - RecordReject per-reason quota saturates - Per-reason quotas independent across reasons - RecordConflict accumulates and saturates - All three categories present in Snapshot after mixed input - NoOp recorder inert across all categories - RFC-quota constants match documented values * pkg/frost/roast/next_attempt_categories_test.go (5) - Single reject crosses threshold -> permanent exclusion - Single conflict crosses threshold -> permanent exclusion - Reject and conflict on different senders -> both excluded - Empty rejects+conflicts -> no exclusion (sanity) - Threshold constants match RFC-21 * Receive-loop wiring is covered by existing send/recv tests combined with the recorder unit tests; no new behaviour test added at the integration level because the NoOp default keeps pre-RFC-21 receive semantics observably unchanged. Verification: * go build ./... + go build -tags 'frost_native frost_tbtc_signer frost_roast_retry' ./... -- both clean * go test ./pkg/frost/... + go test -race ./pkg/frost/roast/... + go test -tags 'frost_native frost_tbtc_signer frost_roast_retry' ./pkg/frost/... -- all pass (5 packages) * staticcheck -checks '-SA1019' ./pkg/frost/... -- silent * go vet ./pkg/frost/... + gofmt -l ./pkg/frost/ -- clean This PR completes M4 from the original PR #3866 review. All four ROAST evidence categories (overflow, reject, conflict, silence) are now operational; the NextAttempt policy excludes on the first three and parks transiently on the fourth, matching RFC-21 Layer B exactly. --- pkg/frost/roast/attempt/evidence_recorder.go | 186 +++++++++++++++--- .../evidence_recorder_categories_test.go | 114 +++++++++++ pkg/frost/roast/next_attempt.go | 78 +++++++- .../roast/next_attempt_categories_test.go | 165 ++++++++++++++++ pkg/frost/roast/transition_message.go | 105 +++++++++- ...ffi_primitive_transitional_frost_native.go | 2 + .../native_frost_protocol_frost_native.go | 4 + 7 files changed, 623 insertions(+), 31 deletions(-) create mode 100644 pkg/frost/roast/attempt/evidence_recorder_categories_test.go create mode 100644 pkg/frost/roast/next_attempt_categories_test.go diff --git a/pkg/frost/roast/attempt/evidence_recorder.go b/pkg/frost/roast/attempt/evidence_recorder.go index 93713bb70c..b67d23513c 100644 --- a/pkg/frost/roast/attempt/evidence_recorder.go +++ b/pkg/frost/roast/attempt/evidence_recorder.go @@ -17,13 +17,36 @@ import ( // of how aggressively a peer (or its network link) misbehaves. const OverflowQuotaDefault uint = 8 +// RejectQuotaDefault is the default per-sender reject event quota. +// Matches categoryQuota.Reject in RFC-21 Layer A. A reject event is +// recorded each time a peer's payload fails the validation gate +// (shouldAcceptNativeFROSTMessage returning false), regardless of +// the specific reason. +const RejectQuotaDefault uint = 8 + +// ConflictQuotaDefault is the default per-sender conflict event +// quota. Matches categoryQuota.Conflict in RFC-21 Layer A. A +// conflict event is recorded when a peer retransmits a message for +// a sender slot that already holds a byte-different contribution +// (first-write-wins reject). +const ConflictQuotaDefault uint = 4 + // EvidenceRecorder collects bounded, per-attempt evidence of receive- // path anomalies that the ROAST coordinator's exclusion policy may // later consume. // -// Phase 2 introduces only the overflow channel; future phases extend -// the interface with separate methods for reject events, first-write- -// wins conflicts, and silent peers. +// The interface tracks three categories of evidence: +// - Overflow: payload arrived but the inbound channel was full. +// - Reject: payload arrived but failed validation +// (shouldAcceptNativeFROSTMessage returning false). +// - Conflict: a peer's later retransmission disagreed with its +// earlier contribution for the same slot (equivocation +// signal). +// +// Silence -- peers in the IncludedSet that produced no snapshot at +// all -- is derived implicitly by the NextAttempt policy from +// (ctx.IncludedSet - bundleSenders) and does not need a recorder +// method. // // Implementations must be safe for concurrent calls from multiple // goroutines, since the receive-callback closure in pkg/frost/signing @@ -35,49 +58,102 @@ type EvidenceRecorder interface { // applies its own quota; callers do not need to suppress at the // call site. RecordOverflow(sender group.MemberIndex) + // RecordReject notes that a payload from the named sender failed + // the validation gate (typically shouldAcceptNativeFROSTMessage + // returning false). The reason string is preserved verbatim in + // the snapshot so the coordinator's exclusion policy can later + // route by reason if needed; the recorder applies its own + // per-sender quota regardless of reason. + RecordReject(sender group.MemberIndex, reason string) + // RecordConflict notes that a peer retransmitted a message for + // a sender slot that already holds a byte-different contribution + // (equivocation signal under the first-write-wins assembly + // policy). + RecordConflict(sender group.MemberIndex) // Snapshot returns a copy of the recorded evidence so far. The // returned value does not alias internal state; the recorder may // continue receiving events after Snapshot is called. Snapshot() Evidence } +// RejectEntry describes a single per-sender reject event recorded +// during an attempt. The reason captures *why* the validation gate +// rejected the payload; the coordinator's exclusion policy treats +// every distinct reason as equally blamable today, but the field +// is kept structured so future policy refinements can differentiate. +type RejectEntry struct { + Reason string + Count uint +} + // Evidence is the per-attempt snapshot of receive-path anomalies // captured by an EvidenceRecorder. It is the value the ROAST -// coordinator's NextAttempt policy consumes (in a later RFC-21 -// phase) to derive the next attempt's ExcludedSet. +// coordinator's NextAttempt policy consumes to derive the next +// attempt's ExcludedSet. +// +// Maps are nil-safe in callers: an absent key means the category +// did not fire for that sender, count zero. type Evidence struct { // Overflows maps each sender to the number of overflow events // observed for that sender during the attempt, saturated at the - // recorder's overflow quota. A missing key means the sender did - // not overflow at all during the attempt. + // recorder's overflow quota. Overflows map[group.MemberIndex]uint + // Rejects maps each sender to a per-reason set of reject entries. + // The outer map's key is the sender; the inner slice carries one + // entry per distinct reason, with Count saturated at the + // recorder's reject quota. + Rejects map[group.MemberIndex][]RejectEntry + // Conflicts maps each sender to the number of first-write-wins + // conflict events observed during the attempt, saturated at the + // recorder's conflict quota. + Conflicts map[group.MemberIndex]uint } // NewBoundedRecorder returns an EvidenceRecorder with default -// per-sender quotas. The recorder is safe for concurrent use. -// -// Phase 2 wiring uses NoOpRecorder by default at every call site; -// real use of the bounded recorder lands in a later phase behind a -// build tag, when the coordinator state machine arrives. +// per-sender quotas across all three categories. The recorder is +// safe for concurrent use. func NewBoundedRecorder() EvidenceRecorder { - return NewBoundedRecorderWithQuota(OverflowQuotaDefault) + return NewBoundedRecorderWithQuotas( + OverflowQuotaDefault, + RejectQuotaDefault, + ConflictQuotaDefault, + ) } // NewBoundedRecorderWithQuota returns a recorder with a custom -// overflow quota. Intended for tests; production callers should use -// NewBoundedRecorder so the per-attempt evidence size is uniform -// across the network. +// overflow quota; reject and conflict quotas use their defaults. +// Preserved as the Phase-2 entry point so existing callers do not +// need to update. func NewBoundedRecorderWithQuota(overflowQuota uint) EvidenceRecorder { + return NewBoundedRecorderWithQuotas( + overflowQuota, + RejectQuotaDefault, + ConflictQuotaDefault, + ) +} + +// NewBoundedRecorderWithQuotas returns a recorder with custom +// per-category quotas. Intended for tests; production callers +// should use NewBoundedRecorder so the per-attempt evidence size +// is uniform across the network. +func NewBoundedRecorderWithQuotas( + overflowQuota, rejectQuota, conflictQuota uint, +) EvidenceRecorder { return &boundedRecorder{ overflowQuota: overflowQuota, + rejectQuota: rejectQuota, + conflictQuota: conflictQuota, overflows: map[group.MemberIndex]uint{}, + rejects: map[group.MemberIndex]map[string]uint{}, + conflicts: map[group.MemberIndex]uint{}, } } // NoOpRecorder returns a recorder that discards every event and -// reports an empty Evidence on Snapshot. It is the default at every -// Phase 2 call site so the receive loops' observable behaviour stays -// identical to pre-Phase-2 until a later phase wires real recorders. +// reports an empty Evidence on Snapshot. It is the default at +// every receive-loop call site when the ROAST-retry registry is +// not populated, so the receive loops' observable behaviour stays +// identical to pre-Phase-2 until a real recorder is wired. func NoOpRecorder() EvidenceRecorder { return noOpRecorder{} } @@ -85,7 +161,17 @@ func NoOpRecorder() EvidenceRecorder { type boundedRecorder struct { mu sync.Mutex overflowQuota uint + rejectQuota uint + conflictQuota uint overflows map[group.MemberIndex]uint + // rejects[sender][reason] = count. The two-level map keeps each + // reason bucket bounded by rejectQuota independently so a peer + // cannot saturate one reason to mask another (RFC-21 Layer A: + // "a peer cannot spam overflow events to drown out reject + // evidence or vice-versa"; the same principle applies within + // reject reasons). + rejects map[group.MemberIndex]map[string]uint + conflicts map[group.MemberIndex]uint } func (r *boundedRecorder) RecordOverflow(sender group.MemberIndex) { @@ -96,20 +182,72 @@ func (r *boundedRecorder) RecordOverflow(sender group.MemberIndex) { } } +func (r *boundedRecorder) RecordReject( + sender group.MemberIndex, + reason string, +) { + r.mu.Lock() + defer r.mu.Unlock() + bySender, ok := r.rejects[sender] + if !ok { + bySender = map[string]uint{} + r.rejects[sender] = bySender + } + if bySender[reason] < r.rejectQuota { + bySender[reason]++ + } +} + +func (r *boundedRecorder) RecordConflict(sender group.MemberIndex) { + r.mu.Lock() + defer r.mu.Unlock() + if r.conflicts[sender] < r.conflictQuota { + r.conflicts[sender]++ + } +} + func (r *boundedRecorder) Snapshot() Evidence { r.mu.Lock() defer r.mu.Unlock() - out := make(map[group.MemberIndex]uint, len(r.overflows)) + overflows := make(map[group.MemberIndex]uint, len(r.overflows)) for sender, count := range r.overflows { - out[sender] = count + overflows[sender] = count + } + rejects := make( + map[group.MemberIndex][]RejectEntry, + len(r.rejects), + ) + for sender, reasons := range r.rejects { + entries := make([]RejectEntry, 0, len(reasons)) + for reason, count := range reasons { + entries = append(entries, RejectEntry{ + Reason: reason, + Count: count, + }) + } + rejects[sender] = entries + } + conflicts := make(map[group.MemberIndex]uint, len(r.conflicts)) + for sender, count := range r.conflicts { + conflicts[sender] = count + } + return Evidence{ + Overflows: overflows, + Rejects: rejects, + Conflicts: conflicts, } - return Evidence{Overflows: out} } type noOpRecorder struct{} -func (noOpRecorder) RecordOverflow(group.MemberIndex) {} +func (noOpRecorder) RecordOverflow(group.MemberIndex) {} +func (noOpRecorder) RecordReject(group.MemberIndex, string) {} +func (noOpRecorder) RecordConflict(group.MemberIndex) {} func (noOpRecorder) Snapshot() Evidence { - return Evidence{Overflows: map[group.MemberIndex]uint{}} + return Evidence{ + Overflows: map[group.MemberIndex]uint{}, + Rejects: map[group.MemberIndex][]RejectEntry{}, + Conflicts: map[group.MemberIndex]uint{}, + } } diff --git a/pkg/frost/roast/attempt/evidence_recorder_categories_test.go b/pkg/frost/roast/attempt/evidence_recorder_categories_test.go new file mode 100644 index 0000000000..176d61f152 --- /dev/null +++ b/pkg/frost/roast/attempt/evidence_recorder_categories_test.go @@ -0,0 +1,114 @@ +package attempt + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestBoundedRecorder_RecordReject_AccumulatesByReason(t *testing.T) { + rec := NewBoundedRecorder() + rec.RecordReject(1, "validation_gate_rejected") + rec.RecordReject(1, "validation_gate_rejected") + rec.RecordReject(1, "some_other_reason") + + snap := rec.Snapshot() + entries := snap.Rejects[1] + if len(entries) != 2 { + t.Fatalf("expected 2 reject reasons, got %d", len(entries)) + } + counts := map[string]uint{} + for _, e := range entries { + counts[e.Reason] = e.Count + } + if counts["validation_gate_rejected"] != 2 { + t.Fatalf("validation_gate_rejected count: got %d want 2", counts["validation_gate_rejected"]) + } + if counts["some_other_reason"] != 1 { + t.Fatalf("some_other_reason count: got %d want 1", counts["some_other_reason"]) + } +} + +func TestBoundedRecorder_RecordReject_PerReasonQuota(t *testing.T) { + rec := NewBoundedRecorderWithQuotas(8, 3, 4) + for i := 0; i < 10; i++ { + rec.RecordReject(1, "spam") + } + snap := rec.Snapshot() + got := snap.Rejects[1][0].Count + if got != 3 { + t.Fatalf("reject quota not enforced: got %d, want 3", got) + } +} + +func TestBoundedRecorder_RecordReject_PerReasonQuotasIndependent(t *testing.T) { + // A peer cannot saturate one reason to mask another -- each + // reason has its own quota counter. + rec := NewBoundedRecorderWithQuotas(8, 2, 4) + for i := 0; i < 10; i++ { + rec.RecordReject(1, "reason-A") + } + rec.RecordReject(1, "reason-B") + snap := rec.Snapshot() + counts := map[string]uint{} + for _, e := range snap.Rejects[1] { + counts[e.Reason] = e.Count + } + if counts["reason-A"] != 2 { + t.Fatalf("reason-A saturated at: got %d want 2", counts["reason-A"]) + } + if counts["reason-B"] != 1 { + t.Fatalf("reason-B counted independently: got %d want 1", counts["reason-B"]) + } +} + +func TestBoundedRecorder_RecordConflict_AccumulatesAndSaturates(t *testing.T) { + rec := NewBoundedRecorderWithQuotas(8, 8, 2) + rec.RecordConflict(7) + rec.RecordConflict(7) + rec.RecordConflict(7) + rec.RecordConflict(7) + snap := rec.Snapshot() + if got := snap.Conflicts[7]; got != 2 { + t.Fatalf("conflict count saturated at quota; got %d want 2", got) + } +} + +func TestBoundedRecorder_AllCategoriesPresentInSnapshot(t *testing.T) { + rec := NewBoundedRecorder() + rec.RecordOverflow(1) + rec.RecordReject(2, "validation_gate_rejected") + rec.RecordConflict(3) + snap := rec.Snapshot() + if snap.Overflows[1] == 0 { + t.Fatal("overflow not recorded") + } + if len(snap.Rejects[2]) == 0 { + t.Fatal("reject not recorded") + } + if snap.Conflicts[3] == 0 { + t.Fatal("conflict not recorded") + } +} + +func TestNoOpRecorder_AllCategoriesInert(t *testing.T) { + rec := NoOpRecorder() + for i := 0; i < 100; i++ { + rec.RecordOverflow(group.MemberIndex(i % 5)) + rec.RecordReject(group.MemberIndex(i%5), "spam") + rec.RecordConflict(group.MemberIndex(i % 5)) + } + snap := rec.Snapshot() + if len(snap.Overflows) != 0 || len(snap.Rejects) != 0 || len(snap.Conflicts) != 0 { + t.Fatalf("NoOp recorder must report empty snapshot; got %+v", snap) + } +} + +func TestRejectAndConflictQuotaConstants_MatchRFC(t *testing.T) { + if RejectQuotaDefault != 8 { + t.Fatalf("RFC-21 specifies reject quota = 8; constant is %d", RejectQuotaDefault) + } + if ConflictQuotaDefault != 4 { + t.Fatalf("RFC-21 specifies conflict quota = 4; constant is %d", ConflictQuotaDefault) + } +} diff --git a/pkg/frost/roast/next_attempt.go b/pkg/frost/roast/next_attempt.go index e4d450b8f9..4f896c6b23 100644 --- a/pkg/frost/roast/next_attempt.go +++ b/pkg/frost/roast/next_attempt.go @@ -15,6 +15,22 @@ import ( // 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 + // ErrAttemptInfeasible is returned by NextAttempt when the next // attempt's IncludedSet would drop below the signing threshold t and // the session can no longer make progress with the original signer @@ -104,16 +120,26 @@ func computeNextAttempt( threshold uint, dkgGroupPublicKey []byte, ) (attempt.AttemptContext, error) { - // (1) Permanent exclusion from overflow evidence. + // (1) Permanent exclusion from overflow evidence (transport + // blamable). overflowBlamed := overflowBlamedSenders(bundle, OverflowExclusionThreshold) - // (2) Reject blame -- Phase 3.4 has no reject category to read. - // rejectBlamed := + // (2) Permanent exclusion from reject evidence (validation + // blamable). Counts across reasons are summed per-sender. + rejectBlamed := rejectBlamedSenders(bundle, RejectExclusionThreshold) + + // (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) // 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. @@ -191,6 +217,52 @@ func overflowBlamedSenders( counts[entry.Sender] += entry.Count } } + return blamedSenders(counts, threshold) +} + +// 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, +) []group.MemberIndex { + counts := map[group.MemberIndex]uint{} + for i := range bundle.Bundle { + for _, entry := range bundle.Bundle[i].Rejects { + counts[entry.Sender] += entry.Count + } + } + return blamedSenders(counts, threshold) +} + +// 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( + bundle *TransitionMessage, + threshold uint, +) []group.MemberIndex { + counts := map[group.MemberIndex]uint{} + for i := range bundle.Bundle { + for _, entry := range bundle.Bundle[i].Conflicts { + counts[entry.Sender] += entry.Count + } + } + return blamedSenders(counts, threshold) +} + +// 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. +func blamedSenders( + counts map[group.MemberIndex]uint, + threshold uint, +) []group.MemberIndex { out := make([]group.MemberIndex, 0) for sender, count := range counts { if count >= threshold { diff --git a/pkg/frost/roast/next_attempt_categories_test.go b/pkg/frost/roast/next_attempt_categories_test.go new file mode 100644 index 0000000000..0729ae13e6 --- /dev/null +++ b/pkg/frost/roast/next_attempt_categories_test.go @@ -0,0 +1,165 @@ +package roast + +import ( + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "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. +func buildBundleWithCategories( + t *testing.T, + prev attempt.AttemptContext, + rejects map[group.MemberIndex][]string, + conflicts []group.MemberIndex, +) *TransitionMessage { + t.Helper() + prevHash := prev.Hash() + bundle := make([]LocalEvidenceSnapshot, 0, len(prev.IncludedSet)) + for _, sender := range prev.IncludedSet { + snap := LocalEvidenceSnapshot{ + SenderIDValue: uint32(sender), + AttemptContextHash: append([]byte{}, prevHash[:]...), + } + var rejectEntries []RejectEntry + for blamedSender, reasons := range rejects { + for _, r := range reasons { + rejectEntries = append(rejectEntries, RejectEntry{ + Sender: blamedSender, + Reason: r, + Count: 1, + }) + } + } + sortRejectEntriesForTest(rejectEntries) + if len(rejectEntries) > 0 { + snap.Rejects = rejectEntries + } + var conflictEntries []ConflictEntry + for _, blamedSender := range conflicts { + conflictEntries = append(conflictEntries, ConflictEntry{ + Sender: blamedSender, + Count: 1, + }) + } + if len(conflictEntries) > 0 { + snap.Conflicts = conflictEntries + } + bundle = append(bundle, snap) + } + return &TransitionMessage{ + AttemptContextHash: append([]byte{}, prevHash[:]...), + CoordinatorIDValue: 1, + Bundle: bundle, + } +} + +func sortRejectEntriesForTest(entries []RejectEntry) { + for i := 1; i < len(entries); i++ { + for j := i; j > 0 && (entries[j].Sender < entries[j-1].Sender || + (entries[j].Sender == entries[j-1].Sender && entries[j].Reason < entries[j-1].Reason)); j-- { + entries[j], entries[j-1] = entries[j-1], entries[j] + } + } +} + +func TestNextAttempt_SingleRejectExcludesPermanently(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. + bundle := buildBundleWithCategories( + t, + prev, + 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 !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf("sender 3 must be excluded; got %v", next.ExcludedSet) + } + if memberSliceContains(next.IncludedSet, 3) { + t.Fatal("sender 3 must not be in next IncludedSet") + } +} + +func TestNextAttempt_SingleConflictExcludesPermanently(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := buildBundleWithCategories( + t, + prev, + nil, + []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( + "sender 3 must be excluded after a single conflict; got %v", + next.ExcludedSet, + ) + } +} + +func TestNextAttempt_RejectAndConflictBothExclude(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := buildBundleWithCategories( + t, + prev, + map[group.MemberIndex][]string{2: {"validation_gate_rejected"}}, + []group.MemberIndex{4}, + ) + + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 2) { + t.Fatalf("sender 2 (reject) must be excluded; got %v", next.ExcludedSet) + } + if !memberSliceContains(next.ExcludedSet, 4) { + t.Fatalf("sender 4 (conflict) must be excluded; got %v", next.ExcludedSet) + } +} + +func TestNextAttempt_EmptyRejectsAndConflicts_DoNotExclude(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := buildBundleWithCategories(t, prev, nil, nil) + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if len(next.ExcludedSet) != 0 { + t.Fatalf("no evidence -> no exclusions; got %v", next.ExcludedSet) + } +} + +func TestRejectAndConflictThresholds_MatchRFC(t *testing.T) { + if RejectExclusionThreshold != 1 { + t.Fatalf( + "RFC-21 Layer B specifies reject threshold = 1; constant is %d", + RejectExclusionThreshold, + ) + } + if ConflictExclusionThreshold != 1 { + t.Fatalf( + "single conflict is sufficient evidence; constant is %d", + ConflictExclusionThreshold, + ) + } +} diff --git a/pkg/frost/roast/transition_message.go b/pkg/frost/roast/transition_message.go index b5835dd236..f8747bd4b7 100644 --- a/pkg/frost/roast/transition_message.go +++ b/pkg/frost/roast/transition_message.go @@ -53,6 +53,24 @@ type OverflowEntry struct { Count uint `json:"count"` } +// RejectEntry carries one per-(sender, reason) reject count from an +// attempt.Evidence map. The bundle's Rejects field is sorted +// ascending first by Sender, then by Reason, so two honest signers +// produce byte-identical canonical encodings. +type RejectEntry struct { + Sender group.MemberIndex `json:"sender"` + Reason string `json:"reason"` + Count uint `json:"count"` +} + +// ConflictEntry carries one per-sender conflict count -- the number +// of first-write-wins disagreements detected during the attempt. +// Sorted ascending by Sender for canonical encoding. +type ConflictEntry struct { + Sender group.MemberIndex `json:"sender"` + Count uint `json:"count"` +} + // LocalEvidenceSnapshot is the per-signer signed evidence produced // during a single attempt. It is the input to the coordinator's // aggregation and to the receiver-side bundle verification. @@ -68,11 +86,22 @@ type LocalEvidenceSnapshot struct { // attempt.Evidence.Overflows map; sorted ascending by Sender. // Omitted when no overflow events were observed. Overflows []OverflowEntry `json:"overflows,omitempty"` + // Rejects is the canonical sorted form of the + // attempt.Evidence.Rejects map; sorted ascending first by Sender, + // then by Reason. Omitted when no validation-reject events were + // observed. Each entry counts the number of rejects observed + // for one (sender, reason) pair, saturated at the recorder's + // reject quota. + Rejects []RejectEntry `json:"rejects,omitempty"` + // Conflicts is the canonical sorted form of the + // attempt.Evidence.Conflicts map; sorted ascending by Sender. + // Omitted when no first-write-wins-conflict events were + // observed. + Conflicts []ConflictEntry `json:"conflicts,omitempty"` // OperatorSignature is the signer's operator-key signature over // the canonical encoding of (senderID, attemptContextHash, - // overflows). Phase 3.3 defines the canonical-encoding - // algorithm and the verification routine. Phase 3.2 treats this - // field as opaque bytes with a length cap. + // overflows, rejects, conflicts). Phase 3.3 defines the + // canonical-encoding algorithm and the verification routine. OperatorSignature []byte `json:"operatorSignature,omitempty"` } @@ -93,11 +122,44 @@ func NewLocalEvidenceSnapshot( sort.Slice(overflows, func(i, j int) bool { return overflows[i].Sender < overflows[j].Sender }) - return &LocalEvidenceSnapshot{ + + rejects := make([]RejectEntry, 0) + for s, entries := range evidence.Rejects { + for _, e := range entries { + rejects = append(rejects, RejectEntry{ + Sender: s, + Reason: e.Reason, + Count: e.Count, + }) + } + } + sort.Slice(rejects, func(i, j int) bool { + if rejects[i].Sender != rejects[j].Sender { + return rejects[i].Sender < rejects[j].Sender + } + return rejects[i].Reason < rejects[j].Reason + }) + + conflicts := make([]ConflictEntry, 0, len(evidence.Conflicts)) + for s, c := range evidence.Conflicts { + conflicts = append(conflicts, ConflictEntry{Sender: s, Count: c}) + } + sort.Slice(conflicts, func(i, j int) bool { + return conflicts[i].Sender < conflicts[j].Sender + }) + + snap := &LocalEvidenceSnapshot{ SenderIDValue: uint32(sender), AttemptContextHash: append([]byte{}, attemptContextHash[:]...), Overflows: overflows, } + if len(rejects) > 0 { + snap.Rejects = rejects + } + if len(conflicts) > 0 { + snap.Conflicts = conflicts + } + return snap } // SenderID returns the snapshot's sender as a group.MemberIndex. @@ -122,10 +184,21 @@ func (s *LocalEvidenceSnapshot) AttemptContextHashArray() [attempt.MessageDigest func (s *LocalEvidenceSnapshot) Evidence() attempt.Evidence { out := attempt.Evidence{ Overflows: make(map[group.MemberIndex]uint, len(s.Overflows)), + Rejects: make(map[group.MemberIndex][]attempt.RejectEntry, 0), + Conflicts: make(map[group.MemberIndex]uint, len(s.Conflicts)), } for _, e := range s.Overflows { out.Overflows[e.Sender] = e.Count } + for _, e := range s.Rejects { + out.Rejects[e.Sender] = append(out.Rejects[e.Sender], attempt.RejectEntry{ + Reason: e.Reason, + Count: e.Count, + }) + } + for _, e := range s.Conflicts { + out.Conflicts[e.Sender] = e.Count + } return out } @@ -181,6 +254,30 @@ func (s *LocalEvidenceSnapshot) Validate() error { ) } } + for i := 1; i < len(s.Rejects); i++ { + prev := s.Rejects[i-1] + cur := s.Rejects[i] + if cur.Sender < prev.Sender { + return fmt.Errorf( + "local evidence snapshot: rejects not sorted ascending by sender at index %d", + i, + ) + } + if cur.Sender == prev.Sender && cur.Reason <= prev.Reason { + return fmt.Errorf( + "local evidence snapshot: rejects not sorted ascending by reason or contain duplicate at index %d", + i, + ) + } + } + for i := 1; i < len(s.Conflicts); i++ { + if s.Conflicts[i].Sender <= s.Conflicts[i-1].Sender { + return fmt.Errorf( + "local evidence snapshot: conflicts not sorted ascending or contain duplicate at index %d", + i, + ) + } + } return nil } diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go index 30d0c8f5bf..1a2671bd68 100644 --- a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go @@ -998,6 +998,7 @@ func collectBuildTaggedTBTCSignerRoundContributionMessages( payload.SessionID(), message.SenderPublicKey(), ) { + evidence.RecordReject(payload.SenderID(), "validation_gate_rejected") return } @@ -1026,6 +1027,7 @@ func collectBuildTaggedTBTCSignerRoundContributionMessages( existing, message, ) { + evidence.RecordConflict(senderID) protocolLogger.Warnf( "dropping conflicting tbtc-signer round contribution "+ "from sender [%d]; first-write-wins keeps the "+ diff --git a/pkg/frost/signing/native_frost_protocol_frost_native.go b/pkg/frost/signing/native_frost_protocol_frost_native.go index 51e6d20bff..5fcb8e9cff 100644 --- a/pkg/frost/signing/native_frost_protocol_frost_native.go +++ b/pkg/frost/signing/native_frost_protocol_frost_native.go @@ -636,6 +636,7 @@ func collectNativeFROSTRoundOneMessages( payload.SessionID(), message.SenderPublicKey(), ) { + evidence.RecordReject(payload.SenderID(), "validation_gate_rejected") return } @@ -658,6 +659,7 @@ func collectNativeFROSTRoundOneMessages( senderID := message.SenderID() if existing, ok := receivedMessages[senderID]; ok { if !nativeFROSTRoundOneCommitmentMessagesEqual(existing, message) { + evidence.RecordConflict(senderID) protocolLogger.Warnf( "dropping conflicting native FROST round one "+ "commitment from sender [%d]; first-write-wins "+ @@ -716,6 +718,7 @@ func collectNativeFROSTRoundTwoMessages( payload.SessionID(), message.SenderPublicKey(), ) { + evidence.RecordReject(payload.SenderID(), "validation_gate_rejected") return } @@ -740,6 +743,7 @@ func collectNativeFROSTRoundTwoMessages( existing, message, ) { + evidence.RecordConflict(senderID) protocolLogger.Warnf( "dropping conflicting native FROST round two "+ "signature share from sender [%d]; first-write-wins "+