Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions pkg/frost/roast/gen/pb/signing_package.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions pkg/frost/roast/gen/pb/signing_package.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions pkg/frost/roast/signing_package.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -116,6 +126,7 @@ func signingPackageBodyMessage(p *SigningPackage) *pb.SigningPackageBody {
CoordinatorId: p.CoordinatorIDValue,
SigningPackage: p.SigningPackageBytes,
TaprootMerkleRoot: p.TaprootMerkleRoot,
SignerIds: p.SignerIDsValue,
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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
}
58 changes: 58 additions & 0 deletions pkg/frost/roast/signing_package_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading