diff --git a/simplex/qc.go b/simplex/qc.go new file mode 100644 index 000000000000..29ffc4bb681d --- /dev/null +++ b/simplex/qc.go @@ -0,0 +1,159 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package simplex + +import ( + "errors" + "fmt" + + "github.com/ava-labs/simplex" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/crypto/bls" +) + +var ( + _ simplex.QuorumCertificate = (*QC)(nil) + _ simplex.QCDeserializer = QCDeserializer{} + _ simplex.SignatureAggregator = (*SignatureAggregator)(nil) + + // QC errors + errFailedToParseQC = errors.New("failed to parse quorum certificate") + errUnexpectedSigners = errors.New("unexpected number of signers in quorum certificate") + errSignatureAggregation = errors.New("signature aggregation failed") + errEncodingMessageToSign = errors.New("failed to encode message to sign") +) + +// QC represents a quorum certificate in the Simplex consensus protocol. +type QC struct { + verifier *BLSVerifier + sig *bls.Signature + signers []simplex.NodeID +} + +type SerializedQC struct { + Sig []byte `serialize:"true"` + Signers []simplex.NodeID `serialize:"true"` +} + +// Signers returns the list of signers for the quorum certificate. +func (qc *QC) Signers() []simplex.NodeID { + return qc.signers +} + +// Verify checks if the quorum certificate is valid by verifying the aggregated signature against the signers' public keys. +func (qc *QC) Verify(msg []byte) error { + pks := make([]*bls.PublicKey, 0, len(qc.signers)) + quorum := simplex.Quorum(len(qc.verifier.nodeID2PK)) + if len(qc.signers) != quorum { + return fmt.Errorf("%w: expected %d signers but got %d", errUnexpectedSigners, quorum, len(qc.signers)) + } + + // ensure all signers are in the membership set + for _, signer := range qc.signers { + pk, exists := qc.verifier.nodeID2PK[ids.NodeID(signer)] + if !exists { + return fmt.Errorf("%w: %x", errSignerNotFound, signer) + } + + pks = append(pks, pk) + } + + // aggregate the public keys + aggPK, err := bls.AggregatePublicKeys(pks) + if err != nil { + return fmt.Errorf("%w: %w", errSignatureAggregation, err) + } + + message2Verify, err := encodeMessageToSign(msg, qc.verifier.chainID, qc.verifier.networkID) + if err != nil { + return fmt.Errorf("%w: %w", errEncodingMessageToSign, err) + } + + if !bls.Verify(aggPK, qc.sig, message2Verify) { + return errSignatureVerificationFailed + } + + return nil +} + +// Bytes serializes the quorum certificate into bytes. +func (qc *QC) Bytes() []byte { + serializedQC := &SerializedQC{ + Sig: bls.SignatureToBytes(qc.sig), + Signers: qc.signers, + } + + bytes, err := Codec.Marshal(CodecVersion, serializedQC) + if err != nil { + panic(fmt.Errorf("failed to marshal QC: %w", err)) + } + return bytes +} + +type QCDeserializer BLSVerifier + +// DeserializeQuorumCertificate deserializes a quorum certificate from bytes. +func (d QCDeserializer) DeserializeQuorumCertificate(bytes []byte) (simplex.QuorumCertificate, error) { + var serializedQC SerializedQC + if _, err := Codec.Unmarshal(bytes, &serializedQC); err != nil { + return nil, fmt.Errorf("%w: %w", errFailedToParseQC, err) + } + + sig, err := bls.SignatureFromBytes(serializedQC.Sig) + if err != nil { + return nil, fmt.Errorf("%w: %w", errFailedToParseSignature, err) + } + + qc := QC{ + sig: sig, + signers: serializedQC.Signers, + } + + verifier := BLSVerifier(d) + qc.verifier = &verifier + + return &qc, nil +} + +// SignatureAggregator aggregates signatures into a quorum certificate. +type SignatureAggregator BLSVerifier + +// Aggregate aggregates the provided signatures into a quorum certificate. +// It requires at least a quorum of signatures to succeed. +// If any signature is from a signer not in the membership set, it returns an error. +func (a SignatureAggregator) Aggregate(signatures []simplex.Signature) (simplex.QuorumCertificate, error) { + quorumSize := simplex.Quorum(len(a.nodeID2PK)) + if len(signatures) < quorumSize { + return nil, fmt.Errorf("%w: expected %d signatures but got %d", errUnexpectedSigners, quorumSize, len(signatures)) + } + signatures = signatures[:quorumSize] + + signers := make([]simplex.NodeID, 0, quorumSize) + sigs := make([]*bls.Signature, 0, quorumSize) + for _, signature := range signatures { + signer := signature.Signer + if _, exists := a.nodeID2PK[ids.NodeID(signer)]; !exists { + return nil, fmt.Errorf("%w: %x", errSignerNotFound, signer) + } + signers = append(signers, signer) + sig, err := bls.SignatureFromBytes(signature.Value) + if err != nil { + return nil, fmt.Errorf("%w: %w", errFailedToParseSignature, err) + } + sigs = append(sigs, sig) + } + + aggregatedSig, err := bls.AggregateSignatures(sigs) + if err != nil { + return nil, fmt.Errorf("%w: %w", errSignatureAggregation, err) + } + + verifier := BLSVerifier(a) + return &QC{ + verifier: &verifier, + signers: signers, + sig: aggregatedSig, + }, nil +} diff --git a/simplex/qc_test.go b/simplex/qc_test.go new file mode 100644 index 000000000000..c583c2081160 --- /dev/null +++ b/simplex/qc_test.go @@ -0,0 +1,220 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package simplex + +import ( + "testing" + + "github.com/ava-labs/simplex" + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/crypto/bls" +) + +// TestQCAggregateAndSign tests the aggregation of multiple signatures +// and then verifies the generated quorum certificate on that message. +func TestQCAggregateAndSign(t *testing.T) { + configs := newNetworkConfigs(t, 2) + quorum := simplex.Quorum(len(configs)) + + msg := []byte("Begin at the beginning, and go on till you come to the end: then stop") + + signatures := make([]simplex.Signature, 0, quorum) + nodes := make([]simplex.NodeID, 0, quorum) + var signer BLSSigner + var verifier BLSVerifier + + for _, config := range configs { + signer, verifier = NewBLSAuth(config) + + sig, err := signer.Sign(msg) + require.NoError(t, err) + require.NoError(t, verifier.Verify(msg, sig, config.Ctx.NodeID[:])) + + signatures = append(signatures, simplex.Signature{ + Signer: config.Ctx.NodeID[:], + Value: sig, + }) + nodes = append(nodes, config.Ctx.NodeID[:]) + } + + // aggregate the signatures into a quorum certificate + signatureAggregator := SignatureAggregator(verifier) + qc, err := signatureAggregator.Aggregate(signatures) + + require.NoError(t, err) + require.Equal(t, nodes, qc.Signers()) + // verify the quorum certificate + require.NoError(t, qc.Verify(msg)) + + d := QCDeserializer(verifier) + // try to deserialize the quorum certificate + deserializedQC, err := d.DeserializeQuorumCertificate(qc.Bytes()) + require.NoError(t, err) + + require.Equal(t, qc.Signers(), deserializedQC.Signers()) + require.Equal(t, qc.Bytes(), deserializedQC.Bytes()) + require.NoError(t, deserializedQC.Verify(msg)) +} + +func TestQCSignerNotInMembershipSet(t *testing.T) { + node1 := newEngineConfig(t, 2) + signer, verifier := NewBLSAuth(node1) + + // nodes 1 and 2 will sign the same message + msg := []byte("Begin at the beginning, and go on till you come to the end: then stop") + sig, err := signer.Sign(msg) + require.NoError(t, err) + require.NoError(t, verifier.Verify(msg, sig, node1.Ctx.NodeID[:])) + + // add a new validator, but it won't be in the membership set of the first node signer/verifier + node2 := newEngineConfig(t, 2) + node2.Ctx.ChainID = node1.Ctx.ChainID + + // sign the same message with the new node + signer2, verifier2 := NewBLSAuth(node2) + sig2, err := signer2.Sign(msg) + require.NoError(t, err) + require.NoError(t, verifier2.Verify(msg, sig2, node2.Ctx.NodeID[:])) + + // aggregate the signatures into a quorum certificate + signatureAggregator := SignatureAggregator(verifier) + _, err = signatureAggregator.Aggregate( + []simplex.Signature{ + {Signer: node1.Ctx.NodeID[:], Value: sig}, + {Signer: node2.Ctx.NodeID[:], Value: sig2}, + }, + ) + require.ErrorIs(t, err, errSignerNotFound) +} + +func TestQCDeserializerInvalidInput(t *testing.T) { + config := newEngineConfig(t, 2) + + _, verifier := NewBLSAuth(config) + deserializer := QCDeserializer(verifier) + + tests := []struct { + name string + input []byte + err error + }{ + { + name: "too short input", + input: make([]byte, 10), + err: errFailedToParseQC, + }, + { + name: "invalid signature bytes", + input: make([]byte, simplex.Quorum(len(verifier.nodeID2PK))*ids.NodeIDLen+bls.SignatureLen), + err: errFailedToParseQC, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := deserializer.DeserializeQuorumCertificate(tt.input) + require.ErrorIs(t, err, tt.err) + }) + } +} + +func TestSignatureAggregatorInsufficientSignatures(t *testing.T) { + config := newEngineConfig(t, 3) + + signer, verifier := NewBLSAuth(config) + msg := []byte("test message") + sig, err := signer.Sign(msg) + require.NoError(t, err) + + // try to aggregate with only 1 signature when quorum is 2 + signatureAggregator := SignatureAggregator(verifier) + _, err = signatureAggregator.Aggregate( + []simplex.Signature{ + {Signer: config.Ctx.NodeID[:], Value: sig}, + }, + ) + require.ErrorIs(t, err, errUnexpectedSigners) +} + +func TestSignatureAggregatorInvalidSignatureBytes(t *testing.T) { + config := newEngineConfig(t, 2) + + signer, verifier := NewBLSAuth(config) + msg := []byte("test message") + sig, err := signer.Sign(msg) + require.NoError(t, err) + + signatureAggregator := SignatureAggregator(verifier) + _, err = signatureAggregator.Aggregate( + []simplex.Signature{ + {Signer: config.Ctx.NodeID[:], Value: sig}, + {Signer: config.Ctx.NodeID[:], Value: []byte("invalid signature")}, + }, + ) + require.ErrorIs(t, err, errFailedToParseSignature) +} + +func TestSignatureAggregatorExcessSignatures(t *testing.T) { + configs := newNetworkConfigs(t, 4) + + msg := []byte("test message") + + var nodeSigner BLSSigner + var verifier BLSVerifier + + // Create signatures from all 4 nodes + signatures := make([]simplex.Signature, 4) + for i, config := range configs { + nodeSigner, verifier = NewBLSAuth(config) + sig, err := nodeSigner.Sign(msg) + require.NoError(t, err) + + signatures[i] = simplex.Signature{Signer: config.Ctx.NodeID[:], Value: sig} + } + + // Aggregate should only use the first 3 signatures + signatureAggregator := SignatureAggregator(verifier) + qc, err := signatureAggregator.Aggregate(signatures) + require.NoError(t, err) + + // Should only have 3 signers, not 4 + require.Len(t, qc.Signers(), simplex.Quorum(len(configs))) + require.NoError(t, qc.Verify(msg)) +} + +func TestQCVerifyWithWrongMessage(t *testing.T) { + configs := newNetworkConfigs(t, 2) + + node1 := configs[0] + node2 := configs[1] + signer, verifier := NewBLSAuth(node1) + originalMsg := []byte("original message") + wrongMsg := []byte("wrong message") + + // Create signatures for original message + sig1, err := signer.Sign(originalMsg) + require.NoError(t, err) + + signer2, _ := NewBLSAuth(node2) + sig2, err := signer2.Sign(originalMsg) + require.NoError(t, err) + + signatureAggregator := SignatureAggregator(verifier) + qc, err := signatureAggregator.Aggregate( + []simplex.Signature{ + {Signer: node1.Ctx.NodeID[:], Value: sig1}, + {Signer: node2.Ctx.NodeID[:], Value: sig2}, + }, + ) + require.NoError(t, err) + + // Verify with original message should succeed + require.NoError(t, qc.Verify(originalMsg)) + + // Verify with wrong message should fail + err = qc.Verify(wrongMsg) + require.ErrorIs(t, err, errSignatureVerificationFailed) +}