Skip to content

Commit d7e0546

Browse files
committed
feat(frost/roast): RFC-21 Phase 3.2 -- TransitionMessage + LocalEvidenceSnapshot
Adds the wire types the ROAST coordinator-aggregation flow defined in RFC-21's Resolved Decisions section needs. No transport, aggregation, or signature handling yet -- those land in PR 3.3 and later. * pkg/frost/roast/transition_message.go - LocalEvidenceSnapshot: per-signer signed evidence carrying SenderID, AttemptContextHash, sorted OverflowEntry slice, and an OperatorSignature placeholder. - TransitionMessage: coordinator-aggregated bundle carrying the attempt context hash, the elected coordinator's member index, a SenderID-ascending Bundle of LocalEvidenceSnapshots, and a CoordinatorSignature placeholder. - OverflowEntry: JSON-friendly canonical key/value pair for one attempt.Evidence.Overflows entry. Sorting by Sender ascending is enforced at construction and validated at Unmarshal. - NewLocalEvidenceSnapshot: converts an attempt.Evidence map into the canonical sorted-slice form ready for signing. - (*LocalEvidenceSnapshot).Evidence(): reverse conversion to the attempt.Evidence map form. - Type() implementations under the frost_signing/roast/ prefix so net.TaggedUnmarshaler routing can dispatch unambiguously. - Marshal/Unmarshal pair via encoding/json, matching the Phase 1B pattern. - Validation in Unmarshal: zero-sender rejection, 32-byte AttemptContextHash enforcement, MaxSnapshotsPerBundle cap (256), MaxOperatorSignatureBytes / MaxCoordinatorSignatureBytes caps (256 each), bundle ordering by sender ascending, and every snapshot's AttemptContextHash matching the bundle hash. Phase 3.2 deliberately treats the OperatorSignature and CoordinatorSignature fields as opaque length-capped byte slices. Phase 3.3 introduces the canonical-encoding contract those signatures cover (along with the aggregate / verify routines on the Coordinator interface). Tests (16 cases, both bundle and snapshot): * TestLocalEvidenceSnapshot_TypeIsStable * TestNewLocalEvidenceSnapshot_SortsOverflows * TestNewLocalEvidenceSnapshot_EmptyEvidenceOmitsOverflows * TestLocalEvidenceSnapshot_RoundTrip * TestLocalEvidenceSnapshot_RejectsZeroSender * TestLocalEvidenceSnapshot_RejectsWrongHashLength * TestLocalEvidenceSnapshot_RejectsOversizeSignature * TestLocalEvidenceSnapshot_RejectsUnsortedOverflows * TestLocalEvidenceSnapshot_RejectsDuplicateOverflowSender * TestLocalEvidenceSnapshot_EvidenceReconstructsMap * TestLocalEvidenceSnapshot_AttemptContextHashArrayHandlesMalformed * TestTransitionMessage_TypeIsStable * TestTransitionMessage_RoundTrip * TestTransitionMessage_RejectsBadBundleOrdering * TestTransitionMessage_RejectsMismatchedBundleHash * TestTransitionMessage_RejectsEmptyBundle * TestTransitionMessage_RejectsOversizeBundle * TestTransitionMessage_RejectsZeroCoordinatorID * TestTransitionMessage_RejectsOversizeCoordinatorSignature * TestTransitionMessage_RejectsBundleWithInvalidSnapshot * TestTransitionMessage_RejectsDuplicateBundleSender * TestTransitionMessage_DeterministicJSONForIdenticalInputs All pass under: go test ./pkg/frost/roast/..., go test -race ./pkg/frost/roast/..., go test -tags 'frost_native frost_tbtc_signer' ./pkg/frost/..., staticcheck -checks '-SA1019' ./pkg/frost/roast/..., go vet ./pkg/frost/roast/..., gofmt -l ./pkg/frost/roast/. Stacked on Phase 3.1 (#3968).
1 parent cd7e222 commit d7e0546

2 files changed

Lines changed: 680 additions & 0 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
package roast
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"sort"
9+
10+
"github.com/keep-network/keep-core/pkg/frost/roast/attempt"
11+
"github.com/keep-network/keep-core/pkg/protocol/group"
12+
)
13+
14+
// roastMessageTypePrefix is the per-protocol prefix every ROAST-layer
15+
// wire message uses for its net.TaggedUnmarshaler Type(). Distinct
16+
// from frost_signing/native_frost/ and frost_signing/native_tbtc_signer/
17+
// so the network router can dispatch unambiguously.
18+
const roastMessageTypePrefix = "frost_signing/roast/"
19+
20+
// LocalEvidenceSnapshotType is the stable Type() string for a single
21+
// signer's signed evidence snapshot.
22+
const LocalEvidenceSnapshotType = roastMessageTypePrefix + "local_evidence_snapshot"
23+
24+
// TransitionMessageType is the stable Type() string for the
25+
// coordinator-aggregated bundle.
26+
const TransitionMessageType = roastMessageTypePrefix + "transition_message"
27+
28+
// MaxSnapshotsPerBundle caps the number of LocalEvidenceSnapshot
29+
// entries a TransitionMessage may carry. Sized for the worst-case
30+
// production signing group plus headroom; rejects pathological
31+
// bundles at Unmarshal time so a misbehaving peer cannot exhaust
32+
// memory on the receiver.
33+
const MaxSnapshotsPerBundle = 256
34+
35+
// MaxOperatorSignatureBytes caps the per-snapshot OperatorSignature
36+
// length. Sized to accept secp256k1 DER (~72 bytes), ed25519 (64
37+
// bytes), and reasonable post-quantum candidates without committing
38+
// to a specific scheme at this layer. Rejects oversize payloads.
39+
const MaxOperatorSignatureBytes = 256
40+
41+
// MaxCoordinatorSignatureBytes caps the bundle-level
42+
// CoordinatorSignature. Same justification as
43+
// MaxOperatorSignatureBytes.
44+
const MaxCoordinatorSignatureBytes = 256
45+
46+
// OverflowEntry is the JSON-friendly key/value pair representing one
47+
// per-sender overflow count from an attempt.Evidence map. The slice
48+
// representation is canonical (sorted by Sender ascending) so any
49+
// two honest signers serialising the same evidence produce
50+
// byte-identical JSON.
51+
type OverflowEntry struct {
52+
Sender group.MemberIndex `json:"sender"`
53+
Count uint `json:"count"`
54+
}
55+
56+
// LocalEvidenceSnapshot is the per-signer signed evidence produced
57+
// during a single attempt. It is the input to the coordinator's
58+
// aggregation and to the receiver-side bundle verification.
59+
//
60+
// Phase 3.2 (this file) defines the wire type only. Signature
61+
// computation and verification land in Phase 3.3.
62+
type LocalEvidenceSnapshot struct {
63+
SenderIDValue uint32 `json:"senderID"`
64+
// AttemptContextHash binds the snapshot to the attempt the
65+
// evidence describes. Always exactly 32 bytes.
66+
AttemptContextHash []byte `json:"attemptContextHash"`
67+
// Overflows is the canonical sorted form of the
68+
// attempt.Evidence.Overflows map; sorted ascending by Sender.
69+
// Omitted when no overflow events were observed.
70+
Overflows []OverflowEntry `json:"overflows,omitempty"`
71+
// OperatorSignature is the signer's operator-key signature over
72+
// the canonical encoding of (senderID, attemptContextHash,
73+
// overflows). Phase 3.3 defines the canonical-encoding
74+
// algorithm and the verification routine. Phase 3.2 treats this
75+
// field as opaque bytes with a length cap.
76+
OperatorSignature []byte `json:"operatorSignature,omitempty"`
77+
}
78+
79+
// NewLocalEvidenceSnapshot converts an attempt.Evidence map into a
80+
// LocalEvidenceSnapshot ready for signing and broadcast. The
81+
// resulting snapshot's Overflows field is sorted ascending by
82+
// Sender for deterministic JSON encoding. The OperatorSignature is
83+
// left empty -- the caller must sign and populate it (Phase 3.3).
84+
func NewLocalEvidenceSnapshot(
85+
sender group.MemberIndex,
86+
attemptContextHash [attempt.MessageDigestLength]byte,
87+
evidence attempt.Evidence,
88+
) *LocalEvidenceSnapshot {
89+
overflows := make([]OverflowEntry, 0, len(evidence.Overflows))
90+
for s, c := range evidence.Overflows {
91+
overflows = append(overflows, OverflowEntry{Sender: s, Count: c})
92+
}
93+
sort.Slice(overflows, func(i, j int) bool {
94+
return overflows[i].Sender < overflows[j].Sender
95+
})
96+
return &LocalEvidenceSnapshot{
97+
SenderIDValue: uint32(sender),
98+
AttemptContextHash: append([]byte{}, attemptContextHash[:]...),
99+
Overflows: overflows,
100+
}
101+
}
102+
103+
// SenderID returns the snapshot's sender as a group.MemberIndex.
104+
func (s *LocalEvidenceSnapshot) SenderID() group.MemberIndex {
105+
return group.MemberIndex(s.SenderIDValue)
106+
}
107+
108+
// AttemptContextHashArray returns the 32-byte attempt context hash
109+
// as a fixed-size array. Returns the zero array if the field is
110+
// malformed (caller should have validated via Unmarshal first).
111+
func (s *LocalEvidenceSnapshot) AttemptContextHashArray() [attempt.MessageDigestLength]byte {
112+
var out [attempt.MessageDigestLength]byte
113+
if len(s.AttemptContextHash) == attempt.MessageDigestLength {
114+
copy(out[:], s.AttemptContextHash)
115+
}
116+
return out
117+
}
118+
119+
// Evidence reconstructs the attempt.Evidence map form from the
120+
// canonical sorted-slice representation. The returned Evidence
121+
// shares no state with the snapshot.
122+
func (s *LocalEvidenceSnapshot) Evidence() attempt.Evidence {
123+
out := attempt.Evidence{
124+
Overflows: make(map[group.MemberIndex]uint, len(s.Overflows)),
125+
}
126+
for _, e := range s.Overflows {
127+
out.Overflows[e.Sender] = e.Count
128+
}
129+
return out
130+
}
131+
132+
// Type implements net.TaggedUnmarshaler.
133+
func (s *LocalEvidenceSnapshot) Type() string {
134+
return LocalEvidenceSnapshotType
135+
}
136+
137+
// Marshal serialises the snapshot to canonical JSON. The Overflows
138+
// slice is sorted by Sender ascending in NewLocalEvidenceSnapshot
139+
// so two honest signers with the same evidence produce
140+
// byte-identical bytes.
141+
func (s *LocalEvidenceSnapshot) Marshal() ([]byte, error) {
142+
return json.Marshal(s)
143+
}
144+
145+
// Unmarshal parses canonical JSON into the snapshot and validates
146+
// the resulting structure.
147+
func (s *LocalEvidenceSnapshot) Unmarshal(data []byte) error {
148+
if err := json.Unmarshal(data, s); err != nil {
149+
return err
150+
}
151+
return s.validate()
152+
}
153+
154+
func (s *LocalEvidenceSnapshot) validate() error {
155+
if s.SenderIDValue == 0 {
156+
return errors.New("local evidence snapshot: senderID is zero")
157+
}
158+
if len(s.AttemptContextHash) != attempt.MessageDigestLength {
159+
return fmt.Errorf(
160+
"local evidence snapshot: attemptContextHash length [%d], expected [%d]",
161+
len(s.AttemptContextHash),
162+
attempt.MessageDigestLength,
163+
)
164+
}
165+
if len(s.OperatorSignature) > MaxOperatorSignatureBytes {
166+
return fmt.Errorf(
167+
"local evidence snapshot: operatorSignature length [%d] exceeds cap [%d]",
168+
len(s.OperatorSignature),
169+
MaxOperatorSignatureBytes,
170+
)
171+
}
172+
for i := 1; i < len(s.Overflows); i++ {
173+
if s.Overflows[i].Sender <= s.Overflows[i-1].Sender {
174+
return fmt.Errorf(
175+
"local evidence snapshot: overflows not sorted ascending or contain duplicate at index %d",
176+
i,
177+
)
178+
}
179+
}
180+
return nil
181+
}
182+
183+
// TransitionMessage is the coordinator-aggregated bundle that drives
184+
// the deterministic NextAttempt transition. It contains every
185+
// participating signer's signed evidence snapshot for one attempt,
186+
// plus the coordinator's own signature over the canonical bundle.
187+
//
188+
// Phase 3.2 (this file) defines the wire type. Aggregation,
189+
// canonical encoding, and verification land in Phase 3.3.
190+
type TransitionMessage struct {
191+
// AttemptContextHash identifies the attempt the bundle
192+
// describes. Must match every snapshot's AttemptContextHash.
193+
// Always exactly 32 bytes.
194+
AttemptContextHash []byte `json:"attemptContextHash"`
195+
// CoordinatorIDValue is the member index of the elected
196+
// coordinator that produced this bundle.
197+
CoordinatorIDValue uint32 `json:"coordinatorID"`
198+
// Bundle is the canonical sorted-by-SenderID list of signed
199+
// evidence snapshots aggregated by the coordinator.
200+
Bundle []LocalEvidenceSnapshot `json:"bundle"`
201+
// CoordinatorSignature is the coordinator's operator-key
202+
// signature over the canonical encoding of the bundle. Phase
203+
// 3.3 defines the canonical-encoding algorithm and the
204+
// verification routine. Phase 3.2 treats this field as opaque
205+
// bytes with a length cap.
206+
CoordinatorSignature []byte `json:"coordinatorSignature,omitempty"`
207+
}
208+
209+
// CoordinatorID returns the coordinator member index as a
210+
// group.MemberIndex.
211+
func (m *TransitionMessage) CoordinatorID() group.MemberIndex {
212+
return group.MemberIndex(m.CoordinatorIDValue)
213+
}
214+
215+
// AttemptContextHashArray returns the 32-byte attempt context hash
216+
// as a fixed-size array. Returns the zero array if the field is
217+
// malformed (caller should have validated via Unmarshal first).
218+
func (m *TransitionMessage) AttemptContextHashArray() [attempt.MessageDigestLength]byte {
219+
var out [attempt.MessageDigestLength]byte
220+
if len(m.AttemptContextHash) == attempt.MessageDigestLength {
221+
copy(out[:], m.AttemptContextHash)
222+
}
223+
return out
224+
}
225+
226+
// Type implements net.TaggedUnmarshaler.
227+
func (m *TransitionMessage) Type() string {
228+
return TransitionMessageType
229+
}
230+
231+
// Marshal serialises the message to canonical JSON.
232+
func (m *TransitionMessage) Marshal() ([]byte, error) {
233+
return json.Marshal(m)
234+
}
235+
236+
// Unmarshal parses canonical JSON into the message and validates
237+
// the structure: hash length, bundle size cap, signature size cap,
238+
// snapshot validity, bundle ordering by SenderID ascending, and
239+
// every snapshot binding to the same AttemptContextHash as the
240+
// bundle.
241+
func (m *TransitionMessage) Unmarshal(data []byte) error {
242+
if err := json.Unmarshal(data, m); err != nil {
243+
return err
244+
}
245+
return m.validate()
246+
}
247+
248+
func (m *TransitionMessage) validate() error {
249+
if len(m.AttemptContextHash) != attempt.MessageDigestLength {
250+
return fmt.Errorf(
251+
"transition message: attemptContextHash length [%d], expected [%d]",
252+
len(m.AttemptContextHash),
253+
attempt.MessageDigestLength,
254+
)
255+
}
256+
if m.CoordinatorIDValue == 0 {
257+
return errors.New("transition message: coordinatorID is zero")
258+
}
259+
if len(m.Bundle) == 0 {
260+
return errors.New("transition message: bundle must not be empty")
261+
}
262+
if len(m.Bundle) > MaxSnapshotsPerBundle {
263+
return fmt.Errorf(
264+
"transition message: bundle length [%d] exceeds cap [%d]",
265+
len(m.Bundle),
266+
MaxSnapshotsPerBundle,
267+
)
268+
}
269+
if len(m.CoordinatorSignature) > MaxCoordinatorSignatureBytes {
270+
return fmt.Errorf(
271+
"transition message: coordinatorSignature length [%d] exceeds cap [%d]",
272+
len(m.CoordinatorSignature),
273+
MaxCoordinatorSignatureBytes,
274+
)
275+
}
276+
for i := range m.Bundle {
277+
if err := m.Bundle[i].validate(); err != nil {
278+
return fmt.Errorf(
279+
"transition message: bundle[%d] invalid: %w",
280+
i, err,
281+
)
282+
}
283+
if !bytes.Equal(m.Bundle[i].AttemptContextHash, m.AttemptContextHash) {
284+
return fmt.Errorf(
285+
"transition message: bundle[%d] attempt context hash does not match bundle hash",
286+
i,
287+
)
288+
}
289+
if i > 0 {
290+
if m.Bundle[i].SenderIDValue <= m.Bundle[i-1].SenderIDValue {
291+
return fmt.Errorf(
292+
"transition message: bundle not sorted ascending by senderID or contains duplicate at index %d",
293+
i,
294+
)
295+
}
296+
}
297+
}
298+
return nil
299+
}

0 commit comments

Comments
 (0)