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 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/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..f8499ad91f 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,40 @@ 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. The returned slice is +// the internal cache - callers must not mutate it. 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 +319,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 +359,79 @@ 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. The +// returned slice is the internal cache - callers must not mutate it. 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..a7932aae49 --- /dev/null +++ b/pkg/frost/roast/wire.go @@ -0,0 +1,159 @@ +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, 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") + } + 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. 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") + } + 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",