diff --git a/pkg/frost/roast/share_submission_auth.go b/pkg/frost/roast/share_submission_auth.go new file mode 100644 index 0000000000..30201f0abb --- /dev/null +++ b/pkg/frost/roast/share_submission_auth.go @@ -0,0 +1,121 @@ +package roast + +import ( + "bytes" + "errors" + "fmt" + + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// ErrShareSubmissionWrongCoordinator is returned by AuthenticateShareSubmission +// when a share names a coordinator other than the attempt's elected coordinator +// (RFC-21 Annex A). A member that resolved a different coordinator (e.g. under a +// partition) must not have its share accepted into this attempt. +var ErrShareSubmissionWrongCoordinator = errors.New( + "roast: share submission coordinator is not the attempt's elected coordinator", +) + +// ErrShareSubmissionWrongAttempt is returned when a share's attempt_context_hash +// does not match the live attempt. +var ErrShareSubmissionWrongAttempt = errors.New( + "roast: share submission attempt context hash does not match the live attempt", +) + +// ErrShareSubmissionWrongPackage is returned when a share's signing_package_hash +// does not match the signing package the coordinator distributed for the attempt +// - the share answers a different or stale package. +var ErrShareSubmissionWrongPackage = errors.New( + "roast: share submission signing package hash does not match the live package", +) + +// SignShareSubmission signs sub with the submitting member's operator key, +// setting sub.SubmitterSignature over sub.SignableBytes() (the domain-tagged +// body). A member calls this after authenticating the signing package and +// accepting its taproot root, to return its round-2 share. sub must be +// structurally valid (call Validate first). +func SignShareSubmission(signer Signer, sub *ShareSubmission) error { + payload, err := sub.SignableBytes() + if err != nil { + return err + } + signature, err := signer.Sign(payload) + if err != nil { + return fmt.Errorf("roast: sign share submission: %w", err) + } + sub.SubmitterSignature = signature + return nil +} + +// AuthenticateShareSubmission verifies that sub is a genuine round-2 share from +// its declared submitter, for this exact attempt and package: it names +// electedCoordinator, its attempt_context_hash matches the live attempt, its +// signing_package_hash matches the package the coordinator distributed +// (liveSigningPackageHash), and its signature verifies under the submitter's +// operator key over the domain-tagged body. (electedCoordinator and +// liveSigningPackageHash are resolved by the caller from the attempt and the +// distributed SignedSigningPackage - see SigningPackage.EnvelopeHash.) +// +// The signature check is over sub.SubmitterID(), so a forged submitter_id does +// not verify: the signature binds the declared submitter to the actual signer. +// +// A submission that passes is attributable to its submitter and bound to the +// package, so the caller MUST retain its exact received bytes for the +// cross-member equivocation comparison (Phase 7.2b-4). A submission that fails +// any check is forgeable or misdirected noise: the caller rejects it WITHOUT +// retaining it. Membership of the submitter in the included set and de-dup of +// repeated shares are the caller's responsibility, not this function's. +func AuthenticateShareSubmission( + verifier SignatureVerifier, + sub *ShareSubmission, + electedCoordinator group.MemberIndex, + liveAttemptContextHash []byte, + liveSigningPackageHash []byte, +) error { + // Structurally validate first: this is an authentication boundary for + // untrusted input, and the checks below use the truncating ID accessor and + // bytes.Equal. A manually-assembled (un-Unmarshaled) submission must be + // rejected before any field is trusted - e.g. a submitter_id that truncates + // to another member (uint32 -> uint8), or empty hashes that would make + // bytes.Equal(nil, nil) pass. + if err := sub.Validate(); err != nil { + return fmt.Errorf("share submission failed structural validation: %w", err) + } + if len(sub.SubmitterSignature) == 0 { + return fmt.Errorf( + "%w: share submission has no submitter signature", + ErrSignatureMissing, + ) + } + if sub.CoordinatorID() != electedCoordinator { + return fmt.Errorf( + "%w: share coordinator %d, elected %d", + ErrShareSubmissionWrongCoordinator, + sub.CoordinatorID(), + electedCoordinator, + ) + } + if !bytes.Equal(sub.AttemptContextHash, liveAttemptContextHash) { + return ErrShareSubmissionWrongAttempt + } + if !bytes.Equal(sub.SigningPackageHash, liveSigningPackageHash) { + return ErrShareSubmissionWrongPackage + } + payload, err := sub.SignableBytes() + if err != nil { + return fmt.Errorf("share submission signable bytes: %w", err) + } + if err := verifier.Verify( + payload, + sub.SubmitterSignature, + sub.SubmitterID(), + ); err != nil { + return fmt.Errorf( + "%w: submitter %d: %s", + ErrSignatureInvalid, + sub.SubmitterID(), + err.Error(), + ) + } + return nil +} diff --git a/pkg/frost/roast/share_submission_auth_test.go b/pkg/frost/roast/share_submission_auth_test.go new file mode 100644 index 0000000000..2028fdfa44 --- /dev/null +++ b/pkg/frost/roast/share_submission_auth_test.go @@ -0,0 +1,215 @@ +package roast + +import ( + "bytes" + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func TestSignShareSubmission_RoundTripAuthenticates(t *testing.T) { + const submitter = group.MemberIndex(3) + pkgHash := testSigningPackageHash() + sub := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: uint32(submitter), + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: pkgHash, + SignatureShare: []byte("frost-round2-share"), + } + if err := SignShareSubmission(&fakeSigner{id: submitter}, sub); err != nil { + t.Fatalf("sign: %v", err) + } + if len(sub.SubmitterSignature) == 0 { + t.Fatal("SignShareSubmission must set a submitter signature") + } + + // The coordinator receives the submission off the wire and authenticates it. + wire, err := sub.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + var received ShareSubmission + if err := received.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := AuthenticateShareSubmission( + fakeVerifier{}, + &received, + group.MemberIndex(testShareCoordinatorID), + pinnedContextHash[:], + pkgHash, + ); err != nil { + t.Fatalf("authenticate a genuine submission: %v", err) + } +} + +func TestAuthenticateShareSubmission_Rejections(t *testing.T) { + const submitter = group.MemberIndex(3) + elected := group.MemberIndex(testShareCoordinatorID) + pkgHash := testSigningPackageHash() + signed := func() *ShareSubmission { + sub := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: uint32(submitter), + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: pkgHash, + SignatureShare: []byte("share"), + } + if err := SignShareSubmission(&fakeSigner{id: submitter}, sub); err != nil { + t.Fatalf("sign: %v", err) + } + return sub + } + otherAttempt := bytes.Repeat([]byte{0x09}, attempt.MessageDigestLength) + + t.Run("missing signature is rejected", func(t *testing.T) { + sub := signed() + sub.SubmitterSignature = nil + err := AuthenticateShareSubmission(fakeVerifier{}, sub, elected, pinnedContextHash[:], pkgHash) + if !errors.Is(err, ErrSignatureMissing) { + t.Fatalf("want ErrSignatureMissing, got %v", err) + } + }) + + t.Run("non-elected coordinator is rejected", func(t *testing.T) { + err := AuthenticateShareSubmission(fakeVerifier{}, signed(), elected+1, pinnedContextHash[:], pkgHash) + if !errors.Is(err, ErrShareSubmissionWrongCoordinator) { + t.Fatalf("want ErrShareSubmissionWrongCoordinator, got %v", err) + } + }) + + t.Run("wrong attempt is rejected", func(t *testing.T) { + err := AuthenticateShareSubmission(fakeVerifier{}, signed(), elected, otherAttempt, pkgHash) + if !errors.Is(err, ErrShareSubmissionWrongAttempt) { + t.Fatalf("want ErrShareSubmissionWrongAttempt, got %v", err) + } + }) + + t.Run("wrong package is rejected", func(t *testing.T) { + otherPkg := bytes.Repeat([]byte{0x11}, SigningPackageHashLength) + err := AuthenticateShareSubmission(fakeVerifier{}, signed(), elected, pinnedContextHash[:], otherPkg) + if !errors.Is(err, ErrShareSubmissionWrongPackage) { + t.Fatalf("want ErrShareSubmissionWrongPackage, got %v", err) + } + }) + + t.Run("tampered signature fails verification", func(t *testing.T) { + sub := signed() + sub.SubmitterSignature[0] ^= 0xff + err := AuthenticateShareSubmission(fakeVerifier{}, sub, elected, pinnedContextHash[:], pkgHash) + if !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("want ErrSignatureInvalid, got %v", err) + } + }) + + t.Run("submission signed by a non-submitter is rejected", func(t *testing.T) { + // A different operator signs a body carrying submitter_id=3; the + // signature does not verify under member 3's key. + sub := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: uint32(submitter), + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: pkgHash, + SignatureShare: []byte("share"), + } + if err := SignShareSubmission(&fakeSigner{id: submitter + 7}, sub); err != nil { + t.Fatalf("sign: %v", err) + } + err := AuthenticateShareSubmission(fakeVerifier{}, sub, elected, pinnedContextHash[:], pkgHash) + if !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("want ErrSignatureInvalid, got %v", err) + } + }) + + t.Run("structurally invalid submission is rejected before verification", func(t *testing.T) { + // submitter_id 259 truncates to member 3 (uint32 -> uint8); signed by + // member 3 it would otherwise verify AS member 3 despite the wire id. The + // structural pre-check (submitter_id > MaxMemberIndex) rejects it before + // any signature is trusted. + sub := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: 259, + CoordinatorIDValue: testShareCoordinatorID, + SigningPackageHash: pkgHash, + SignatureShare: []byte("share"), + } + if err := SignShareSubmission(&fakeSigner{id: 3}, sub); err != nil { + t.Fatalf("sign: %v", err) + } + if err := AuthenticateShareSubmission(fakeVerifier{}, sub, elected, pinnedContextHash[:], pkgHash); err == nil { + t.Fatal("an out-of-range submitter_id must be rejected before verification") + } + }) +} + +func TestShareSubmissionBindsToSigningPackageEnvelope(t *testing.T) { + // End-to-end: a share bound to a real signing package's EnvelopeHash + // authenticates against that hash, and a share checked against a different + // package's hash is rejected. + const ( + submitter = group.MemberIndex(3) + coordinator = group.MemberIndex(7) + ) + pkg := signedTestSigningPackage(t, coordinator, nil) + pkgHash, err := pkg.EnvelopeHash() + if err != nil { + t.Fatalf("envelope hash: %v", err) + } + + sub := &ShareSubmission{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + SubmitterIDValue: uint32(submitter), + CoordinatorIDValue: uint32(coordinator), + SigningPackageHash: pkgHash[:], + SignatureShare: []byte("share"), + } + if err := SignShareSubmission(&fakeSigner{id: submitter}, sub); err != nil { + t.Fatalf("sign: %v", err) + } + if err := AuthenticateShareSubmission( + fakeVerifier{}, sub, coordinator, pinnedContextHash[:], pkgHash[:], + ); err != nil { + t.Fatalf("authenticate against the bound package: %v", err) + } + + // A different package -> different envelope hash -> rejected. + otherPkg := signedTestSigningPackage(t, coordinator, bytes.Repeat([]byte{0xcd}, TaprootMerkleRootLength)) + otherHash, err := otherPkg.EnvelopeHash() + if err != nil { + t.Fatalf("other envelope hash: %v", err) + } + if bytes.Equal(pkgHash[:], otherHash[:]) { + t.Fatal("sanity: distinct packages must have distinct envelope hashes") + } + if err := AuthenticateShareSubmission( + fakeVerifier{}, sub, coordinator, pinnedContextHash[:], otherHash[:], + ); !errors.Is(err, ErrShareSubmissionWrongPackage) { + t.Fatalf("want ErrShareSubmissionWrongPackage, got %v", err) + } +} + +func TestSigningPackageEnvelopeHash_StableAcrossWire(t *testing.T) { + pkg := signedTestSigningPackage(t, 3, nil) + wire, err := pkg.Marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + producerHash, err := pkg.EnvelopeHash() + if err != nil { + t.Fatalf("producer hash: %v", err) + } + var received SigningPackage + if err := received.Unmarshal(wire); err != nil { + t.Fatalf("unmarshal: %v", err) + } + receivedHash, err := received.EnvelopeHash() + if err != nil { + t.Fatalf("received hash: %v", err) + } + if producerHash != receivedHash { + t.Fatal("envelope hash must match for producer and receiver over the same bytes") + } +} diff --git a/pkg/frost/roast/signing_package.go b/pkg/frost/roast/signing_package.go index 8d163dfcff..126079dc2a 100644 --- a/pkg/frost/roast/signing_package.go +++ b/pkg/frost/roast/signing_package.go @@ -1,6 +1,7 @@ package roast import ( + "crypto/sha256" "errors" "fmt" @@ -208,6 +209,20 @@ func (p *SigningPackage) Marshal() ([]byte, error) { return envelope, nil } +// EnvelopeHash returns the SHA-256 of the package's on-wire +// SignedSigningPackage envelope - the value a ShareSubmission commits to in +// signing_package_hash. For a package parsed off the wire this hashes the exact +// received bytes, so the submitting member and every verifier derive the same +// binding over the bytes the coordinator distributed. The package must be +// signed (Marshal requires it). +func (p *SigningPackage) EnvelopeHash() ([sha256.Size]byte, error) { + envelope, err := p.Marshal() + if err != nil { + return [sha256.Size]byte{}, err + } + return sha256.Sum256(envelope), nil +} + // Unmarshal parses a SignedSigningPackage envelope, retains the received // body and envelope bytes verbatim (the coordinator signature is verified // over exactly these bytes), populates the fields from the body, and diff --git a/pkg/frost/roast/signing_package_auth.go b/pkg/frost/roast/signing_package_auth.go index dfe345a27b..eb0de66960 100644 --- a/pkg/frost/roast/signing_package_auth.go +++ b/pkg/frost/roast/signing_package_auth.go @@ -64,6 +64,13 @@ func AuthenticateSigningPackage( electedCoordinator group.MemberIndex, liveAttemptContextHash []byte, ) error { + // Structurally validate first (authentication boundary): reject a + // manually-assembled package before the truncating ID accessor or bytes.Equal + // checks below trust any field - e.g. a coordinator_id that truncates to the + // elected member (uint32 -> uint8). Mirrors AuthenticateShareSubmission. + if err := pkg.Validate(); err != nil { + return fmt.Errorf("signing package failed structural validation: %w", err) + } if len(pkg.CoordinatorSignature) == 0 { return fmt.Errorf( "%w: signing package has no coordinator signature", diff --git a/pkg/frost/roast/signing_package_auth_test.go b/pkg/frost/roast/signing_package_auth_test.go index 3237e86f14..2f90f8e6b4 100644 --- a/pkg/frost/roast/signing_package_auth_test.go +++ b/pkg/frost/roast/signing_package_auth_test.go @@ -131,3 +131,21 @@ func TestSigningPackage_MatchesRoot(t *testing.T) { t.Fatal("a script-path package must not match a divergent root") } } + +func TestAuthenticateSigningPackage_RejectsStructurallyInvalid(t *testing.T) { + // Authentication is a boundary for untrusted input: a manually-assembled + // package that did not pass Unmarshal/Validate - here a coordinator_id that + // truncates to the elected member (uint32 -> uint8) - must be rejected + // before verification. + pkg := &SigningPackage{ + AttemptContextHash: append([]byte(nil), pinnedContextHash[:]...), + CoordinatorIDValue: 259, // truncates to member 3 + SigningPackageBytes: []byte("pkg"), + } + if err := SignSigningPackage(&fakeSigner{id: 3}, pkg); err != nil { + t.Fatalf("sign: %v", err) + } + if err := AuthenticateSigningPackage(fakeVerifier{}, pkg, 3, pinnedContextHash[:]); err == nil { + t.Fatal("an out-of-range coordinator_id must be rejected before verification") + } +}