From 4f7907829ee93cfbcd537ddb5c79efc7b920e3a7 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 09:54:17 -0400 Subject: [PATCH 1/3] feat(frost/roast): protobuf signed-body envelopes for transition evidence Post-merge follow-up #4 from the June 2026 review stack: replace json.Marshal as the canonical signed-bytes encoding for evidence snapshots and transition bundles before Phase 7 wiring ossifies the format. Design - sign what you transmit, verify what you received: - new pkg/frost/roast/gen/pb/evidence.proto: a snapshot is SignedLocalEvidenceSnapshot{body, operator_signature} where body is the serialized LocalEvidenceSnapshotBody; a transition message is SignedTransitionMessage{body, coordinator_signature} whose body embeds every member's signed snapshot envelope VERBATIM (repeated bytes signed_snapshots) - producers marshal a body exactly once at signing time and cache it; parsed messages retain the received body/envelope bytes verbatim; verification always runs over exact received bytes and nothing in the evidence chain is ever re-encoded - signature validity never depends on any serializer's canonical form, across protobuf library versions or across languages (the Phase 7 Rust signer verifies and parses these same bytes) - CanonicalSnapshotBytes/CanonicalBundleBytes are replaced by SignableBytes() accessors; the coordinator's first-write-wins conflict comparison now compares exact signed bytes - Marshal of a received message returns the received envelope verbatim, so evidence bytes survive re-broadcast; wire-legal but non-canonical encodings also survive (pinned by test) - RFC-21 "Evidence message format" decision rewritten accordingly, with the rationale for retiring canonical JSON Tests: existing suite migrated off JSON fixtures (encode helpers bypass production signing to exercise structural rejection); new wire_test.go pins byte-preservation through re-broadcast, verbatim snapshot-envelope embedding in bundles, non-canonical-encoding survival, and tampered-body verification failure. go build ./..., go vet, gofmt clean; frost + tbtc package tests green. Co-Authored-By: Claude Fable 5 --- ...dinator-retry-and-transition-evidence.adoc | 32 +- pkg/frost/roast/bundle_aggregation_test.go | 6 +- pkg/frost/roast/coordinator_state.go | 12 +- pkg/frost/roast/gen/pb/evidence.pb.go | 560 ++++++++++++++++++ pkg/frost/roast/gen/pb/evidence.proto | 69 +++ .../roast/multi_coordinator_soak_test.go | 4 +- pkg/frost/roast/signature.go | 57 +- pkg/frost/roast/signature_test.go | 84 +-- pkg/frost/roast/transition_message.go | 173 ++++-- pkg/frost/roast/transition_message_test.go | 102 +++- pkg/frost/roast/wire.go | 157 +++++ pkg/frost/roast/wire_test.go | 192 ++++++ pkg/frost/signing/roast_retry_submit.go | 2 +- 13 files changed, 1275 insertions(+), 175 deletions(-) create mode 100644 pkg/frost/roast/gen/pb/evidence.pb.go create mode 100644 pkg/frost/roast/gen/pb/evidence.proto create mode 100644 pkg/frost/roast/wire.go create mode 100644 pkg/frost/roast/wire_test.go 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 02690c9573..14b8eceb82 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 @@ -631,12 +631,30 @@ optimization given the current key model. === Evidence message format -*Decision: JSON payload wrapped in the existing `pkg/net/gen/pb` -envelope, routed via the `net.Message` interface.* - -This matches the FROST/tbtc-signer protocol messages (Phase 1B) -and inherits the network layer's operator-key signing -automatically. Raw JSON does not appear on the wire. +*Decision: signed-body protobuf envelopes +(`pkg/frost/roast/gen/pb/evidence.proto`), routed via the +`net.Message` interface.* + +Evidence signatures cover exact serialized body bytes, and those +bytes travel verbatim: a snapshot is +`SignedLocalEvidenceSnapshot{body, operator_signature}` where +`body` is the serialized `LocalEvidenceSnapshotBody`, and a +transition message is `SignedTransitionMessage{body, +coordinator_signature}` whose body embeds every member's signed +snapshot envelope verbatim (`repeated bytes signed_snapshots`). +A verifier checks each signature over the bytes it received and +only then parses them; nothing in the evidence chain is ever +re-encoded. Signature validity therefore never depends on any +serializer's canonical form -- across protobuf library versions +or across languages (the Phase 7 Rust signer verifies and parses +these same bytes). Producers marshal a body exactly once, at +signing time, and transmit those bytes. + +The original Phase 3 implementation used canonical JSON +(`json.Marshal` over field-order-stable structs) as the signed +encoding; it was replaced before any persisted or cross-component +evidence existed because canonical-JSON byte stability is a +Go-implementation accident, not a portable contract. === Maximum evidence-message size @@ -645,7 +663,7 @@ chunking.* Under coordinator-aggregation, the per-transition payload is `O(N)` not `O(N^2)`. At a 100-signer group with all four -quotas saturated the JSON-encoded bundle is ~10-20 KiB, +quotas saturated the encoded bundle is ~10-20 KiB, comfortably within libp2p's per-message limits. === Session-handle binding TTL eviction diff --git a/pkg/frost/roast/bundle_aggregation_test.go b/pkg/frost/roast/bundle_aggregation_test.go index 412c63db24..569f7dc9e2 100644 --- a/pkg/frost/roast/bundle_aggregation_test.go +++ b/pkg/frost/roast/bundle_aggregation_test.go @@ -38,7 +38,7 @@ func signSnapshotForTest( ) *LocalEvidenceSnapshot { t.Helper() signer := &fakeSigner{id: snap.SenderID()} - payload, err := CanonicalSnapshotBytes(snap) + payload, err := snap.SignableBytes() if err != nil { t.Fatalf("canonical: %v", err) } @@ -418,7 +418,7 @@ func TestVerifyBundle_DetectsCoordinatorSignatureForgery(t *testing.T) { // Tamper: re-sign the bundle as a different (non-elected) member. const wrongSigner group.MemberIndex = 99 bundle.CoordinatorIDValue = uint32(wrongSigner) - payload, _ := CanonicalBundleBytes(bundle) + payload, _ := bundle.SignableBytes() forged, _ := (&fakeSigner{id: wrongSigner}).Sign(payload) bundle.CoordinatorSignature = forged @@ -458,7 +458,7 @@ func TestVerifyBundle_DetectsSnapshotSignatureForgery(t *testing.T) { // bundle with the new garbage signature so the bundle-level // signature appears valid but the snapshot signature does not. bundle.Bundle[0].OperatorSignature = []byte{0xde, 0xad} - payload, _ := CanonicalBundleBytes(bundle) + payload, _ := bundle.SignableBytes() resign, _ := (&fakeSigner{id: elected}).Sign(payload) bundle.CoordinatorSignature = resign diff --git a/pkg/frost/roast/coordinator_state.go b/pkg/frost/roast/coordinator_state.go index afbd32792a..0dc22be852 100644 --- a/pkg/frost/roast/coordinator_state.go +++ b/pkg/frost/roast/coordinator_state.go @@ -348,13 +348,13 @@ func (c *inMemoryCoordinator) RecordEvidence( } if existing, present := record.snapshots[snapshot.SenderID()]; present { - existingBytes, err := CanonicalSnapshotBytes(existing) + existingBytes, err := existing.SignableBytes() if err != nil { - return fmt.Errorf("coordinator: canonical existing: %w", err) + return fmt.Errorf("coordinator: existing signable bytes: %w", err) } - newBytes, err := CanonicalSnapshotBytes(snapshot) + newBytes, err := snapshot.SignableBytes() if err != nil { - return fmt.Errorf("coordinator: canonical new: %w", err) + return fmt.Errorf("coordinator: new signable bytes: %w", err) } if !bytes.Equal(existingBytes, newBytes) || !bytes.Equal(existing.OperatorSignature, snapshot.OperatorSignature) { @@ -414,10 +414,10 @@ func (c *inMemoryCoordinator) AggregateBundle( CoordinatorIDValue: uint32(coord), Bundle: bundle, } - payload, err := CanonicalBundleBytes(msg) + payload, err := msg.SignableBytes() if err != nil { c.markTransitionedLocked(handle.id) - return nil, fmt.Errorf("coordinator: canonical bundle: %w", err) + return nil, fmt.Errorf("coordinator: bundle signable bytes: %w", err) } sig, err := c.signer.Sign(payload) if err != nil { diff --git a/pkg/frost/roast/gen/pb/evidence.pb.go b/pkg/frost/roast/gen/pb/evidence.pb.go new file mode 100644 index 0000000000..e026531675 --- /dev/null +++ b/pkg/frost/roast/gen/pb/evidence.pb.go @@ -0,0 +1,560 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.3 +// protoc v6.33.4 +// source: pkg/frost/roast/gen/pb/evidence.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// The byte stream a signer's operator key signs. Carried as exact bytes in +// SignedLocalEvidenceSnapshot.body. +type LocalEvidenceSnapshotBody struct { + state protoimpl.MessageState `protogen:"open.v1"` + SenderId uint32 `protobuf:"varint,1,opt,name=sender_id,json=senderId,proto3" json:"sender_id,omitempty"` + // 32-byte attempt context hash binding the evidence to one attempt. + AttemptContextHash []byte `protobuf:"bytes,2,opt,name=attempt_context_hash,json=attemptContextHash,proto3" json:"attempt_context_hash,omitempty"` + // Sorted ascending by sender; producers emit the canonical order, and + // receivers validate it after signature verification. + Overflows []*OverflowEntry `protobuf:"bytes,3,rep,name=overflows,proto3" json:"overflows,omitempty"` + // Sorted ascending by (sender, reason). + Rejects []*RejectEntry `protobuf:"bytes,4,rep,name=rejects,proto3" json:"rejects,omitempty"` + // Sorted ascending by sender. + Conflicts []*ConflictEntry `protobuf:"bytes,5,rep,name=conflicts,proto3" json:"conflicts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LocalEvidenceSnapshotBody) Reset() { + *x = LocalEvidenceSnapshotBody{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LocalEvidenceSnapshotBody) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LocalEvidenceSnapshotBody) ProtoMessage() {} + +func (x *LocalEvidenceSnapshotBody) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LocalEvidenceSnapshotBody.ProtoReflect.Descriptor instead. +func (*LocalEvidenceSnapshotBody) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{0} +} + +func (x *LocalEvidenceSnapshotBody) GetSenderId() uint32 { + if x != nil { + return x.SenderId + } + return 0 +} + +func (x *LocalEvidenceSnapshotBody) GetAttemptContextHash() []byte { + if x != nil { + return x.AttemptContextHash + } + return nil +} + +func (x *LocalEvidenceSnapshotBody) GetOverflows() []*OverflowEntry { + if x != nil { + return x.Overflows + } + return nil +} + +func (x *LocalEvidenceSnapshotBody) GetRejects() []*RejectEntry { + if x != nil { + return x.Rejects + } + return nil +} + +func (x *LocalEvidenceSnapshotBody) GetConflicts() []*ConflictEntry { + if x != nil { + return x.Conflicts + } + return nil +} + +type OverflowEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sender uint32 `protobuf:"varint,1,opt,name=sender,proto3" json:"sender,omitempty"` + Count uint64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OverflowEntry) Reset() { + *x = OverflowEntry{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OverflowEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OverflowEntry) ProtoMessage() {} + +func (x *OverflowEntry) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OverflowEntry.ProtoReflect.Descriptor instead. +func (*OverflowEntry) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{1} +} + +func (x *OverflowEntry) GetSender() uint32 { + if x != nil { + return x.Sender + } + return 0 +} + +func (x *OverflowEntry) GetCount() uint64 { + if x != nil { + return x.Count + } + return 0 +} + +type RejectEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sender uint32 `protobuf:"varint,1,opt,name=sender,proto3" json:"sender,omitempty"` + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + Count uint64 `protobuf:"varint,3,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RejectEntry) Reset() { + *x = RejectEntry{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RejectEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RejectEntry) ProtoMessage() {} + +func (x *RejectEntry) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RejectEntry.ProtoReflect.Descriptor instead. +func (*RejectEntry) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{2} +} + +func (x *RejectEntry) GetSender() uint32 { + if x != nil { + return x.Sender + } + return 0 +} + +func (x *RejectEntry) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *RejectEntry) GetCount() uint64 { + if x != nil { + return x.Count + } + return 0 +} + +type ConflictEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sender uint32 `protobuf:"varint,1,opt,name=sender,proto3" json:"sender,omitempty"` + Count uint64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConflictEntry) Reset() { + *x = ConflictEntry{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConflictEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConflictEntry) ProtoMessage() {} + +func (x *ConflictEntry) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConflictEntry.ProtoReflect.Descriptor instead. +func (*ConflictEntry) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{3} +} + +func (x *ConflictEntry) GetSender() uint32 { + if x != nil { + return x.Sender + } + return 0 +} + +func (x *ConflictEntry) GetCount() uint64 { + if x != nil { + return x.Count + } + return 0 +} + +// The on-wire snapshot message: exact signed body bytes plus the operator +// signature over them. +type SignedLocalEvidenceSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + Body []byte `protobuf:"bytes,1,opt,name=body,proto3" json:"body,omitempty"` + OperatorSignature []byte `protobuf:"bytes,2,opt,name=operator_signature,json=operatorSignature,proto3" json:"operator_signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignedLocalEvidenceSnapshot) Reset() { + *x = SignedLocalEvidenceSnapshot{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignedLocalEvidenceSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignedLocalEvidenceSnapshot) ProtoMessage() {} + +func (x *SignedLocalEvidenceSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SignedLocalEvidenceSnapshot.ProtoReflect.Descriptor instead. +func (*SignedLocalEvidenceSnapshot) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{4} +} + +func (x *SignedLocalEvidenceSnapshot) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *SignedLocalEvidenceSnapshot) GetOperatorSignature() []byte { + if x != nil { + return x.OperatorSignature + } + return nil +} + +// The byte stream the elected coordinator signs. signed_snapshots carries +// each member's SignedLocalEvidenceSnapshot envelope verbatim as received, +// so the coordinator attests to the exact signed snapshots it assembled, +// in order, and downstream verifiers re-check the operator signatures over +// those same exact bytes. +type TransitionMessageBody struct { + state protoimpl.MessageState `protogen:"open.v1"` + AttemptContextHash []byte `protobuf:"bytes,1,opt,name=attempt_context_hash,json=attemptContextHash,proto3" json:"attempt_context_hash,omitempty"` + CoordinatorId uint32 `protobuf:"varint,2,opt,name=coordinator_id,json=coordinatorId,proto3" json:"coordinator_id,omitempty"` + SignedSnapshots [][]byte `protobuf:"bytes,3,rep,name=signed_snapshots,json=signedSnapshots,proto3" json:"signed_snapshots,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransitionMessageBody) Reset() { + *x = TransitionMessageBody{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransitionMessageBody) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransitionMessageBody) ProtoMessage() {} + +func (x *TransitionMessageBody) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransitionMessageBody.ProtoReflect.Descriptor instead. +func (*TransitionMessageBody) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{5} +} + +func (x *TransitionMessageBody) GetAttemptContextHash() []byte { + if x != nil { + return x.AttemptContextHash + } + return nil +} + +func (x *TransitionMessageBody) GetCoordinatorId() uint32 { + if x != nil { + return x.CoordinatorId + } + return 0 +} + +func (x *TransitionMessageBody) GetSignedSnapshots() [][]byte { + if x != nil { + return x.SignedSnapshots + } + return nil +} + +// The on-wire transition message: exact signed body bytes plus the +// coordinator signature over them. +type SignedTransitionMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + Body []byte `protobuf:"bytes,1,opt,name=body,proto3" json:"body,omitempty"` + CoordinatorSignature []byte `protobuf:"bytes,2,opt,name=coordinator_signature,json=coordinatorSignature,proto3" json:"coordinator_signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignedTransitionMessage) Reset() { + *x = SignedTransitionMessage{} + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignedTransitionMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignedTransitionMessage) ProtoMessage() {} + +func (x *SignedTransitionMessage) ProtoReflect() protoreflect.Message { + mi := &file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SignedTransitionMessage.ProtoReflect.Descriptor instead. +func (*SignedTransitionMessage) Descriptor() ([]byte, []int) { + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP(), []int{6} +} + +func (x *SignedTransitionMessage) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *SignedTransitionMessage) GetCoordinatorSignature() []byte { + if x != nil { + return x.CoordinatorSignature + } + return nil +} + +var File_pkg_frost_roast_gen_pb_evidence_proto protoreflect.FileDescriptor + +var file_pkg_frost_roast_gen_pb_evidence_proto_rawDesc = []byte{ + 0x0a, 0x25, 0x70, 0x6b, 0x67, 0x2f, 0x66, 0x72, 0x6f, 0x73, 0x74, 0x2f, 0x72, 0x6f, 0x61, 0x73, + 0x74, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x62, 0x2f, 0x65, 0x76, 0x69, 0x64, 0x65, 0x6e, 0x63, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x72, 0x6f, 0x61, 0x73, 0x74, 0x22, 0x80, + 0x02, 0x0a, 0x19, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x45, 0x76, 0x69, 0x64, 0x65, 0x6e, 0x63, 0x65, + 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1b, 0x0a, 0x09, + 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x08, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, + 0x65, 0x6d, 0x70, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x68, 0x61, 0x73, + 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x12, 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, + 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12, 0x32, 0x0a, 0x09, 0x6f, + 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, + 0x2e, 0x72, 0x6f, 0x61, 0x73, 0x74, 0x2e, 0x4f, 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, 0x6f, 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x73, 0x12, + 0x2c, 0x0a, 0x07, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x12, 0x2e, 0x72, 0x6f, 0x61, 0x73, 0x74, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x32, 0x0a, + 0x09, 0x63, 0x6f, 0x6e, 0x66, 0x6c, 0x69, 0x63, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x14, 0x2e, 0x72, 0x6f, 0x61, 0x73, 0x74, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x6c, 0x69, 0x63, + 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x66, 0x6c, 0x69, 0x63, 0x74, + 0x73, 0x22, 0x3d, 0x0a, 0x0d, 0x4f, 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x22, 0x53, 0x0a, 0x0b, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x16, 0x0a, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, + 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3d, 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x66, 0x6c, 0x69, 0x63, + 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x14, + 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x60, 0x0a, 0x1b, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x4c, 0x6f, + 0x63, 0x61, 0x6c, 0x45, 0x76, 0x69, 0x64, 0x65, 0x6e, 0x63, 0x65, 0x53, 0x6e, 0x61, 0x70, 0x73, + 0x68, 0x6f, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x2d, 0x0a, 0x12, 0x6f, 0x70, 0x65, 0x72, 0x61, + 0x74, 0x6f, 0x72, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x11, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x69, 0x67, + 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0x9b, 0x01, 0x0a, 0x15, 0x54, 0x72, 0x61, 0x6e, 0x73, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x6f, 0x64, 0x79, + 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, + 0x65, 0x78, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x12, + 0x61, 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x48, 0x61, + 0x73, 0x68, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x6f, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x63, 0x6f, 0x6f, 0x72, + 0x64, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x69, 0x67, + 0x6e, 0x65, 0x64, 0x5f, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x73, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x53, 0x6e, 0x61, 0x70, 0x73, + 0x68, 0x6f, 0x74, 0x73, 0x22, 0x62, 0x0a, 0x17, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x54, 0x72, + 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, + 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, + 0x6f, 0x64, 0x79, 0x12, 0x33, 0x0a, 0x15, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, + 0x6f, 0x72, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x14, 0x63, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x53, + 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_pkg_frost_roast_gen_pb_evidence_proto_rawDescOnce sync.Once + file_pkg_frost_roast_gen_pb_evidence_proto_rawDescData = file_pkg_frost_roast_gen_pb_evidence_proto_rawDesc +) + +func file_pkg_frost_roast_gen_pb_evidence_proto_rawDescGZIP() []byte { + file_pkg_frost_roast_gen_pb_evidence_proto_rawDescOnce.Do(func() { + file_pkg_frost_roast_gen_pb_evidence_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_frost_roast_gen_pb_evidence_proto_rawDescData) + }) + return file_pkg_frost_roast_gen_pb_evidence_proto_rawDescData +} + +var file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_pkg_frost_roast_gen_pb_evidence_proto_goTypes = []any{ + (*LocalEvidenceSnapshotBody)(nil), // 0: roast.LocalEvidenceSnapshotBody + (*OverflowEntry)(nil), // 1: roast.OverflowEntry + (*RejectEntry)(nil), // 2: roast.RejectEntry + (*ConflictEntry)(nil), // 3: roast.ConflictEntry + (*SignedLocalEvidenceSnapshot)(nil), // 4: roast.SignedLocalEvidenceSnapshot + (*TransitionMessageBody)(nil), // 5: roast.TransitionMessageBody + (*SignedTransitionMessage)(nil), // 6: roast.SignedTransitionMessage +} +var file_pkg_frost_roast_gen_pb_evidence_proto_depIdxs = []int32{ + 1, // 0: roast.LocalEvidenceSnapshotBody.overflows:type_name -> roast.OverflowEntry + 2, // 1: roast.LocalEvidenceSnapshotBody.rejects:type_name -> roast.RejectEntry + 3, // 2: roast.LocalEvidenceSnapshotBody.conflicts:type_name -> roast.ConflictEntry + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_pkg_frost_roast_gen_pb_evidence_proto_init() } +func file_pkg_frost_roast_gen_pb_evidence_proto_init() { + if File_pkg_frost_roast_gen_pb_evidence_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_pkg_frost_roast_gen_pb_evidence_proto_rawDesc, + NumEnums: 0, + NumMessages: 7, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pkg_frost_roast_gen_pb_evidence_proto_goTypes, + DependencyIndexes: file_pkg_frost_roast_gen_pb_evidence_proto_depIdxs, + MessageInfos: file_pkg_frost_roast_gen_pb_evidence_proto_msgTypes, + }.Build() + File_pkg_frost_roast_gen_pb_evidence_proto = out.File + file_pkg_frost_roast_gen_pb_evidence_proto_rawDesc = nil + file_pkg_frost_roast_gen_pb_evidence_proto_goTypes = nil + file_pkg_frost_roast_gen_pb_evidence_proto_depIdxs = nil +} diff --git a/pkg/frost/roast/gen/pb/evidence.proto b/pkg/frost/roast/gen/pb/evidence.proto new file mode 100644 index 0000000000..ac78895eb0 --- /dev/null +++ b/pkg/frost/roast/gen/pb/evidence.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +option go_package = "./pb"; +package roast; + +// Evidence wire format (RFC-21 Layer B). +// +// Signatures cover exact serialized body bytes, and those bytes travel +// verbatim: a verifier checks the signature over the bytes it received and +// only then parses them. Nothing in the evidence chain is ever re-encoded, +// so signature validity never depends on any serializer's canonical form - +// across protobuf library versions or across languages (the Phase 7 Rust +// signer verifies and parses these same bytes). + +// The byte stream a signer's operator key signs. Carried as exact bytes in +// SignedLocalEvidenceSnapshot.body. +message LocalEvidenceSnapshotBody { + uint32 sender_id = 1; + // 32-byte attempt context hash binding the evidence to one attempt. + bytes attempt_context_hash = 2; + // Sorted ascending by sender; producers emit the canonical order, and + // receivers validate it after signature verification. + repeated OverflowEntry overflows = 3; + // Sorted ascending by (sender, reason). + repeated RejectEntry rejects = 4; + // Sorted ascending by sender. + repeated ConflictEntry conflicts = 5; +} + +message OverflowEntry { + uint32 sender = 1; + uint64 count = 2; +} + +message RejectEntry { + uint32 sender = 1; + string reason = 2; + uint64 count = 3; +} + +message ConflictEntry { + uint32 sender = 1; + uint64 count = 2; +} + +// The on-wire snapshot message: exact signed body bytes plus the operator +// signature over them. +message SignedLocalEvidenceSnapshot { + bytes body = 1; + bytes operator_signature = 2; +} + +// The byte stream the elected coordinator signs. signed_snapshots carries +// each member's SignedLocalEvidenceSnapshot envelope verbatim as received, +// so the coordinator attests to the exact signed snapshots it assembled, +// in order, and downstream verifiers re-check the operator signatures over +// those same exact bytes. +message TransitionMessageBody { + bytes attempt_context_hash = 1; + uint32 coordinator_id = 2; + repeated bytes signed_snapshots = 3; +} + +// The on-wire transition message: exact signed body bytes plus the +// coordinator signature over them. +message SignedTransitionMessage { + bytes body = 1; + bytes coordinator_signature = 2; +} diff --git a/pkg/frost/roast/multi_coordinator_soak_test.go b/pkg/frost/roast/multi_coordinator_soak_test.go index 14e5631a39..18caa65e5c 100644 --- a/pkg/frost/roast/multi_coordinator_soak_test.go +++ b/pkg/frost/roast/multi_coordinator_soak_test.go @@ -149,7 +149,7 @@ func soakAttempt( evidence.Overflows[sender]++ } snap := NewLocalEvidenceSnapshot(n.self, ctx.Hash(), evidence) - payload, _ := CanonicalSnapshotBytes(snap) + payload, _ := snap.SignableBytes() sig, _ := n.signer.Sign(payload) snap.OperatorSignature = sig snaps = append(snaps, signedSnap{from: n.self, snapshot: snap}) @@ -376,7 +376,7 @@ func TestSoak_InfeasibilityWhenBelowThreshold(t *testing.T) { continue } snap := NewLocalEvidenceSnapshot(n.self, prev.Hash(), attempt.Evidence{}) - payload, _ := CanonicalSnapshotBytes(snap) + payload, _ := snap.SignableBytes() sig, _ := n.signer.Sign(payload) snap.OperatorSignature = sig for _, b := range begins { diff --git a/pkg/frost/roast/signature.go b/pkg/frost/roast/signature.go index 7e841ee6be..fb107447e3 100644 --- a/pkg/frost/roast/signature.go +++ b/pkg/frost/roast/signature.go @@ -2,7 +2,6 @@ package roast import ( "bytes" - "encoding/json" "errors" "fmt" @@ -23,7 +22,8 @@ import ( // goroutines. type Signer interface { // Sign returns a signature over the canonical payload produced - // by CanonicalSnapshotBytes or CanonicalBundleBytes. The + // by LocalEvidenceSnapshot.SignableBytes or + // TransitionMessage.SignableBytes. The // returned signature is treated as opaque bytes by the // coordinator state machine; the SignatureVerifier is the only // component that interprets the byte sequence. @@ -96,51 +96,6 @@ func (noOpSignatureVerifier) Verify(_, _ []byte, _ group.MemberIndex) error { return nil } -// CanonicalSnapshotBytes returns the byte stream over which a signer -// signs a LocalEvidenceSnapshot. The encoding excludes the -// OperatorSignature field so a verifier can recompute the bytes from -// the snapshot it received over the wire. -// -// The encoding is canonical JSON: the Overflows slice must already -// be sorted ascending by Sender (NewLocalEvidenceSnapshot guarantees -// this; Unmarshal enforces it). Any two honest signers seeing the -// same snapshot fields produce byte-identical canonical bytes. -func CanonicalSnapshotBytes(s *LocalEvidenceSnapshot) ([]byte, error) { - if s == nil { - return nil, errors.New("roast: cannot canonicalise a nil snapshot") - } - clone := LocalEvidenceSnapshot{ - SenderIDValue: s.SenderIDValue, - AttemptContextHash: s.AttemptContextHash, - Overflows: s.Overflows, - // OperatorSignature intentionally omitted -- it is the - // signature *over* this canonical encoding, not part of it. - } - return json.Marshal(&clone) -} - -// CanonicalBundleBytes returns the byte stream over which the elected -// coordinator signs a TransitionMessage. The encoding excludes the -// CoordinatorSignature field but *includes* every snapshot's -// OperatorSignature -- the coordinator's signature attests that -// these specific signed snapshots were assembled in this specific -// order. -// -// The Bundle slice must already be sorted ascending by SenderID; the -// canonical encoding assumes that invariant holds. -func CanonicalBundleBytes(m *TransitionMessage) ([]byte, error) { - if m == nil { - return nil, errors.New("roast: cannot canonicalise a nil transition message") - } - clone := TransitionMessage{ - AttemptContextHash: m.AttemptContextHash, - CoordinatorIDValue: m.CoordinatorIDValue, - Bundle: m.Bundle, - // CoordinatorSignature intentionally omitted. - } - return json.Marshal(&clone) -} - // verifySnapshotSignature checks the OperatorSignature on a single // LocalEvidenceSnapshot against the verifier's record of the // snapshot's sender's operator key. @@ -155,9 +110,9 @@ func verifySnapshotSignature( snapshot.SenderID(), ) } - payload, err := CanonicalSnapshotBytes(snapshot) + payload, err := snapshot.SignableBytes() if err != nil { - return fmt.Errorf("canonical snapshot bytes: %w", err) + return fmt.Errorf("snapshot signable bytes: %w", err) } if err := verifier.Verify( payload, @@ -198,9 +153,9 @@ func verifyBundleSignature( expectedCoordinator, ) } - payload, err := CanonicalBundleBytes(msg) + payload, err := msg.SignableBytes() if err != nil { - return fmt.Errorf("canonical bundle bytes: %w", err) + return fmt.Errorf("bundle signable bytes: %w", err) } if err := verifier.Verify( payload, diff --git a/pkg/frost/roast/signature_test.go b/pkg/frost/roast/signature_test.go index 1c37c53380..f7f3cc150a 100644 --- a/pkg/frost/roast/signature_test.go +++ b/pkg/frost/roast/signature_test.go @@ -85,12 +85,12 @@ func TestCanonicalSnapshotBytes_ExcludesOperatorSignature(t *testing.T) { snap := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{ Overflows: map[group.MemberIndex]uint{1: 2, 3: 4}, }) - withoutSig, err := CanonicalSnapshotBytes(snap) + withoutSig, err := snap.SignableBytes() if err != nil { t.Fatalf("canonical bytes (no sig): %v", err) } snap.OperatorSignature = []byte{0xff, 0xee} - withSig, err := CanonicalSnapshotBytes(snap) + withSig, err := snap.SignableBytes() if err != nil { t.Fatalf("canonical bytes (with sig): %v", err) } @@ -103,45 +103,52 @@ func TestCanonicalSnapshotBytes_ExcludesOperatorSignature(t *testing.T) { } func TestCanonicalSnapshotBytes_RejectsNil(t *testing.T) { - if _, err := CanonicalSnapshotBytes(nil); err == nil { + if _, err := (*LocalEvidenceSnapshot)(nil).SignableBytes(); err == nil { t.Fatal("expected error for nil snapshot") } } -func TestCanonicalBundleBytes_ExcludesCoordinatorSignatureButIncludesSnapshots(t *testing.T) { - msg := buildValidTransitionMessage() - // Make sure each snapshot's OperatorSignature is non-empty so we - // can verify they appear in the canonical bytes. - for i := range msg.Bundle { - msg.Bundle[i].OperatorSignature = []byte{byte(i + 1)} +func TestBundleSignableBytes_ExcludeCoordinatorSignatureButIncludeSnapshotSignatures(t *testing.T) { + // Two fresh messages identical except for the coordinator signature + // must sign over the same bytes (the signature is over the body, not + // part of it). Fresh messages are required because signable bytes are + // computed once and cached. + msgA := buildValidTransitionMessage() + msgA.CoordinatorSignature = bytes.Repeat([]byte{0xaa}, 64) + msgB := buildValidTransitionMessage() + msgB.CoordinatorSignature = bytes.Repeat([]byte{0xbb}, 64) + bytesA, err := msgA.SignableBytes() + if err != nil { + t.Fatalf("signable bundle A: %v", err) } - msg.CoordinatorSignature = []byte{0xaa, 0xbb} - canonical, err := CanonicalBundleBytes(msg) + bytesB, err := msgB.SignableBytes() if err != nil { - t.Fatalf("canonical bundle: %v", err) + t.Fatalf("signable bundle B: %v", err) } - // CoordinatorSignature bytes should not appear in the canonical - // payload (omitempty + nil in clone). - if bytes.Contains(canonical, []byte{0xaa, 0xbb}) { - t.Fatalf( - "CoordinatorSignature 0xaabb leaked into canonical bytes: %s", - string(canonical), - ) + if !bytes.Equal(bytesA, bytesB) { + t.Fatal("coordinator signature leaked into the signed bundle body") + } + + // A distinctive per-snapshot operator signature must appear verbatim + // inside the signed bundle body: the coordinator attests to the exact + // signed snapshot envelopes. + distinctive := bytes.Repeat([]byte{0xc7, 0x3d}, 16) + msgC := buildValidTransitionMessage() + msgC.Bundle[0].OperatorSignature = distinctive + bytesC, err := msgC.SignableBytes() + if err != nil { + t.Fatalf("signable bundle C: %v", err) + } + if !bytes.Contains(bytesC, distinctive) { + t.Fatal("per-snapshot operator signature missing from signed bundle body") } - // Each snapshot's OperatorSignature should appear via base64 - // "AQ==", "Ag==", "Aw==" (1, 2, 3 → 0x01, 0x02, 0x03). - for _, want := range []string{`"AQ=="`, `"Ag=="`, `"Aw=="`} { - if !bytes.Contains(canonical, []byte(want)) { - t.Fatalf( - "expected per-snapshot OperatorSignature %q in canonical bundle: %s", - want, string(canonical), - ) - } + if bytes.Equal(bytesA, bytesC) { + t.Fatal("changing a snapshot operator signature must change the bundle body") } } func TestCanonicalBundleBytes_RejectsNil(t *testing.T) { - if _, err := CanonicalBundleBytes(nil); err == nil { + if _, err := (*TransitionMessage)(nil).SignableBytes(); err == nil { t.Fatal("expected error for nil message") } } @@ -149,7 +156,7 @@ func TestCanonicalBundleBytes_RejectsNil(t *testing.T) { func TestVerifySnapshotSignature_RoundTripsThroughFakeSignerVerifier(t *testing.T) { signer := &fakeSigner{id: 7} snap := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) - payload, err := CanonicalSnapshotBytes(snap) + payload, err := snap.SignableBytes() if err != nil { t.Fatalf("canonical: %v", err) } @@ -174,13 +181,16 @@ func TestVerifySnapshotSignature_RejectsMissingSignature(t *testing.T) { func TestVerifySnapshotSignature_RejectsTamperedPayload(t *testing.T) { signer := &fakeSigner{id: 7} snap := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{}) - payload, _ := CanonicalSnapshotBytes(snap) + payload, _ := snap.SignableBytes() sig, _ := signer.Sign(payload) - snap.OperatorSignature = sig - // Tamper: change the overflow set; the recomputed canonical - // bytes will no longer match. - snap.Overflows = []OverflowEntry{{Sender: 99, Count: 1}} - if err := verifySnapshotSignature(fakeVerifier{}, snap); !errors.Is(err, ErrSignatureInvalid) { + // Attach the signature to a *different* snapshot (fresh struct, so + // no cached bytes): its signed body differs, so verification over + // its bytes must fail. + tampered := NewLocalEvidenceSnapshot(7, pinnedContextHash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{99: 1}, + }) + tampered.OperatorSignature = sig + if err := verifySnapshotSignature(fakeVerifier{}, tampered); !errors.Is(err, ErrSignatureInvalid) { t.Fatalf("expected ErrSignatureInvalid, got %v", err) } } @@ -190,7 +200,7 @@ func TestVerifyBundleSignature_RoundTrip(t *testing.T) { msg := buildValidTransitionMessage() msg.CoordinatorIDValue = 11 msg.CoordinatorSignature = nil - payload, _ := CanonicalBundleBytes(msg) + payload, _ := msg.SignableBytes() sig, _ := signer.Sign(payload) msg.CoordinatorSignature = sig if err := verifyBundleSignature(fakeVerifier{}, msg, 11); err != nil { diff --git a/pkg/frost/roast/transition_message.go b/pkg/frost/roast/transition_message.go index f8747bd4b7..4422e9344f 100644 --- a/pkg/frost/roast/transition_message.go +++ b/pkg/frost/roast/transition_message.go @@ -2,12 +2,14 @@ package roast import ( "bytes" - "encoding/json" "errors" "fmt" "sort" + "google.golang.org/protobuf/proto" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" "github.com/keep-network/keep-core/pkg/protocol/group" ) @@ -49,8 +51,8 @@ const MaxCoordinatorSignatureBytes = 256 // two honest signers serialising the same evidence produce // byte-identical JSON. type OverflowEntry struct { - Sender group.MemberIndex `json:"sender"` - Count uint `json:"count"` + Sender group.MemberIndex + Count uint } // RejectEntry carries one per-(sender, reason) reject count from an @@ -58,17 +60,17 @@ type OverflowEntry struct { // 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"` + Sender group.MemberIndex + Reason string + Count uint } // 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"` + Sender group.MemberIndex + Count uint } // LocalEvidenceSnapshot is the per-signer signed evidence produced @@ -78,31 +80,40 @@ type ConflictEntry struct { // Phase 3.2 (this file) defines the wire type only. Signature // computation and verification land in Phase 3.3. type LocalEvidenceSnapshot struct { - SenderIDValue uint32 `json:"senderID"` + SenderIDValue uint32 // AttemptContextHash binds the snapshot to the attempt the // evidence describes. Always exactly 32 bytes. - AttemptContextHash []byte `json:"attemptContextHash"` + AttemptContextHash []byte // Overflows is the canonical sorted form of the // attempt.Evidence.Overflows map; sorted ascending by Sender. // Omitted when no overflow events were observed. - Overflows []OverflowEntry `json:"overflows,omitempty"` + Overflows []OverflowEntry // 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"` + Rejects []RejectEntry // 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"` + Conflicts []ConflictEntry // OperatorSignature is the signer's operator-key signature over - // the canonical encoding of (senderID, attemptContextHash, - // overflows, rejects, conflicts). Phase 3.3 defines the - // canonical-encoding algorithm and the verification routine. - OperatorSignature []byte `json:"operatorSignature,omitempty"` + // SignableBytes(): the serialized protobuf body of (senderID, + // attemptContextHash, overflows, rejects, conflicts). + OperatorSignature []byte + + // signedBody caches the exact serialized body bytes the + // OperatorSignature covers: marshaled once at signing time for + // self-authored snapshots, or the received bytes verbatim for + // parsed ones. Evidence fields must not be mutated once set. + signedBody []byte + // wireEnvelope caches the exact on-wire envelope (body + + // signature): the received bytes verbatim for parsed snapshots, + // or built once after signing for self-authored ones. + wireEnvelope []byte } // NewLocalEvidenceSnapshot converts an attempt.Evidence map into a @@ -207,24 +218,39 @@ func (s *LocalEvidenceSnapshot) Type() string { return LocalEvidenceSnapshotType } -// Marshal serialises the snapshot to canonical JSON. The Overflows -// slice is sorted by Sender ascending in NewLocalEvidenceSnapshot -// so two honest signers with the same evidence produce -// byte-identical bytes. +// Marshal serialises the snapshot as a SignedLocalEvidenceSnapshot +// envelope: the exact signed body bytes plus the operator signature. +// For a snapshot parsed off the wire the received envelope is +// returned verbatim, so evidence bytes survive any re-broadcast +// unchanged. The snapshot must be signed first. func (s *LocalEvidenceSnapshot) Marshal() ([]byte, error) { - return json.Marshal(s) + return s.wireEnvelopeBytes() } -// Unmarshal parses canonical JSON into the snapshot and validates -// the resulting structure. +// Unmarshal parses a SignedLocalEvidenceSnapshot envelope, retains +// the received body and envelope bytes verbatim (signature +// verification runs over exactly these bytes), populates the +// evidence fields from the body, and validates the structure. func (s *LocalEvidenceSnapshot) Unmarshal(data []byte) error { - if err := json.Unmarshal(data, s); err != nil { - return err + var envelope pb.SignedLocalEvidenceSnapshot + if err := proto.Unmarshal(data, &envelope); err != nil { + return fmt.Errorf("local evidence snapshot: parse envelope: %w", err) + } + if len(envelope.Body) == 0 { + return errors.New("local evidence snapshot: empty body") } + var body pb.LocalEvidenceSnapshotBody + if err := proto.Unmarshal(envelope.Body, &body); err != nil { + return fmt.Errorf("local evidence snapshot: parse body: %w", err) + } + snapshotFieldsFromBody(s, &body) + s.OperatorSignature = append([]byte(nil), envelope.OperatorSignature...) + s.signedBody = append([]byte(nil), envelope.Body...) + s.wireEnvelope = append([]byte(nil), data...) return s.Validate() } -// Validate runs the structural checks Unmarshal applies after a JSON +// Validate runs the structural checks Unmarshal applies after a // decode. Exposed publicly so callers that construct snapshots in // memory (e.g. the Coordinator state machine) can validate without // a marshal/unmarshal round-trip. @@ -292,19 +318,22 @@ type TransitionMessage struct { // AttemptContextHash identifies the attempt the bundle // describes. Must match every snapshot's AttemptContextHash. // Always exactly 32 bytes. - AttemptContextHash []byte `json:"attemptContextHash"` + AttemptContextHash []byte // CoordinatorIDValue is the member index of the elected // coordinator that produced this bundle. - CoordinatorIDValue uint32 `json:"coordinatorID"` + CoordinatorIDValue uint32 // Bundle is the canonical sorted-by-SenderID list of signed // evidence snapshots aggregated by the coordinator. - Bundle []LocalEvidenceSnapshot `json:"bundle"` + Bundle []LocalEvidenceSnapshot // CoordinatorSignature is the coordinator's operator-key - // signature over the canonical encoding of the bundle. 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. - CoordinatorSignature []byte `json:"coordinatorSignature,omitempty"` + // signature over SignableBytes(): the serialized protobuf body + // embedding every snapshot's signed envelope verbatim. + CoordinatorSignature []byte + + // signedBody and wireEnvelope cache exact bytes with the same + // semantics as the LocalEvidenceSnapshot caches. + signedBody []byte + wireEnvelope []byte } // CoordinatorID returns the coordinator member index as a @@ -329,24 +358,78 @@ func (m *TransitionMessage) Type() string { return TransitionMessageType } -// Marshal serialises the message to canonical JSON. +// Marshal serialises the message as a SignedTransitionMessage +// envelope: the exact signed body bytes plus the coordinator +// signature. For a message parsed off the wire the received envelope +// is returned verbatim. The message must be signed first. func (m *TransitionMessage) Marshal() ([]byte, error) { - return json.Marshal(m) + if m.wireEnvelope != nil { + return m.wireEnvelope, nil + } + if len(m.CoordinatorSignature) == 0 { + return nil, errors.New( + "transition message: must be signed before wire encoding", + ) + } + body, err := m.SignableBytes() + if err != nil { + return nil, err + } + envelope, err := proto.Marshal(&pb.SignedTransitionMessage{ + Body: body, + CoordinatorSignature: m.CoordinatorSignature, + }) + if err != nil { + return nil, fmt.Errorf("transition message: marshal envelope: %w", err) + } + m.wireEnvelope = envelope + return envelope, nil } -// Unmarshal parses canonical JSON into the message and validates -// the structure: hash length, bundle size cap, signature size cap, -// snapshot validity, bundle ordering by SenderID ascending, and -// every snapshot binding to the same AttemptContextHash as the -// bundle. +// Unmarshal parses a SignedTransitionMessage envelope, retains the +// received body and envelope bytes verbatim (the coordinator +// signature verifies over exactly these bytes), parses each embedded +// snapshot envelope (each retaining its own received bytes), and +// validates the structure: hash length, bundle size cap, signature +// size cap, snapshot validity, bundle ordering by SenderID +// ascending, and every snapshot binding to the same +// AttemptContextHash as the bundle. func (m *TransitionMessage) Unmarshal(data []byte) error { - if err := json.Unmarshal(data, m); err != nil { - return err + var envelope pb.SignedTransitionMessage + if err := proto.Unmarshal(data, &envelope); err != nil { + return fmt.Errorf("transition message: parse envelope: %w", err) + } + if len(envelope.Body) == 0 { + return errors.New("transition message: empty body") + } + var body pb.TransitionMessageBody + if err := proto.Unmarshal(envelope.Body, &body); err != nil { + return fmt.Errorf("transition message: parse body: %w", err) + } + if len(body.SignedSnapshots) > MaxSnapshotsPerBundle { + return fmt.Errorf( + "transition message: bundle length [%d] exceeds cap [%d]", + len(body.SignedSnapshots), + MaxSnapshotsPerBundle, + ) + } + m.AttemptContextHash = append([]byte(nil), body.AttemptContextHash...) + m.CoordinatorIDValue = body.CoordinatorId + m.Bundle = make([]LocalEvidenceSnapshot, 0, len(body.SignedSnapshots)) + for i, raw := range body.SignedSnapshots { + var snapshot LocalEvidenceSnapshot + if err := snapshot.Unmarshal(raw); err != nil { + return fmt.Errorf("transition message: bundle[%d]: %w", i, err) + } + m.Bundle = append(m.Bundle, snapshot) } + m.CoordinatorSignature = append([]byte(nil), envelope.CoordinatorSignature...) + m.signedBody = append([]byte(nil), envelope.Body...) + m.wireEnvelope = append([]byte(nil), data...) return m.Validate() } -// Validate runs the structural checks Unmarshal applies after a JSON +// Validate runs the structural checks Unmarshal applies after a // decode: bundle hash length, bundle size cap, coordinator id, every // snapshot's validity, bundle ordering, and intra-bundle hash // consistency. Exposed publicly so callers that construct messages diff --git a/pkg/frost/roast/transition_message_test.go b/pkg/frost/roast/transition_message_test.go index 4fadf13871..3c21fb5664 100644 --- a/pkg/frost/roast/transition_message_test.go +++ b/pkg/frost/roast/transition_message_test.go @@ -2,14 +2,64 @@ package roast import ( "bytes" - "encoding/json" "strings" "testing" + "google.golang.org/protobuf/proto" + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" "github.com/keep-network/keep-core/pkg/protocol/group" ) +// encodeSnapshotForTest builds a SignedLocalEvidenceSnapshot envelope from +// arbitrary (possibly invalid) in-memory fields, bypassing the production +// signing/validation path, so tests can exercise Unmarshal's rejection of +// structurally invalid wire bytes. +func encodeSnapshotForTest(t *testing.T, s *LocalEvidenceSnapshot) []byte { + t.Helper() + body, err := proto.Marshal(snapshotBodyMessage(s)) + if err != nil { + t.Fatalf("encode snapshot body: %v", err) + } + envelope, err := proto.Marshal(&pb.SignedLocalEvidenceSnapshot{ + Body: body, + OperatorSignature: s.OperatorSignature, + }) + if err != nil { + t.Fatalf("encode snapshot envelope: %v", err) + } + return envelope +} + +// encodeTransitionForTest builds a SignedTransitionMessage envelope from +// arbitrary (possibly invalid) in-memory fields; see encodeSnapshotForTest. +func encodeTransitionForTest(t *testing.T, m *TransitionMessage) []byte { + t.Helper() + body := &pb.TransitionMessageBody{ + AttemptContextHash: m.AttemptContextHash, + CoordinatorId: m.CoordinatorIDValue, + } + for i := range m.Bundle { + body.SignedSnapshots = append( + body.SignedSnapshots, + encodeSnapshotForTest(t, &m.Bundle[i]), + ) + } + bodyBytes, err := proto.Marshal(body) + if err != nil { + t.Fatalf("encode transition body: %v", err) + } + envelope, err := proto.Marshal(&pb.SignedTransitionMessage{ + Body: bodyBytes, + CoordinatorSignature: m.CoordinatorSignature, + }) + if err != nil { + t.Fatalf("encode transition envelope: %v", err) + } + return envelope +} + var pinnedContextHash = [attempt.MessageDigestLength]byte{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, @@ -69,14 +119,19 @@ func TestNewLocalEvidenceSnapshot_EmptyEvidenceOmitsOverflows(t *testing.T) { if len(s.Overflows) != 0 { t.Fatalf("expected empty overflows, got %v", s.Overflows) } + s.OperatorSignature = bytes.Repeat([]byte{0xab}, 64) data, err := s.Marshal() if err != nil { t.Fatalf("marshal: %v", err) } - if strings.Contains(string(data), "overflows") { + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(decoded.Overflows) != 0 { t.Fatalf( - "empty overflows should be omitted by omitempty; got JSON: %s", - string(data), + "empty overflows must stay empty through the wire; got %v", + decoded.Overflows, ) } } @@ -121,7 +176,7 @@ func TestLocalEvidenceSnapshot_RejectsZeroSender(t *testing.T) { SenderIDValue: 0, AttemptContextHash: pinnedContextHash[:], } - data, _ := json.Marshal(s) + data := encodeSnapshotForTest(t, s) err := (&LocalEvidenceSnapshot{}).Unmarshal(data) if err == nil || !strings.Contains(err.Error(), "senderID is zero") { t.Fatalf("expected zero-sender error, got %v", err) @@ -129,10 +184,10 @@ func TestLocalEvidenceSnapshot_RejectsZeroSender(t *testing.T) { } func TestLocalEvidenceSnapshot_RejectsWrongHashLength(t *testing.T) { - bad := []byte(`{ - "senderID": 1, - "attemptContextHash": "AAEC" - }`) + bad := encodeSnapshotForTest(t, &LocalEvidenceSnapshot{ + SenderIDValue: 1, + AttemptContextHash: []byte{0x00, 0x01, 0x02}, + }) err := (&LocalEvidenceSnapshot{}).Unmarshal(bad) if err == nil || !strings.Contains(err.Error(), "attemptContextHash length") { t.Fatalf("expected hash-length error, got %v", err) @@ -142,7 +197,7 @@ func TestLocalEvidenceSnapshot_RejectsWrongHashLength(t *testing.T) { func TestLocalEvidenceSnapshot_RejectsOversizeSignature(t *testing.T) { s := NewLocalEvidenceSnapshot(1, pinnedContextHash, attempt.Evidence{}) s.OperatorSignature = bytes.Repeat([]byte{0xff}, MaxOperatorSignatureBytes+1) - data, _ := json.Marshal(s) + data := encodeSnapshotForTest(t, s) err := (&LocalEvidenceSnapshot{}).Unmarshal(data) if err == nil || !strings.Contains(err.Error(), "exceeds cap") { t.Fatalf("expected signature-cap error, got %v", err) @@ -158,7 +213,7 @@ func TestLocalEvidenceSnapshot_RejectsUnsortedOverflows(t *testing.T) { {Sender: 1, Count: 1}, }, } - data, _ := json.Marshal(bad) + data := encodeSnapshotForTest(t, bad) err := (&LocalEvidenceSnapshot{}).Unmarshal(data) if err == nil || !strings.Contains(err.Error(), "not sorted") { t.Fatalf("expected sort error, got %v", err) @@ -174,7 +229,7 @@ func TestLocalEvidenceSnapshot_RejectsDuplicateOverflowSender(t *testing.T) { {Sender: 3, Count: 1}, }, } - data, _ := json.Marshal(bad) + data := encodeSnapshotForTest(t, bad) err := (&LocalEvidenceSnapshot{}).Unmarshal(data) if err == nil { t.Fatal("expected duplicate-sender error") @@ -249,7 +304,7 @@ func TestTransitionMessage_RejectsBadBundleOrdering(t *testing.T) { m := buildValidTransitionMessage() // Swap order to make it unsorted. m.Bundle[0], m.Bundle[1] = m.Bundle[1], m.Bundle[0] - data, _ := json.Marshal(m) + data := encodeTransitionForTest(t, m) err := (&TransitionMessage{}).Unmarshal(data) if err == nil || !strings.Contains(err.Error(), "not sorted") { t.Fatalf("expected sort error, got %v", err) @@ -264,7 +319,7 @@ func TestTransitionMessage_RejectsMismatchedBundleHash(t *testing.T) { for i := range m.Bundle[0].AttemptContextHash { m.Bundle[0].AttemptContextHash[i] = 0xff } - data, _ := json.Marshal(m) + data := encodeTransitionForTest(t, m) err := (&TransitionMessage{}).Unmarshal(data) if err == nil || !strings.Contains(err.Error(), "does not match bundle hash") { t.Fatalf("expected hash-mismatch error, got %v", err) @@ -274,7 +329,7 @@ func TestTransitionMessage_RejectsMismatchedBundleHash(t *testing.T) { func TestTransitionMessage_RejectsEmptyBundle(t *testing.T) { m := buildValidTransitionMessage() m.Bundle = nil - data, _ := json.Marshal(m) + data := encodeTransitionForTest(t, m) err := (&TransitionMessage{}).Unmarshal(data) if err == nil || !strings.Contains(err.Error(), "must not be empty") { t.Fatalf("expected empty-bundle error, got %v", err) @@ -292,7 +347,7 @@ func TestTransitionMessage_RejectsOversizeBundle(t *testing.T) { AttemptContextHash: append([]byte{}, m.AttemptContextHash...), } } - data, _ := json.Marshal(m) + data := encodeTransitionForTest(t, m) err := (&TransitionMessage{}).Unmarshal(data) if err == nil || !strings.Contains(err.Error(), "exceeds cap") { t.Fatalf("expected oversize-bundle error, got %v", err) @@ -302,7 +357,7 @@ func TestTransitionMessage_RejectsOversizeBundle(t *testing.T) { func TestTransitionMessage_RejectsZeroCoordinatorID(t *testing.T) { m := buildValidTransitionMessage() m.CoordinatorIDValue = 0 - data, _ := json.Marshal(m) + data := encodeTransitionForTest(t, m) err := (&TransitionMessage{}).Unmarshal(data) if err == nil || !strings.Contains(err.Error(), "coordinatorID is zero") { t.Fatalf("expected zero-coordinator error, got %v", err) @@ -312,7 +367,7 @@ func TestTransitionMessage_RejectsZeroCoordinatorID(t *testing.T) { func TestTransitionMessage_RejectsOversizeCoordinatorSignature(t *testing.T) { m := buildValidTransitionMessage() m.CoordinatorSignature = bytes.Repeat([]byte{0xff}, MaxCoordinatorSignatureBytes+1) - data, _ := json.Marshal(m) + data := encodeTransitionForTest(t, m) err := (&TransitionMessage{}).Unmarshal(data) if err == nil || !strings.Contains(err.Error(), "exceeds cap") { t.Fatalf("expected oversize-signature error, got %v", err) @@ -322,7 +377,7 @@ func TestTransitionMessage_RejectsOversizeCoordinatorSignature(t *testing.T) { func TestTransitionMessage_RejectsBundleWithInvalidSnapshot(t *testing.T) { m := buildValidTransitionMessage() m.Bundle[0].SenderIDValue = 0 - data, _ := json.Marshal(m) + data := encodeTransitionForTest(t, m) err := (&TransitionMessage{}).Unmarshal(data) if err == nil || !strings.Contains(err.Error(), "senderID is zero") { t.Fatalf("expected invalid-snapshot error, got %v", err) @@ -332,14 +387,14 @@ func TestTransitionMessage_RejectsBundleWithInvalidSnapshot(t *testing.T) { func TestTransitionMessage_RejectsDuplicateBundleSender(t *testing.T) { m := buildValidTransitionMessage() m.Bundle[1].SenderIDValue = m.Bundle[0].SenderIDValue - data, _ := json.Marshal(m) + data := encodeTransitionForTest(t, m) err := (&TransitionMessage{}).Unmarshal(data) if err == nil { t.Fatal("expected duplicate-sender error") } } -func TestTransitionMessage_DeterministicJSONForIdenticalInputs(t *testing.T) { +func TestTransitionMessage_DeterministicEncodingForIdenticalInputs(t *testing.T) { a := buildValidTransitionMessage() b := buildValidTransitionMessage() dataA, err := a.Marshal() @@ -352,8 +407,8 @@ func TestTransitionMessage_DeterministicJSONForIdenticalInputs(t *testing.T) { } if !bytes.Equal(dataA, dataB) { t.Fatalf( - "identical inputs produced different JSON:\n a=%s\n b=%s", - string(dataA), string(dataB), + "identical inputs produced different wire bytes:\n a=%x\n b=%x", + dataA, dataB, ) } } @@ -366,6 +421,7 @@ func buildValidTransitionMessage() *TransitionMessage { Overflows: []OverflowEntry{ {Sender: 99, Count: 1}, }, + OperatorSignature: bytes.Repeat([]byte{0xab}, 64), } } return &TransitionMessage{ diff --git a/pkg/frost/roast/wire.go b/pkg/frost/roast/wire.go new file mode 100644 index 0000000000..35f9a11915 --- /dev/null +++ b/pkg/frost/roast/wire.go @@ -0,0 +1,157 @@ +package roast + +import ( + "errors" + "fmt" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// Evidence wire format: signed-body protobuf envelopes. +// +// Operator and coordinator signatures cover exact serialized body bytes, +// and those bytes travel verbatim - a verifier checks the signature over +// the bytes it received and only then parses them. Nothing in the +// evidence chain is ever re-encoded, so signature validity never depends +// on any serializer's canonical form, across protobuf library versions or +// across languages. Producers marshal a body exactly once (at signing +// time) and cache it; parsed messages cache the received bytes. + +func snapshotBodyMessage(s *LocalEvidenceSnapshot) *pb.LocalEvidenceSnapshotBody { + body := &pb.LocalEvidenceSnapshotBody{ + SenderId: s.SenderIDValue, + AttemptContextHash: s.AttemptContextHash, + } + for _, e := range s.Overflows { + body.Overflows = append(body.Overflows, &pb.OverflowEntry{ + Sender: uint32(e.Sender), + Count: uint64(e.Count), + }) + } + for _, e := range s.Rejects { + body.Rejects = append(body.Rejects, &pb.RejectEntry{ + Sender: uint32(e.Sender), + Reason: e.Reason, + Count: uint64(e.Count), + }) + } + for _, e := range s.Conflicts { + body.Conflicts = append(body.Conflicts, &pb.ConflictEntry{ + Sender: uint32(e.Sender), + Count: uint64(e.Count), + }) + } + return body +} + +func snapshotFieldsFromBody(s *LocalEvidenceSnapshot, body *pb.LocalEvidenceSnapshotBody) { + s.SenderIDValue = body.SenderId + s.AttemptContextHash = append([]byte(nil), body.AttemptContextHash...) + s.Overflows = nil + for _, e := range body.Overflows { + s.Overflows = append(s.Overflows, OverflowEntry{ + Sender: group.MemberIndex(e.Sender), + Count: uint(e.Count), + }) + } + s.Rejects = nil + for _, e := range body.Rejects { + s.Rejects = append(s.Rejects, RejectEntry{ + Sender: group.MemberIndex(e.Sender), + Reason: e.Reason, + Count: uint(e.Count), + }) + } + s.Conflicts = nil + for _, e := range body.Conflicts { + s.Conflicts = append(s.Conflicts, ConflictEntry{ + Sender: group.MemberIndex(e.Sender), + Count: uint(e.Count), + }) + } +} + +// SignableBytes returns the exact byte stream the OperatorSignature covers: +// the serialized LocalEvidenceSnapshotBody. For a self-authored snapshot +// the body is marshaled once and cached - sign exactly what will be +// transmitted. For a snapshot parsed off the wire this returns the +// received body bytes verbatim - verify exactly what was received. The +// snapshot's evidence fields must not be mutated afterwards. +func (s *LocalEvidenceSnapshot) SignableBytes() ([]byte, error) { + if s == nil { + return nil, errors.New("roast: cannot encode a nil snapshot") + } + if s.signedBody != nil { + return s.signedBody, nil + } + body, err := proto.Marshal(snapshotBodyMessage(s)) + if err != nil { + return nil, fmt.Errorf("roast: marshal snapshot body: %w", err) + } + s.signedBody = body + return body, nil +} + +// wireEnvelopeBytes returns the exact on-wire SignedLocalEvidenceSnapshot +// envelope. For parsed snapshots this is the received envelope verbatim; +// for self-authored snapshots it is built once (after signing) and cached, +// so the broadcast bytes and the bytes embedded into a coordinator bundle +// are identical. +func (s *LocalEvidenceSnapshot) wireEnvelopeBytes() ([]byte, error) { + if s.wireEnvelope != nil { + return s.wireEnvelope, nil + } + if len(s.OperatorSignature) == 0 { + return nil, errors.New( + "roast: snapshot must be signed before wire encoding", + ) + } + body, err := s.SignableBytes() + if err != nil { + return nil, err + } + envelope, err := proto.Marshal(&pb.SignedLocalEvidenceSnapshot{ + Body: body, + OperatorSignature: s.OperatorSignature, + }) + if err != nil { + return nil, fmt.Errorf("roast: marshal snapshot envelope: %w", err) + } + s.wireEnvelope = envelope + return envelope, nil +} + +// SignableBytes returns the exact byte stream the CoordinatorSignature +// covers: the serialized TransitionMessageBody, which embeds every +// snapshot's signed envelope verbatim. The coordinator's signature +// attests that these specific signed snapshots were assembled in this +// specific order. For a message parsed off the wire this returns the +// received body bytes verbatim. +func (m *TransitionMessage) SignableBytes() ([]byte, error) { + if m == nil { + return nil, errors.New("roast: cannot encode a nil transition message") + } + if m.signedBody != nil { + return m.signedBody, nil + } + body := &pb.TransitionMessageBody{ + AttemptContextHash: m.AttemptContextHash, + CoordinatorId: m.CoordinatorIDValue, + } + for i := range m.Bundle { + envelope, err := m.Bundle[i].wireEnvelopeBytes() + if err != nil { + return nil, fmt.Errorf("roast: bundle[%d]: %w", i, err) + } + body.SignedSnapshots = append(body.SignedSnapshots, envelope) + } + bodyBytes, err := proto.Marshal(body) + if err != nil { + return nil, fmt.Errorf("roast: marshal transition body: %w", err) + } + m.signedBody = bodyBytes + return bodyBytes, nil +} diff --git a/pkg/frost/roast/wire_test.go b/pkg/frost/roast/wire_test.go new file mode 100644 index 0000000000..e9ddf2e3e8 --- /dev/null +++ b/pkg/frost/roast/wire_test.go @@ -0,0 +1,192 @@ +package roast + +import ( + "bytes" + "testing" + + "google.golang.org/protobuf/proto" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/frost/roast/gen/pb" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// The properties pinned here are the point of the signed-body envelope +// format: signatures verify over exactly the bytes received, those bytes +// travel verbatim through re-broadcast and bundle aggregation, and no step +// ever depends on a serializer's canonical form. + +func signedTestSnapshot(t *testing.T, sender group.MemberIndex) *LocalEvidenceSnapshot { + t.Helper() + signer := &fakeSigner{id: sender} + snap := NewLocalEvidenceSnapshot(sender, pinnedContextHash, attempt.Evidence{ + Overflows: map[group.MemberIndex]uint{1: 2, 3: 4}, + }) + payload, err := snap.SignableBytes() + if err != nil { + t.Fatalf("signable bytes: %v", err) + } + sig, err := signer.Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + snap.OperatorSignature = sig + return snap +} + +func TestSnapshotWire_ReceivedBytesPreservedVerbatim(t *testing.T) { + original := signedTestSnapshot(t, 7) + wire, err := original.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := verifySnapshotSignature(fakeVerifier{}, decoded); err != nil { + t.Fatalf("verify decoded: %v", err) + } + + rebroadcast, err := decoded.Marshal() + if err != nil { + t.Fatalf("re-marshal: %v", err) + } + if !bytes.Equal(rebroadcast, wire) { + t.Fatal("re-marshal of a received snapshot must return the received bytes verbatim") + } + + producerBody, _ := original.SignableBytes() + receiverBody, _ := decoded.SignableBytes() + if !bytes.Equal(producerBody, receiverBody) { + t.Fatal("receiver must verify over exactly the bytes the producer signed") + } +} + +func TestSnapshotWire_NonCanonicalEnvelopeEncodingSurvives(t *testing.T) { + original := signedTestSnapshot(t, 7) + body, _ := original.SignableBytes() + + // Handcraft an envelope with the fields in REVERSE tag order + // (operator_signature before body) - a wire-legal but non-canonical + // encoding no Go marshaler would produce. Field 1 (body) and field 2 + // (operator_signature) are both length-delimited: tags 0x0a and 0x12. + var crafted []byte + crafted = append(crafted, 0x12, byte(len(original.OperatorSignature))) + crafted = append(crafted, original.OperatorSignature...) + crafted = append(crafted, 0x0a, byte(len(body))) + crafted = append(crafted, body...) + + // Sanity: protobuf accepts field-order-free encodings. + var check pb.SignedLocalEvidenceSnapshot + if err := proto.Unmarshal(crafted, &check); err != nil { + t.Fatalf("crafted envelope must be wire-legal: %v", err) + } + + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(crafted); err != nil { + t.Fatalf("unmarshal crafted: %v", err) + } + if err := verifySnapshotSignature(fakeVerifier{}, decoded); err != nil { + t.Fatalf("signature must verify over the embedded body bytes: %v", err) + } + + remarshaled, err := decoded.Marshal() + if err != nil { + t.Fatalf("re-marshal: %v", err) + } + if !bytes.Equal(remarshaled, crafted) { + t.Fatal("re-marshal must preserve even a non-canonical received encoding verbatim") + } +} + +func TestBundleWire_EmbedsReceivedSnapshotEnvelopesVerbatim(t *testing.T) { + snapshotWire := make([][]byte, 0, 2) + bundle := make([]LocalEvidenceSnapshot, 0, 2) + for _, sender := range []group.MemberIndex{1, 2} { + wire, err := signedTestSnapshot(t, sender).Marshal() + if err != nil { + t.Fatalf("marshal snapshot: %v", err) + } + // The coordinator receives the snapshot off the wire. + var received LocalEvidenceSnapshot + if err := received.Unmarshal(wire); err != nil { + t.Fatalf("coordinator unmarshal: %v", err) + } + snapshotWire = append(snapshotWire, wire) + bundle = append(bundle, received) + } + + coordinator := &fakeSigner{id: 2} + msg := &TransitionMessage{ + AttemptContextHash: append([]byte{}, pinnedContextHash[:]...), + CoordinatorIDValue: 2, + Bundle: bundle, + } + payload, err := msg.SignableBytes() + if err != nil { + t.Fatalf("bundle signable bytes: %v", err) + } + for _, wire := range snapshotWire { + if !bytes.Contains(payload, wire) { + t.Fatal("bundle body must embed each received snapshot envelope verbatim") + } + } + sig, err := coordinator.Sign(payload) + if err != nil { + t.Fatalf("sign bundle: %v", err) + } + msg.CoordinatorSignature = sig + + bundleWire, err := msg.Marshal() + if err != nil { + t.Fatalf("marshal bundle: %v", err) + } + decoded := &TransitionMessage{} + if err := decoded.Unmarshal(bundleWire); err != nil { + t.Fatalf("unmarshal bundle: %v", err) + } + if err := verifyBundleSignature(fakeVerifier{}, decoded, 2); err != nil { + t.Fatalf("verify bundle: %v", err) + } + for i := range decoded.Bundle { + if err := verifySnapshotSignature(fakeVerifier{}, &decoded.Bundle[i]); err != nil { + t.Fatalf("verify embedded snapshot %d: %v", i, err) + } + } + + rebroadcast, err := decoded.Marshal() + if err != nil { + t.Fatalf("re-marshal bundle: %v", err) + } + if !bytes.Equal(rebroadcast, bundleWire) { + t.Fatal("re-marshal of a received bundle must return the received bytes verbatim") + } +} + +func TestSnapshotWire_TamperedBodyFailsVerification(t *testing.T) { + original := signedTestSnapshot(t, 7) + wire, err := original.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + + // Flip one byte inside the embedded body (the attempt context hash + // content sits well inside the envelope; the envelope structure stays + // parseable because only a value byte changes). + tampered := append([]byte(nil), wire...) + idx := bytes.Index(tampered, pinnedContextHash[:]) + if idx < 0 { + t.Fatal("context hash not found in wire bytes") + } + tampered[idx] ^= 0xff + + decoded := &LocalEvidenceSnapshot{} + if err := decoded.Unmarshal(tampered); err != nil { + t.Fatalf("tampered envelope still parses (only a value changed): %v", err) + } + if err := verifySnapshotSignature(fakeVerifier{}, decoded); err == nil { + t.Fatal("signature over tampered body bytes must fail verification") + } +} diff --git a/pkg/frost/signing/roast_retry_submit.go b/pkg/frost/signing/roast_retry_submit.go index 3901e58214..f3c5973b20 100644 --- a/pkg/frost/signing/roast_retry_submit.go +++ b/pkg/frost/signing/roast_retry_submit.go @@ -84,7 +84,7 @@ func buildSignedSnapshot( ctx.Hash(), evidence, ) - payload, err := roast.CanonicalSnapshotBytes(snap) + payload, err := snap.SignableBytes() if err != nil { roastRetryLogger.Warnf( "roast-retry: canonicalising snapshot failed: %v", From ed0e27cef2b046adf766087ecf799da97ed0343a Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 10:01:03 -0400 Subject: [PATCH 2/3] build: copy pkg/frost/roast/gen into the Docker generation stage The image strips committed **/gen/**/*.go (.dockerignore) and regenerates protobufs in-image via make generate, which only sees the gen directories explicitly COPY'd before it runs. Without this line the new evidence.proto is absent at generation time and the later full COPY restores only the .proto (not the stripped .pb.go), so go mod tidy fails on the pkg/frost/roast/gen/pb import. Co-Authored-By: Claude Fable 5 --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 97181a29ef..91646a4bf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,7 @@ COPY ./pkg/chain/ethereum/common/gen $APP_DIR/pkg/chain/ethereum/common/gen COPY ./pkg/chain/ethereum/ecdsa/gen $APP_DIR/pkg/chain/ethereum/ecdsa/gen COPY ./pkg/chain/ethereum/tbtc/gen $APP_DIR/pkg/chain/ethereum/tbtc/gen COPY ./pkg/chain/ethereum/threshold/gen $APP_DIR/pkg/chain/ethereum/threshold/gen +COPY ./pkg/frost/roast/gen $APP_DIR/pkg/frost/roast/gen COPY ./pkg/net/gen $APP_DIR/pkg/net/gen COPY ./pkg/tbtc/gen $APP_DIR/pkg/tbtc/gen COPY ./pkg/tecdsa/dkg/gen $APP_DIR/pkg/tecdsa/dkg/gen From 44eadfe16305ae2b2fd6f050fdde5be8b63091de Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 10:20:25 -0400 Subject: [PATCH 3/3] docs(frost/roast): pin cache-slice immutability; note Dockerfile proto coupling Review follow-ups on #4040 (own findings; Codex and Gemini passes were clean): - SignableBytes/Marshal docs now state the returned slice is the internal cache and must not be mutated - in-tree callers are all read-only, this pins the contract for future ones - Makefile gen_proto comment points new proto packages at the Dockerfile gen-directory COPY allowlist, so the next proto package learns about the in-image regeneration coupling from the Makefile instead of from a CI failure Co-Authored-By: Claude Fable 5 --- Makefile | 4 ++++ pkg/frost/roast/transition_message.go | 6 ++++-- pkg/frost/roast/wire.go | 6 ++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index ab468ae08f..1fca3ddc46 100644 --- a/Makefile +++ b/Makefile @@ -77,6 +77,10 @@ else $(foreach module,$(modules),$(call get_npm_package,$(module),$(environment))) endif +# NOTE: a new *.proto package must also be added to the gen-directory COPY +# allowlist in the Dockerfile: the image strips committed **/gen/**/*.go +# (.dockerignore) and regenerates protobufs in-image, and make generate only +# sees gen directories copied before it runs. proto_files := $(shell find ./pkg -name '*.proto') proto_targets := $(proto_files:.proto=.pb.go) diff --git a/pkg/frost/roast/transition_message.go b/pkg/frost/roast/transition_message.go index 4422e9344f..f8499ad91f 100644 --- a/pkg/frost/roast/transition_message.go +++ b/pkg/frost/roast/transition_message.go @@ -222,7 +222,8 @@ func (s *LocalEvidenceSnapshot) Type() string { // envelope: the exact signed body bytes plus the operator signature. // For a snapshot parsed off the wire the received envelope is // returned verbatim, so evidence bytes survive any re-broadcast -// unchanged. The snapshot must be signed first. +// unchanged. The snapshot must be signed first. The returned slice is +// the internal cache - callers must not mutate it. func (s *LocalEvidenceSnapshot) Marshal() ([]byte, error) { return s.wireEnvelopeBytes() } @@ -361,7 +362,8 @@ func (m *TransitionMessage) Type() string { // Marshal serialises the message as a SignedTransitionMessage // envelope: the exact signed body bytes plus the coordinator // signature. For a message parsed off the wire the received envelope -// is returned verbatim. The message must be signed first. +// is returned verbatim. The message must be signed first. The +// returned slice is the internal cache - callers must not mutate it. func (m *TransitionMessage) Marshal() ([]byte, error) { if m.wireEnvelope != nil { return m.wireEnvelope, nil diff --git a/pkg/frost/roast/wire.go b/pkg/frost/roast/wire.go index 35f9a11915..a7932aae49 100644 --- a/pkg/frost/roast/wire.go +++ b/pkg/frost/roast/wire.go @@ -79,7 +79,8 @@ func snapshotFieldsFromBody(s *LocalEvidenceSnapshot, body *pb.LocalEvidenceSnap // the body is marshaled once and cached - sign exactly what will be // transmitted. For a snapshot parsed off the wire this returns the // received body bytes verbatim - verify exactly what was received. The -// snapshot's evidence fields must not be mutated afterwards. +// snapshot's evidence fields must not be mutated afterwards, and the +// returned slice is the internal cache - callers must not mutate it. func (s *LocalEvidenceSnapshot) SignableBytes() ([]byte, error) { if s == nil { return nil, errors.New("roast: cannot encode a nil snapshot") @@ -129,7 +130,8 @@ func (s *LocalEvidenceSnapshot) wireEnvelopeBytes() ([]byte, error) { // snapshot's signed envelope verbatim. The coordinator's signature // attests that these specific signed snapshots were assembled in this // specific order. For a message parsed off the wire this returns the -// received body bytes verbatim. +// received body bytes verbatim. The returned slice is the internal +// cache - callers must not mutate it. func (m *TransitionMessage) SignableBytes() ([]byte, error) { if m == nil { return nil, errors.New("roast: cannot encode a nil transition message")