mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user