chainadapter: real committee-cert signature verification

CommitteeCert.Verify(committee) now verifies each endorsement as a BLS
signature over the certificate's canonical signing digest by a distinct,
known committee member's registered public key. Rejects (fail-closed) nil,
unknown, duplicate, or invalid endorsements, parameter mismatches, and
sub-threshold sets; requires >= Threshold valid distinct signatures. The
engine holds a committee registry (RegisterCommittee) and VerifyResult fails
closed on an unknown committee. Replaces the count-only stub.

Tests: valid quorum + sub-threshold, duplicate-signer, forged-signature,
unknown-signer, parameter-mismatch, and engine fail-closed cases.
This commit is contained in:
zeekay
2026-06-26 19:28:35 -07:00
parent ad7ec6e5f3
commit d8da0e2e43
2 changed files with 330 additions and 8 deletions
+217
View File
@@ -0,0 +1,217 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chainadapter
import (
"fmt"
"testing"
"time"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
)
// newTestCommittee builds an n-member committee with fresh BLS keypairs and
// returns the committee roster alongside the members' secret keys (parallel
// to committee.Members / committee.PublicKeys by index).
func newTestCommittee(t *testing.T, n, threshold int) (*ComputeCommittee, []*bls.SecretKey) {
t.Helper()
members := make([][]byte, n)
pubkeys := make([][]byte, n)
sks := make([]*bls.SecretKey, n)
for i := 0; i < n; i++ {
sk, err := bls.NewSecretKey()
if err != nil {
t.Fatalf("bls keygen: %v", err)
}
sks[i] = sk
pubkeys[i] = bls.PublicKeyToCompressedBytes(bls.PublicFromSecretKey(sk))
members[i] = []byte(fmt.Sprintf("member-%d", i))
}
committee := &ComputeCommittee{
ID: ids.ID{0xC0, 0x11, 0xEE, 0x77},
Members: members,
Threshold: threshold,
PublicKeys: pubkeys,
}
return committee, sks
}
// newSignedCert returns a certificate endorsed by the committee members at the
// given indices, each signing the certificate's canonical digest with its key.
func newSignedCert(t *testing.T, committee *ComputeCommittee, sks []*bls.SecretKey, indices ...int) *CommitteeCert {
t.Helper()
cert := &CommitteeCert{
CommitteeID: committee.ID,
Threshold: committee.Threshold,
TotalMembers: len(committee.Members),
RequestID: ids.ID{0x11, 0x22, 0x33},
OutputCommitment: [32]byte{0xAB, 0xCD, 0xEF},
Timestamp: time.Unix(1_700_000_000, 0).UTC(),
}
msg := cert.signingDigest()
for _, idx := range indices {
sig, err := sks[idx].Sign(msg[:])
if err != nil {
t.Fatalf("sign: %v", err)
}
cert.Endorsements = append(cert.Endorsements, &Endorsement{
MemberID: committee.Members[idx],
MemberIndex: idx,
Signature: bls.SignatureToBytes(sig),
})
}
return cert
}
// TestCommitteeCertValidQuorum: a quorum of distinct, correctly-signed
// endorsements verifies.
func TestCommitteeCertValidQuorum(t *testing.T) {
committee, sks := newTestCommittee(t, 5, 3)
cert := newSignedCert(t, committee, sks, 0, 1, 2)
if err := cert.Verify(committee); err != nil {
t.Fatalf("expected valid quorum to verify, got %v", err)
}
// A full set (all members) must also verify.
full := newSignedCert(t, committee, sks, 0, 1, 2, 3, 4)
if err := full.Verify(committee); err != nil {
t.Fatalf("expected full endorsement set to verify, got %v", err)
}
}
// TestCommitteeCertSubThreshold: fewer than Threshold endorsements is rejected.
func TestCommitteeCertSubThreshold(t *testing.T) {
committee, sks := newTestCommittee(t, 5, 3)
cert := newSignedCert(t, committee, sks, 0, 1) // only 2 < 3
if err := cert.Verify(committee); err == nil {
t.Fatal("expected sub-threshold cert to be REJECTED")
}
}
// TestCommitteeCertDuplicateSigner: a member endorsing twice cannot inflate the
// distinct count, even though both signatures are individually valid.
func TestCommitteeCertDuplicateSigner(t *testing.T) {
committee, sks := newTestCommittee(t, 5, 3)
// Three endorsements but member 1 appears twice => only 2 distinct.
cert := newSignedCert(t, committee, sks, 0, 1, 1)
if err := cert.Verify(committee); err == nil {
t.Fatal("expected duplicate-signer cert to be REJECTED")
}
}
// TestCommitteeCertForgedSignature: a well-formed signature over the WRONG
// message (an attacker who controls a member key but signs a different digest)
// fails verification.
func TestCommitteeCertForgedSignature(t *testing.T) {
committee, sks := newTestCommittee(t, 5, 3)
cert := newSignedCert(t, committee, sks, 0, 1, 2)
// Replace endorsement 2 with a valid signature over a different message.
wrong, err := sks[2].Sign([]byte("not the certificate digest"))
if err != nil {
t.Fatalf("sign: %v", err)
}
cert.Endorsements[2].Signature = bls.SignatureToBytes(wrong)
if err := cert.Verify(committee); err == nil {
t.Fatal("expected forged/wrong-message signature to be REJECTED")
}
// Also: structurally corrupt signature bytes must be rejected, not panic.
cert2 := newSignedCert(t, committee, sks, 0, 1, 2)
cert2.Endorsements[1].Signature = []byte{0x00, 0x01, 0x02}
if err := cert2.Verify(committee); err == nil {
t.Fatal("expected malformed signature bytes to be REJECTED")
}
}
// TestCommitteeCertUnknownSigner: an endorsement that does not correspond to a
// roster member (out-of-range index, foreign key, or mismatched identity) is
// rejected.
func TestCommitteeCertUnknownSigner(t *testing.T) {
committee, sks := newTestCommittee(t, 5, 3)
// (a) Member index outside the roster.
cert := newSignedCert(t, committee, sks, 0, 1, 2)
cert.Endorsements[2].MemberIndex = 99
if err := cert.Verify(committee); err == nil {
t.Fatal("expected out-of-range member index to be REJECTED")
}
// (b) A signer outside the committee: fresh key, but claiming a valid
// in-range index. The signature is over the right digest but by the wrong
// key, so it fails verification against the roster's public key.
cert = newSignedCert(t, committee, sks, 0, 1, 2)
foreign, err := bls.NewSecretKey()
if err != nil {
t.Fatalf("keygen: %v", err)
}
msg := cert.signingDigest()
fsig, err := foreign.Sign(msg[:])
if err != nil {
t.Fatalf("sign: %v", err)
}
cert.Endorsements[2].Signature = bls.SignatureToBytes(fsig)
if err := cert.Verify(committee); err == nil {
t.Fatal("expected foreign-key endorsement to be REJECTED")
}
// (c) Correct key & signature but MemberID does not match the roster index.
cert = newSignedCert(t, committee, sks, 0, 1, 2)
cert.Endorsements[2].MemberID = []byte("someone-else")
if err := cert.Verify(committee); err == nil {
t.Fatal("expected member-id/index mismatch to be REJECTED")
}
}
// TestCommitteeCertParameterMismatch: threshold and committee identity must
// agree with the supplied roster.
func TestCommitteeCertParameterMismatch(t *testing.T) {
committee, sks := newTestCommittee(t, 5, 3)
if err := (&CommitteeCert{}).Verify(nil); err == nil {
t.Fatal("expected nil committee to be REJECTED")
}
cert := newSignedCert(t, committee, sks, 0, 1, 2)
cert.Threshold = 2 // disagrees with committee.Threshold == 3
if err := cert.Verify(committee); err == nil {
t.Fatal("expected threshold mismatch to be REJECTED")
}
cert = newSignedCert(t, committee, sks, 0, 1, 2)
cert.CommitteeID = ids.ID{0xDE, 0xAD}
if err := cert.Verify(committee); err == nil {
t.Fatal("expected committee-id mismatch to be REJECTED")
}
}
// TestVerifyResultCommitteeCert exercises the engine path: an unknown committee
// fails closed; a registered committee verifies a valid certificate.
func TestVerifyResultCommitteeCert(t *testing.T) {
committee, sks := newTestCommittee(t, 4, 2)
cert := newSignedCert(t, committee, sks, 0, 1)
result := &ComputeResult{CommitteeCert: cert}
engine := NewConfidentialComputeEngine(TEEIntelSGX)
// Unknown committee -> fail closed.
if err := engine.VerifyResult(result); err == nil {
t.Fatal("expected unregistered committee to fail closed")
}
// After registration, a valid certificate verifies.
if err := engine.RegisterCommittee(committee); err != nil {
t.Fatalf("register committee: %v", err)
}
if err := engine.VerifyResult(result); err != nil {
t.Fatalf("expected registered committee cert to verify, got %v", err)
}
// Mutating any signed field after the fact (here the certified output
// commitment) changes the digest and invalidates every endorsement.
tampered := newSignedCert(t, committee, sks, 0, 1)
tampered.OutputCommitment = [32]byte{0x99} // endorsements signed over a different value
if err := tampered.Verify(committee); err == nil {
t.Fatal("expected endorsement over a different output to be REJECTED")
}
}
+113 -8
View File
@@ -4,6 +4,7 @@
package chainadapter
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
@@ -11,6 +12,7 @@ import (
"sync"
"time"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/ids"
)
@@ -250,17 +252,93 @@ type Endorsement struct {
TEEAttestation *TEEAttestation `json:"teeAttestation,omitempty"`
}
// Verify verifies the committee certificate
func (c *CommitteeCert) Verify() error {
// signingDigest is the canonical, domain-separated message that every
// committee member signs when endorsing this certificate. It binds the
// committee, the request, the certified output commitment and the
// timestamp so that an endorsement for one (request, output) pair can
// never be replayed for another.
func (c *CommitteeCert) signingDigest() [32]byte {
h := sha256.New()
h.Write([]byte("lux/chainadapter/committee-cert/v1"))
h.Write(c.CommitteeID[:])
h.Write(c.RequestID[:])
h.Write(c.OutputCommitment[:])
var ts [8]byte
binary.BigEndian.PutUint64(ts[:], uint64(c.Timestamp.UTC().UnixNano()))
h.Write(ts[:])
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// Verify checks that the certificate carries at least Threshold valid,
// distinct endorsement signatures from members of the supplied committee.
//
// Each endorsement must:
// - reference a member that exists in the committee roster (known signer);
// - carry a MemberID matching the roster entry at that index;
// - be a BLS signature over the certificate's canonical signing digest
// that verifies under that member's registered public key;
// - be distinct — no member may endorse twice.
//
// The certificate is rejected (fail-closed) on any nil/malformed, unknown,
// duplicate, or cryptographically invalid endorsement, on a committee/cert
// parameter mismatch, or when fewer than Threshold distinct valid
// endorsements are present. There is no count-only path: every accepted
// endorsement has had its signature verified against a registered key.
func (c *CommitteeCert) Verify(committee *ComputeCommittee) error {
if committee == nil {
return ErrCommitteeCertInvalid
}
// Threshold and committee identity must agree, and the roster must be
// internally consistent (one public key per member).
if c.Threshold <= 0 || c.Threshold != committee.Threshold {
return ErrCommitteeCertInvalid
}
if c.CommitteeID != committee.ID {
return ErrCommitteeCertInvalid
}
if len(committee.PublicKeys) != len(committee.Members) || len(committee.PublicKeys) == 0 {
return ErrCommitteeCertInvalid
}
if len(c.Endorsements) < c.Threshold {
return ErrCommitteeCertInvalid
}
// In production, verify:
// 1. Each endorsement signature
// 2. Endorsers are valid committee members
// 3. Optional: aggregate signature
msg := c.signingDigest()
seen := make(map[int]struct{}, len(c.Endorsements))
for _, e := range c.Endorsements {
if e == nil {
return ErrCommitteeCertInvalid
}
idx := e.MemberIndex
if idx < 0 || idx >= len(committee.PublicKeys) {
return ErrCommitteeCertInvalid // unknown signer
}
if _, dup := seen[idx]; dup {
return ErrCommitteeCertInvalid // duplicate signer
}
if !bytes.Equal(e.MemberID, committee.Members[idx]) {
return ErrCommitteeCertInvalid // identity does not match roster index
}
pk, err := bls.PublicKeyFromCompressedBytes(committee.PublicKeys[idx])
if err != nil {
return ErrCommitteeCertInvalid
}
sig, err := bls.SignatureFromBytes(e.Signature)
if err != nil {
return ErrCommitteeCertInvalid
}
if !bls.Verify(pk, sig, msg[:]) {
return ErrCommitteeCertInvalid // forged or invalid signature
}
seen[idx] = struct{}{}
}
if len(seen) < c.Threshold {
return ErrCommitteeCertInvalid
}
return nil
}
@@ -277,6 +355,11 @@ type ConfidentialComputeEngine struct {
// Result cache
results map[ids.ID]*ComputeResult
// Registered committee rosters, keyed by committee ID. A committee
// certificate can only be verified against a roster registered here;
// an unknown committee fails closed.
committees map[ids.ID]*ComputeCommittee
}
// ComputeSession tracks an active computation
@@ -302,9 +385,24 @@ func NewConfidentialComputeEngine(teeType TEEType) *ConfidentialComputeEngine {
teeAvailable: true, // Check actual TEE availability
sessions: make(map[ids.ID]*ComputeSession),
results: make(map[ids.ID]*ComputeResult),
committees: make(map[ids.ID]*ComputeCommittee),
}
}
// RegisterCommittee registers a committee roster so that certificates the
// committee produces can be verified against its members' public keys.
// PublicKeys and Members must be parallel arrays (one compressed BLS public
// key per member).
func (e *ConfidentialComputeEngine) RegisterCommittee(committee *ComputeCommittee) error {
if committee == nil || len(committee.PublicKeys) != len(committee.Members) || len(committee.PublicKeys) == 0 {
return ErrCommitteeCertInvalid
}
e.mu.Lock()
defer e.mu.Unlock()
e.committees[committee.ID] = committee
return nil
}
// SubmitRequest submits a compute request
func (e *ConfidentialComputeEngine) SubmitRequest(ctx context.Context, req *ComputeRequest) error {
e.mu.Lock()
@@ -491,9 +589,16 @@ func (e *ConfidentialComputeEngine) VerifyResult(result *ComputeResult) error {
}
}
// Verify committee cert if present
// Verify committee cert if present. The roster must have been
// registered; an unknown committee fails closed.
if result.CommitteeCert != nil {
if err := result.CommitteeCert.Verify(); err != nil {
e.mu.RLock()
committee := e.committees[result.CommitteeCert.CommitteeID]
e.mu.RUnlock()
if committee == nil {
return ErrCommitteeCertInvalid
}
if err := result.CommitteeCert.Verify(committee); err != nil {
return err
}
}