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
121 changes: 121 additions & 0 deletions pkg/frost/roast/share_submission_auth.go
Original file line number Diff line number Diff line change
@@ -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
}
215 changes: 215 additions & 0 deletions pkg/frost/roast/share_submission_auth_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
15 changes: 15 additions & 0 deletions pkg/frost/roast/signing_package.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package roast

import (
"crypto/sha256"
"errors"
"fmt"

Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions pkg/frost/roast/signing_package_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading