From aabb76a7431529e81bb23cebd3dc767aa4da1230 Mon Sep 17 00:00:00 2001 From: NeonTechnoX <89855167+NeonTechno@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:18:45 +0000 Subject: [PATCH 1/6] fix(consensus): strictly validate quorum certificates --- internal/consensus/consensus.go | 101 ++++++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 24 deletions(-) diff --git a/internal/consensus/consensus.go b/internal/consensus/consensus.go index 25e3ced..7158971 100644 --- a/internal/consensus/consensus.go +++ b/internal/consensus/consensus.go @@ -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 ( @@ -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 { @@ -77,9 +77,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 } @@ -89,7 +95,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 } @@ -106,10 +114,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 = © e.votes = make(map[string]Vote) @@ -119,14 +131,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...) @@ -147,36 +165,71 @@ 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 || (block.Height == 1 && block.PreviousHash != "") { + return ErrInvalidHeight + } + if len(validators) == 0 { + return ErrNoQuorum + } 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 } From 010bdd93036b9fc4cad0f1b261ec38bb756b2ca8 Mon Sep 17 00:00:00 2001 From: NeonTechnoX <89855167+NeonTechno@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:18:57 +0000 Subject: [PATCH 2/6] test(consensus): reject malformed and duplicate certificate votes --- internal/consensus/certificate_test.go | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 internal/consensus/certificate_test.go diff --git a/internal/consensus/certificate_test.go b/internal/consensus/certificate_test.go new file mode 100644 index 0000000..b74a29f --- /dev/null +++ b/internal/consensus/certificate_test.go @@ -0,0 +1,76 @@ +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) + } +} From 1c6f70b88516e5b4be5751b6d2e6a410c6266346 Mon Sep 17 00:00:00 2001 From: NeonTechnoX <89855167+NeonTechno@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:16:26 +0000 Subject: [PATCH 3/6] fix(consensus): validate validator sets and parent hashes --- internal/consensus/consensus.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/consensus/consensus.go b/internal/consensus/consensus.go index 7158971..f981171 100644 --- a/internal/consensus/consensus.go +++ b/internal/consensus/consensus.go @@ -200,12 +200,27 @@ func (e *Engine) CommitWith(persist func(Block, Certificate) error) (Block, Cert // 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 || (block.Height == 1 && block.PreviousHash != "") { + 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 } + for id, key := range validators { + if id == "" || len(key) != ed25519.PublicKeySize { + return fmt.Errorf("%w: invalid validator set entry %q", ErrInvalidCertificate, id) + } + } hash := block.Hash() if cert.BlockHash != hash { return fmt.Errorf("%w: block hash mismatch", ErrInvalidCertificate) From 18e95e784b7649cd570717cd2ed65c758b3dfae5 Mon Sep 17 00:00:00 2001 From: NeonTechnoX <89855167+NeonTechno@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:16:34 +0000 Subject: [PATCH 4/6] test(consensus): cover malformed validator sets and parent hashes --- internal/consensus/certificate_test.go | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/internal/consensus/certificate_test.go b/internal/consensus/certificate_test.go index b74a29f..2a93e82 100644 --- a/internal/consensus/certificate_test.go +++ b/internal/consensus/certificate_test.go @@ -74,3 +74,36 @@ func TestVerifyCertificateRequiresQuorum(t *testing.T) { 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 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) + } + } +} From d12648586b9acffe8abc1625d46b33fd7ddeee80 Mon Sep 17 00:00:00 2001 From: NeonTechnoX <89855167+NeonTechno@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:17:44 +0000 Subject: [PATCH 5/6] fix(consensus): reject duplicate validator public keys --- internal/consensus/consensus.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/consensus/consensus.go b/internal/consensus/consensus.go index f981171..318ea10 100644 --- a/internal/consensus/consensus.go +++ b/internal/consensus/consensus.go @@ -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 @@ -216,10 +222,16 @@ func VerifyCertificate(block Block, cert Certificate, validators map[string]ed25 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 { From 65013c00982cd2e3208edc7c37d9b23a092b938d Mon Sep 17 00:00:00 2001 From: NeonTechnoX <89855167+NeonTechno@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:17:47 +0000 Subject: [PATCH 6/6] test(consensus): reject duplicate validator public keys --- internal/consensus/certificate_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/consensus/certificate_test.go b/internal/consensus/certificate_test.go index 2a93e82..2ea8055 100644 --- a/internal/consensus/certificate_test.go +++ b/internal/consensus/certificate_test.go @@ -91,6 +91,17 @@ func TestVerifyCertificateRejectsMalformedValidatorSet(t *testing.T) { } } +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"} {