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
120 changes: 120 additions & 0 deletions internal/consensus/certificate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package consensus

import (
"crypto/ed25519"
"crypto/rand"
"errors"
"testing"
)

func certificateFixture(t *testing.T, count int) (Block, Certificate, map[string]ed25519.PublicKey) {
t.Helper()
validators := make(map[string]ed25519.PublicKey, count)
privateKeys := make(map[string]ed25519.PrivateKey, count)
for i := 0; i < count; i++ {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
id := string(rune('a' + i))
validators[id] = pub
privateKeys[id] = priv
}
block := Block{Height: 1, TimestampUnix: 1, Payload: []byte("certificate-test")}
hash := block.Hash()
cert := Certificate{BlockHash: hash}
for _, id := range []string{"a", "b", "c"} {
cert.Votes = append(cert.Votes, Vote{
ValidatorID: id,
BlockHash: hash,
Signature: ed25519.Sign(privateKeys[id], []byte(hash)),
})
}
return block, cert, validators
}

func TestVerifyCertificateRejectsDuplicateValidator(t *testing.T) {
block, cert, validators := certificateFixture(t, 4)
cert.Votes = append(cert.Votes, cert.Votes[0])
if err := VerifyCertificate(block, cert, validators); !errors.Is(err, ErrInvalidCertificate) {
t.Fatalf("expected invalid certificate for duplicate vote, got %v", err)
}
}

func TestVerifyCertificateRejectsUnknownValidatorAndBadSignature(t *testing.T) {
block, cert, validators := certificateFixture(t, 4)

unknown := cert
unknown.Votes = append([]Vote(nil), cert.Votes...)
unknown.Votes[0] = Vote{ValidatorID: "unknown", BlockHash: block.Hash(), Signature: make([]byte, ed25519.SignatureSize)}
if err := VerifyCertificate(block, unknown, validators); !errors.Is(err, ErrInvalidCertificate) {
t.Fatalf("expected unknown validator rejection, got %v", err)
}

badSignature := cert
badSignature.Votes = append([]Vote(nil), cert.Votes...)
badSignature.Votes[0].Signature = make([]byte, ed25519.SignatureSize)
if err := VerifyCertificate(block, badSignature, validators); !errors.Is(err, ErrInvalidCertificate) {
t.Fatalf("expected invalid signature rejection, got %v", err)
}
}

func TestVerifyCertificateRejectsInvalidBlockMetadata(t *testing.T) {
block, cert, validators := certificateFixture(t, 4)
block.TimestampUnix = 0
if err := VerifyCertificate(block, cert, validators); !errors.Is(err, ErrInvalidHeight) {
t.Fatalf("expected invalid block metadata rejection, got %v", err)
}
}

func TestVerifyCertificateRequiresQuorum(t *testing.T) {
block, cert, validators := certificateFixture(t, 4)
cert.Votes = cert.Votes[:2]
if err := VerifyCertificate(block, cert, validators); !errors.Is(err, ErrNoQuorum) {
t.Fatalf("expected quorum error, got %v", err)
}
}

func TestVerifyCertificateRejectsMalformedValidatorSet(t *testing.T) {
block, cert, validators := certificateFixture(t, 4)

// Invalid entries must not inflate the validator count used for quorum.
validators["malformed"] = ed25519.PublicKey{1, 2, 3}
if err := VerifyCertificate(block, cert, validators); !errors.Is(err, ErrInvalidCertificate) {
t.Fatalf("expected invalid validator set rejection, got %v", err)
}

delete(validators, "malformed")
validators[""] = validators["a"]
if err := VerifyCertificate(block, cert, validators); !errors.Is(err, ErrInvalidCertificate) {
t.Fatalf("expected empty validator ID rejection, got %v", err)
}
}

func TestVerifyCertificateRejectsDuplicateValidatorPublicKeys(t *testing.T) {
block, cert, validators := certificateFixture(t, 4)
validators["d"] = append(ed25519.PublicKey(nil), validators["a"]...)
if err := VerifyCertificate(block, cert, validators); !errors.Is(err, ErrInvalidCertificate) {
t.Fatalf("expected duplicate public key rejection, got %v", err)
}
if _, err := New(validators); err == nil {
t.Fatal("expected engine construction to reject duplicate validator public keys")
}
}

func TestVerifyCertificateRejectsMalformedParentHash(t *testing.T) {
_, cert, validators := certificateFixture(t, 4)
for _, parentHash := range []string{"", "not-a-hash", "abcd"} {
block := Block{
Height: 2,
PreviousHash: parentHash,
TimestampUnix: 2,
Payload: []byte("child"),
}
invalidCert := cert
invalidCert.BlockHash = block.Hash()
if err := VerifyCertificate(block, invalidCert, validators); !errors.Is(err, ErrInvalidHeight) {
t.Errorf("parent hash %q: expected invalid height/parent error, got %v", parentHash, err)
}
}
}
128 changes: 104 additions & 24 deletions internal/consensus/consensus.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Package consensus implements the first deterministic DRP blockchain-consensus
// primitive. It is an experimental single-height round engine, not a complete
// production BFT protocol: networking, timeouts, view changes and durable WAL are
// deliberately supplied by later integration layers.
// Package consensus implements an experimental deterministic DRP consensus primitive.
// It is not a complete production BFT protocol: networking, timeouts, view changes,
// validator-set transitions, and a durable write-ahead log remain out of scope.
package consensus

import (
Expand All @@ -22,6 +21,7 @@ var (
ErrConflictingVote = errors.New("consensus: validator already voted for another block")
ErrNoQuorum = errors.New("consensus: quorum not reached")
ErrInvalidHeight = errors.New("consensus: invalid block height or parent")
ErrInvalidCertificate = errors.New("consensus: invalid commit certificate")
)

type Block struct {
Expand Down Expand Up @@ -61,10 +61,16 @@ func New(validators map[string]ed25519.PublicKey) (*Engine, error) {
return nil, errors.New("consensus: validator set cannot be empty")
}
copySet := make(map[string]ed25519.PublicKey, len(validators))
seenKeys := make(map[string]string, len(validators))
for id, key := range validators {
if id == "" || len(key) != ed25519.PublicKeySize {
return nil, fmt.Errorf("consensus: invalid validator %q", id)
}
keyID := string(key)
if previousID, exists := seenKeys[keyID]; exists {
return nil, fmt.Errorf("consensus: validators %q and %q share a public key", previousID, id)
}
seenKeys[keyID] = id
copySet[id] = append(ed25519.PublicKey(nil), key...)
}
return &Engine{validators: copySet, votes: make(map[string]Vote)}, nil
Expand All @@ -77,9 +83,15 @@ func (e *Engine) Quorum() int { return (2*len(e.validators))/3 + 1 }
func (e *Engine) RestoreCommitted(block Block) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.last != nil || e.pending != nil { return errors.New("consensus: restore requires an empty engine") }
if block.Height == 0 { return ErrInvalidHeight }
if block.Height == 1 && block.PreviousHash != "" { return ErrInvalidHeight }
if e.last != nil || e.pending != nil {
return errors.New("consensus: restore requires an empty engine")
}
if block.Height == 0 || block.TimestampUnix <= 0 {
return ErrInvalidHeight
}
if block.Height == 1 && block.PreviousHash != "" {
return ErrInvalidHeight
}
e.last = ptrBlock(cloneBlock(block))
return nil
}
Expand All @@ -89,7 +101,9 @@ func ptrBlock(b Block) *Block { return &b }
func (e *Engine) LastCommitted() (Block, bool) {
e.mu.Lock()
defer e.mu.Unlock()
if e.last == nil { return Block{}, false }
if e.last == nil {
return Block{}, false
}
return cloneBlock(*e.last), true
}

Expand All @@ -106,10 +120,14 @@ func (e *Engine) Propose(block Block) error {
return ErrInvalidHeight
}
if e.pending != nil {
if e.pending.Hash() == block.Hash() { return nil }
if e.pending.Hash() == block.Hash() {
return nil
}
return errors.New("consensus: another proposal is already pending")
}
if block.TimestampUnix <= 0 { return errors.New("consensus: block timestamp must be positive") }
if block.TimestampUnix <= 0 {
return errors.New("consensus: block timestamp must be positive")
}
copy := cloneBlock(block)
e.pending = &copy
e.votes = make(map[string]Vote)
Expand All @@ -119,14 +137,20 @@ func (e *Engine) Propose(block Block) error {
func (e *Engine) AddVote(v Vote) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.pending == nil { return ErrNoProposal }
if e.pending == nil {
return ErrNoProposal
}
key, ok := e.validators[v.ValidatorID]
if !ok { return ErrUnknownValidator }
if !ok {
return ErrUnknownValidator
}
if v.BlockHash != e.pending.Hash() || !ed25519.Verify(key, []byte(v.BlockHash), v.Signature) {
return ErrInvalidSignature
}
if previous, exists := e.votes[v.ValidatorID]; exists {
if previous.BlockHash != v.BlockHash { return ErrConflictingVote }
if previous.BlockHash != v.BlockHash {
return ErrConflictingVote
}
return nil
}
v.Signature = append([]byte(nil), v.Signature...)
Expand All @@ -147,36 +171,92 @@ func (e *Engine) Commit() (Block, Certificate, error) { return e.CommitWith(nil)
func (e *Engine) CommitWith(persist func(Block, Certificate) error) (Block, Certificate, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.pending == nil { return Block{}, Certificate{}, ErrNoProposal }
if len(e.votes) < e.Quorum() { return Block{}, Certificate{}, ErrNoQuorum }
if e.pending == nil {
return Block{}, Certificate{}, ErrNoProposal
}
if len(e.votes) < e.Quorum() {
return Block{}, Certificate{}, ErrNoQuorum
}
hash := e.pending.Hash()
cert := Certificate{BlockHash: hash, Votes: make([]Vote, 0, len(e.votes))}
ids := make([]string, 0, len(e.votes))
for id := range e.votes { ids = append(ids, id) }
for id := range e.votes {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids { cert.Votes = append(cert.Votes, e.votes[id]) }
for _, id := range ids {
vote := e.votes[id]
vote.Signature = append([]byte(nil), vote.Signature...)
cert.Votes = append(cert.Votes, vote)
}
committed := cloneBlock(*e.pending)
if persist != nil { if err := persist(cloneBlock(committed), cert); err != nil { return Block{}, Certificate{}, fmt.Errorf("consensus: persist commit: %w", err) } }
if persist != nil {
if err := persist(cloneBlock(committed), cert); err != nil {
return Block{}, Certificate{}, fmt.Errorf("consensus: persist commit: %w", err)
}
}
e.last = &committed
e.pending = nil
e.votes = make(map[string]Vote)
return cloneBlock(committed), cert, nil
}

// VerifyCertificate strictly validates a certificate. Every included vote must be
// from a known validator, unique, bound to this block, and cryptographically valid.
// Rejecting invalid extras prevents callers from accidentally accepting a malformed
// certificate merely because enough other votes happen to be valid.
func VerifyCertificate(block Block, cert Certificate, validators map[string]ed25519.PublicKey) error {
if block.Height == 0 || block.TimestampUnix <= 0 {
return ErrInvalidHeight
}
if block.Height == 1 {
if block.PreviousHash != "" {
return ErrInvalidHeight
}
} else {
parentHash, err := hex.DecodeString(block.PreviousHash)
if err != nil || len(parentHash) != sha256.Size {
return ErrInvalidHeight
}
}
if len(validators) == 0 {
return ErrNoQuorum
}
seenKeys := make(map[string]string, len(validators))
for id, key := range validators {
if id == "" || len(key) != ed25519.PublicKeySize {
return fmt.Errorf("%w: invalid validator set entry %q", ErrInvalidCertificate, id)
}
keyID := string(key)
if previousID, exists := seenKeys[keyID]; exists {
return fmt.Errorf("%w: validators %q and %q share a public key", ErrInvalidCertificate, previousID, id)
}
seenKeys[keyID] = id
}
hash := block.Hash()
if cert.BlockHash != hash { return errors.New("consensus: certificate block hash mismatch") }
if cert.BlockHash != hash {
return fmt.Errorf("%w: block hash mismatch", ErrInvalidCertificate)
}
quorum := (2*len(validators))/3 + 1
seen := make(map[string]struct{}, len(cert.Votes))
valid := 0
for _, vote := range cert.Votes {
if _, exists := seen[vote.ValidatorID]; exists { continue }
if _, exists := seen[vote.ValidatorID]; exists {
return fmt.Errorf("%w: duplicate validator %q", ErrInvalidCertificate, vote.ValidatorID)
}
key, ok := validators[vote.ValidatorID]
if !ok || vote.BlockHash != hash || !ed25519.Verify(key, []byte(hash), vote.Signature) { continue }
if !ok {
return fmt.Errorf("%w: unknown validator %q", ErrInvalidCertificate, vote.ValidatorID)
}
if len(key) != ed25519.PublicKeySize || vote.BlockHash != hash ||
len(vote.Signature) != ed25519.SignatureSize ||
!ed25519.Verify(key, []byte(hash), vote.Signature) {
return fmt.Errorf("%w: invalid vote from %q", ErrInvalidCertificate, vote.ValidatorID)
}
seen[vote.ValidatorID] = struct{}{}
valid++
}
if len(validators) == 0 || valid < quorum { return ErrNoQuorum }
if len(seen) < quorum {
return ErrNoQuorum
}
return nil
}

Expand Down
Loading