From d8da0e2e431646981025ab568b0eb83f163a2c37 Mon Sep 17 00:00:00 2001 From: zeekay Date: Fri, 26 Jun 2026 18:24:44 -0700 Subject: [PATCH] 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. --- vms/chainadapter/committee_cert_test.go | 217 ++++++++++++++++++++++++ vms/chainadapter/fhecrdt_compute.go | 121 ++++++++++++- 2 files changed, 330 insertions(+), 8 deletions(-) create mode 100644 vms/chainadapter/committee_cert_test.go diff --git a/vms/chainadapter/committee_cert_test.go b/vms/chainadapter/committee_cert_test.go new file mode 100644 index 000000000..7b3803d19 --- /dev/null +++ b/vms/chainadapter/committee_cert_test.go @@ -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") + } +} diff --git a/vms/chainadapter/fhecrdt_compute.go b/vms/chainadapter/fhecrdt_compute.go index 16347f600..f48a7fff5 100644 --- a/vms/chainadapter/fhecrdt_compute.go +++ b/vms/chainadapter/fhecrdt_compute.go @@ -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 } }