diff --git a/pkg/frost/roast/gen/pb/signing_package.pb.go b/pkg/frost/roast/gen/pb/signing_package.pb.go index 5d4af72486..e2ca93940e 100644 --- a/pkg/frost/roast/gen/pb/signing_package.pb.go +++ b/pkg/frost/roast/gen/pb/signing_package.pb.go @@ -36,8 +36,18 @@ type SigningPackageBody struct { // The 32-byte taproot script-tree root the signature is tweaked by; // empty for a key-path spend. TaprootMerkleRoot []byte `protobuf:"bytes,4,opt,name=taproot_merkle_root,json=taprootMerkleRoot,proto3" json:"taproot_merkle_root,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The member indices of the chosen signing subset the FROST signing_package + // was built over (RFC-21 Phase 7.3 t-of-included finalize): ascending, + // distinct, each a valid member index. It lets non-coordinators know which + // members to await round-2 shares from when the package covers a t-subset of + // the included set. The cryptographic source of truth is signing_package + // itself, so a coordinator that lies here causes only a LIVENESS failure + // (aggregate fails closed when the shares do not match the package), never a + // wrong signature or false blame. Empty for packages that do not carry a + // subset (the full-included flow). + SignerIds []uint32 `protobuf:"varint,5,rep,packed,name=signer_ids,json=signerIds,proto3" json:"signer_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SigningPackageBody) Reset() { @@ -98,6 +108,13 @@ func (x *SigningPackageBody) GetTaprootMerkleRoot() []byte { return nil } +func (x *SigningPackageBody) GetSignerIds() []uint32 { + if x != nil { + return x.SignerIds + } + return nil +} + // The on-wire signing package: the exact serialized SigningPackageBody bytes // plus the elected coordinator's operator signature. The signature covers the // domain-tagged body (domain_tag || body), not the bare body field. @@ -159,7 +176,7 @@ var file_pkg_frost_roast_gen_pb_signing_package_proto_rawDesc = []byte{ 0x0a, 0x2c, 0x70, 0x6b, 0x67, 0x2f, 0x66, 0x72, 0x6f, 0x73, 0x74, 0x2f, 0x72, 0x6f, 0x61, 0x73, 0x74, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x62, 0x2f, 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, - 0x72, 0x6f, 0x61, 0x73, 0x74, 0x22, 0xc6, 0x01, 0x0a, 0x12, 0x53, 0x69, 0x67, 0x6e, 0x69, 0x6e, + 0x72, 0x6f, 0x61, 0x73, 0x74, 0x22, 0xe5, 0x01, 0x0a, 0x12, 0x53, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x50, 0x61, 0x63, 0x6b, 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, @@ -171,14 +188,16 @@ var file_pkg_frost_roast_gen_pb_signing_package_proto_rawDesc = []byte{ 0x73, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x50, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x12, 0x2e, 0x0a, 0x13, 0x74, 0x61, 0x70, 0x72, 0x6f, 0x6f, 0x74, 0x5f, 0x6d, 0x65, 0x72, 0x6b, 0x6c, 0x65, 0x5f, 0x72, 0x6f, 0x6f, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x61, 0x70, - 0x72, 0x6f, 0x6f, 0x74, 0x4d, 0x65, 0x72, 0x6b, 0x6c, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x22, 0x5f, - 0x0a, 0x14, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x50, - 0x61, 0x63, 0x6b, 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, + 0x72, 0x6f, 0x6f, 0x74, 0x4d, 0x65, 0x72, 0x6b, 0x6c, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x1d, + 0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, + 0x28, 0x0d, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x73, 0x22, 0x5f, 0x0a, + 0x14, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x50, 0x61, + 0x63, 0x6b, 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 ( diff --git a/pkg/frost/roast/gen/pb/signing_package.proto b/pkg/frost/roast/gen/pb/signing_package.proto index 0d04eca9f2..59afc68cdb 100644 --- a/pkg/frost/roast/gen/pb/signing_package.proto +++ b/pkg/frost/roast/gen/pb/signing_package.proto @@ -45,6 +45,16 @@ message SigningPackageBody { // The 32-byte taproot script-tree root the signature is tweaked by; // empty for a key-path spend. bytes taproot_merkle_root = 4; + // The member indices of the chosen signing subset the FROST signing_package + // was built over (RFC-21 Phase 7.3 t-of-included finalize): ascending, + // distinct, each a valid member index. It lets non-coordinators know which + // members to await round-2 shares from when the package covers a t-subset of + // the included set. The cryptographic source of truth is signing_package + // itself, so a coordinator that lies here causes only a LIVENESS failure + // (aggregate fails closed when the shares do not match the package), never a + // wrong signature or false blame. Empty for packages that do not carry a + // subset (the full-included flow). + repeated uint32 signer_ids = 5; } // The on-wire signing package: the exact serialized SigningPackageBody bytes diff --git a/pkg/frost/roast/signing_package.go b/pkg/frost/roast/signing_package.go index 74bfd0713b..1b41693bf9 100644 --- a/pkg/frost/roast/signing_package.go +++ b/pkg/frost/roast/signing_package.go @@ -92,6 +92,16 @@ type SigningPackage struct { // TaprootMerkleRoot is the taproot script-tree root the signature is // tweaked by: exactly 32 bytes, or empty for a key-path spend. TaprootMerkleRoot []byte + // SignerIDsValue is the wire (uint32) form of the chosen signing subset's + // member indices the FROST SigningPackageBytes was built over (RFC-21 Phase + // 7.3 t-of-included finalize): ascending, distinct, each a valid member index. + // It lets non-coordinators know which members to await round-2 shares from when + // the package covers a t-subset of the included set. The SigningPackageBytes is + // the cryptographic source of truth, so a coordinator that lies here causes + // only a liveness failure (aggregate fails closed), never a wrong signature or + // false blame. Empty for the full-included flow. Use SignerIDs() for the + // validated member-index form. + SignerIDsValue []uint32 // CoordinatorSignature is the elected coordinator's operator-key // signature over SignableBytes(). CoordinatorSignature []byte @@ -116,6 +126,7 @@ func signingPackageBodyMessage(p *SigningPackage) *pb.SigningPackageBody { CoordinatorId: p.CoordinatorIDValue, SigningPackage: p.SigningPackageBytes, TaprootMerkleRoot: p.TaprootMerkleRoot, + SignerIds: p.SignerIDsValue, } } @@ -124,6 +135,7 @@ func signingPackageFieldsFromBody(p *SigningPackage, body *pb.SigningPackageBody p.CoordinatorIDValue = body.CoordinatorId p.SigningPackageBytes = append([]byte(nil), body.SigningPackage...) p.TaprootMerkleRoot = append([]byte(nil), body.TaprootMerkleRoot...) + p.SignerIDsValue = append([]uint32(nil), body.SignerIds...) } // SignableBytes returns the exact byte stream the CoordinatorSignature @@ -357,5 +369,39 @@ func (p *SigningPackage) Validate() error { MaxCoordinatorSignatureBytes, ) } + // signer_ids (when present) names the chosen signing subset: each a real member + // index (so SignerIDs() never truncates) and STRICTLY ASCENDING (hence + // distinct, and bounded to <= the member-index space). Empty is valid -- the + // full-included flow carries no subset. This is a structural/liveness check: + // the engine verifies shares against the SigningPackageBytes (the cryptographic + // source of truth), so a lying list only fails an attempt, never produces a + // wrong signature or false blame. + for i, id := range p.SignerIDsValue { + if id == 0 || id > group.MaxMemberIndex { + return fmt.Errorf( + "signed signing package: signerID [%d] is not a valid member index", + id, + ) + } + if i > 0 && id <= p.SignerIDsValue[i-1] { + return fmt.Errorf( + "signed signing package: signerIDs must be strictly ascending (got [%d] after [%d])", + id, p.SignerIDsValue[i-1], + ) + } + } return nil } + +// SignerIDs returns the chosen signing subset's member indices in their validated +// form. Callers MUST Validate the package first (AuthenticateSigningPackage does): +// Validate guarantees each value is a real member index, so the uint32 -> +// group.MemberIndex conversion here cannot truncate. Returns an empty slice when +// the package carries no subset (the full-included flow). +func (p *SigningPackage) SignerIDs() []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(p.SignerIDsValue)) + for _, id := range p.SignerIDsValue { + out = append(out, group.MemberIndex(id)) + } + return out +} diff --git a/pkg/frost/roast/signing_package_test.go b/pkg/frost/roast/signing_package_test.go index 7ac8b2e924..bb3ec5659d 100644 --- a/pkg/frost/roast/signing_package_test.go +++ b/pkg/frost/roast/signing_package_test.go @@ -223,6 +223,58 @@ func TestSigningPackageWire_UnmarshalResetsSignableCache(t *testing.T) { } } +func TestSigningPackageWire_SignerIDsRoundTripAndSigned(t *testing.T) { + pkg := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: 3, + SigningPackageBytes: []byte("frost-signing-package-bytes"), + SignerIDsValue: []uint32{1, 2, 5}, + } + payload, err := pkg.SignableBytes() + if err != nil { + t.Fatalf("signable: %v", err) + } + pkg.CoordinatorSignature, err = (&fakeSigner{id: 3}).Sign(payload) + if err != nil { + t.Fatalf("sign: %v", err) + } + + // signer_ids is part of the SIGNED body: a different list yields different + // signable bytes, so a coordinator cannot swap the subset without re-signing + // and the field is authenticated, not an unsigned sidecar. + other := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: 3, + SigningPackageBytes: []byte("frost-signing-package-bytes"), + SignerIDsValue: []uint32{1, 2, 4}, + } + otherPayload, _ := other.SignableBytes() + if bytes.Equal(payload, otherPayload) { + t.Fatal("signer_ids must be covered by the signed body (different lists -> different signable bytes)") + } + + wire, err := pkg.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + decoded := &SigningPackage{} + if err := decoded.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := decoded.SignerIDs(); len(got) != 3 || got[0] != 1 || got[1] != 2 || got[2] != 5 { + t.Fatalf("signer_ids must round-trip into the validated member indices; got %v", got) + } + if err := AuthenticateSigningPackage(fakeVerifier{}, decoded, 3, pinnedContextHash[:]); err != nil { + t.Fatalf("authenticate a package carrying signer_ids: %v", err) + } + + // A package with no subset (the full-included flow) returns an empty, + // non-panicking SignerIDs() and still validates. + if ids := (&SigningPackage{}).SignerIDs(); len(ids) != 0 { + t.Fatalf("empty signer_ids must yield an empty SignerIDs(); got %v", ids) + } +} + func TestSigningPackageWire_RootRoundTrips(t *testing.T) { for _, tc := range []struct { name string @@ -272,6 +324,12 @@ func TestSigningPackage_ValidateRejectsMalformed(t *testing.T) { {"oversize signing package", func(p *SigningPackage) { p.SigningPackageBytes = make([]byte, MaxSigningPackageBytes+1) }}, + {"signer id zero", func(p *SigningPackage) { p.SignerIDsValue = []uint32{1, 0, 3} }}, + {"signer id out of member-index range", func(p *SigningPackage) { + p.SignerIDsValue = []uint32{1, group.MaxMemberIndex + 1} + }}, + {"signer ids not ascending", func(p *SigningPackage) { p.SignerIDsValue = []uint32{3, 1} }}, + {"signer ids duplicate", func(p *SigningPackage) { p.SignerIDsValue = []uint32{2, 2} }}, } { t.Run(tc.name, func(t *testing.T) { p := valid()