mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
validator: node-bound proof of possession, one key per node
The forged certificate that reached crates.io was killed at verification, but a red review showed the hole had only moved to registration: a key was admitted on key_validate alone, which proves a point is on the curve and nothing about who holds its secret, and duplicate keys were merged into one validator carrying many node ids — so a floor written to require many distinct signers was cleared by one holder registering one key under several ids. This is the Go oracle for the standard ruled in luxfi/conformance: it defines the bytes Rust and C++ reproduce. validator/pop is the primitive. A registrant signs its own identity followed by its own key — node(20, ids.NodeID) ‖ pubkey(48, compressed G1), 68 bytes, no separator — under the proof-of-possession domain BLS_POP_..._POP_, distinct from the vote's ..._NUL_ so a vote is never a proof and a proof never a vote. The node is bound because the pubkey-only IETF proof travels with the key: bound to nothing, it re-registers under any identity. 20 bytes because a Lux validator's identity IS its NodeID; the 32-byte consensus Id names blocks, not nodes, and has no canonical inverse, so widening would invent a second name for one node. validator/registration is the admission rule, written once: encoding, then possession, then uniqueness, then weight — a pairing on non-points is undefined, and weight counted before uniqueness is weight counted twice. A set is admitted whole or refused whole, because a set that silently drops a signer no longer describes its own weight. FlattenValidatorSet's merge of duplicate keys is retired here: it was the only thing that made many-nodes-one-key representable, and after the uniqueness rule that state does not exist to normalize. Order and PublicKeyBytes still match the upstream flatten exactly, so a proven set and a flattened-distinct set are the same set. engine/chain keys the cert store on the DECISION — the signed canonical identity — not the transport envelope id, which no signature covers: two envelopes of one decision were two store entries for one finality fact, and a verified cert could be filed under a block it never attested. Keyed by decision, a cert is found by anything that finalized that decision, and Put is idempotent per decision. The pubkey-only P-chain proof (luxfi/node AddValidatorTx) is NOT touched here: moving it node-bound is a network upgrade, every validator re-proves, and it is activation-gated as the deliberate last step. Anywhere a validator set that shares a key across node ids is admitted today, this now refuses it — that is the intended behaviour, and the coordination point with the P-chain step. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -138,33 +138,39 @@ func (t *Transitive) recoveredOuterAt(h uint64) (ids.ID, bool) {
|
||||
}
|
||||
|
||||
// storeServedCertLocked records the marshaled finality cert for a just-finalized
|
||||
// block so this node can serve it to a peer catching up. Called from the SOLE
|
||||
// DECISION so this node can serve it to a peer catching up. Called from the SOLE
|
||||
// finalizer (acceptWithCertCore) with the engine lock held, so EVERY finalize path
|
||||
// — local assembly, an incoming gossiped cert, and the K==1 single-validator cert —
|
||||
// captures its cert in this ONE place. Idempotent per block id; bounded to
|
||||
// captures its cert in this ONE place. Idempotent per decision; bounded to
|
||||
// maxServedCerts by oldest-height (== FIFO) eviction.
|
||||
//
|
||||
// The key is certCanonical(cert) — the identity the signatures cover — and not the
|
||||
// outer envelope that carried it, so a peer holding a canonical-equivalent sibling
|
||||
// wrapper finds the cert this node actually has. See CertStore for the whole
|
||||
// argument; the in-memory window and the durable store are keyed alike, because a
|
||||
// restart must not change which asks are answerable.
|
||||
//
|
||||
// The caller holds t.mu. The DURABLE copy is written separately, by
|
||||
// persistServedCert, off the lock — see there for why.
|
||||
func (t *Transitive) storeServedCertLocked(blockID ids.ID, certBytes []byte) {
|
||||
func (t *Transitive) storeServedCertLocked(decision ids.ID, certBytes []byte) {
|
||||
if len(certBytes) == 0 {
|
||||
return
|
||||
}
|
||||
if t.certBytesByBlock == nil {
|
||||
t.certBytesByBlock = make(map[ids.ID][]byte, maxServedCerts)
|
||||
if t.certByDecision == nil {
|
||||
t.certByDecision = make(map[ids.ID][]byte, maxServedCerts)
|
||||
}
|
||||
if _, exists := t.certBytesByBlock[blockID]; exists {
|
||||
if _, exists := t.certByDecision[decision]; exists {
|
||||
return
|
||||
}
|
||||
t.certBytesByBlock[blockID] = certBytes
|
||||
t.certServedOrder = append(t.certServedOrder, blockID)
|
||||
t.certByDecision[decision] = certBytes
|
||||
t.certServedOrder = append(t.certServedOrder, decision)
|
||||
// Evict the oldest finalized cert(s) once past the window. A single insert can
|
||||
// only overflow by one, but loop defensively so the invariant holds even if the
|
||||
// cap is lowered at runtime.
|
||||
for len(t.certServedOrder) > maxServedCerts {
|
||||
evict := t.certServedOrder[0]
|
||||
t.certServedOrder = t.certServedOrder[1:]
|
||||
delete(t.certBytesByBlock, evict)
|
||||
delete(t.certByDecision, evict)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,33 +184,53 @@ func (t *Transitive) storeServedCertLocked(blockID ids.ID, certBytes []byte) {
|
||||
// is whether a crash loses the ability to SERVE this one height. That costs a
|
||||
// straggler one fetch from another peer; it cannot cost safety, because nothing
|
||||
// finalizes on a cert this node failed to write.
|
||||
func (t *Transitive) persistServedCert(blockID ids.ID, height uint64, certBytes []byte) {
|
||||
func (t *Transitive) persistServedCert(decision ids.ID, height uint64, certBytes []byte) {
|
||||
t.mu.RLock()
|
||||
certs := t.certs
|
||||
t.mu.RUnlock()
|
||||
if certs == nil || len(certBytes) == 0 {
|
||||
return
|
||||
}
|
||||
if err := certs.Put(blockID, height, certBytes); err != nil && t.log != nil {
|
||||
if err := certs.Put(decision, height, certBytes); err != nil && t.log != nil {
|
||||
t.log.Warn("could not persist the finality cert — once this process exits, no peer can be caught up through this height",
|
||||
log.Stringer("blockID", blockID),
|
||||
log.Stringer("decision", decision),
|
||||
log.Uint64("height", height),
|
||||
log.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
// CertForBlock returns the marshaled α-of-K finality cert this node recorded when
|
||||
// it finalized blockID, so the node can hand it to a peer catching up. ok is false
|
||||
// when blockID is not finalized here, or its cert has aged out of the served window
|
||||
// (the peer then fetches from another node, or bootstraps if it is too far behind).
|
||||
// it finalized blk, so the node can hand it to a peer catching up. ok is false when
|
||||
// blk is not finalized here, or its cert has aged out of the served window (the
|
||||
// peer then fetches from another node, or bootstraps if it is too far behind).
|
||||
//
|
||||
// IT TAKES THE BLOCK, NOT AN ID, because the cert is filed under the DECISION and
|
||||
// only the block knows its own: canonicalIDOf reads the inner execution commitment
|
||||
// off it, degrading to the outer id on a bare chain. An id alone cannot be resolved
|
||||
// — the engine drops a block's pending state at finality, and the durable store
|
||||
// outlives the process — so asking for the block is asking for the one thing that
|
||||
// can answer. Every caller already holds it: the serve path fetches the block to
|
||||
// pair with the cert, and pairing a block with a cert filed under a DIFFERENT
|
||||
// envelope of the same decision is exactly what this fixes.
|
||||
//
|
||||
// The returned bytes decode+verify to the SAME VerifiedQuorumCert every node
|
||||
// finalized blockID on — serving it lets the peer finalize through its own
|
||||
// finalized that decision on — serving it lets the peer finalize through its own
|
||||
// HandleIncomingCert with no trust in this node. A defensive copy is returned so a
|
||||
// caller cannot mutate the served buffer.
|
||||
func (t *Transitive) CertForBlock(blockID ids.ID) ([]byte, bool) {
|
||||
func (t *Transitive) CertForBlock(blk block.Block) ([]byte, bool) {
|
||||
if blk == nil {
|
||||
return nil, false
|
||||
}
|
||||
return t.certFor(canonicalIDOf(blk))
|
||||
}
|
||||
|
||||
// certFor is the lookup itself, given the decision already resolved. It is the
|
||||
// ONE place the two tiers are read in order — the process window, then the
|
||||
// durable store — so a restart changes how far back an ask reaches and nothing
|
||||
// else about how it is answered.
|
||||
func (t *Transitive) certFor(decision ids.ID) ([]byte, bool) {
|
||||
t.mu.RLock()
|
||||
b, ok := t.certBytesByBlock[blockID]
|
||||
b, ok := t.certByDecision[decision]
|
||||
certs := t.certs
|
||||
t.mu.RUnlock()
|
||||
if ok {
|
||||
@@ -216,7 +242,7 @@ func (t *Transitive) CertForBlock(blockID ids.ID) ([]byte, bool) {
|
||||
if certs == nil {
|
||||
return nil, false
|
||||
}
|
||||
return certs.Get(blockID)
|
||||
return certs.Get(decision)
|
||||
}
|
||||
|
||||
// VerifyCatchupCertificate verifies that certBytes is a portable quorum proof for
|
||||
|
||||
@@ -405,7 +405,7 @@ func TestCatchup_CertForBlockServesWhatWasFinalized(t *testing.T) {
|
||||
blk := buildGap(vm, tip, 1)[0] // N+1
|
||||
|
||||
// Before finalize: nothing to serve.
|
||||
if _, ok := rt.CertForBlock(blk.id); ok {
|
||||
if _, ok := rt.CertForBlock(blk); ok {
|
||||
t.Fatal("CertForBlock returned a cert for an unfinalized block")
|
||||
}
|
||||
|
||||
@@ -416,7 +416,7 @@ func TestCatchup_CertForBlockServesWhatWasFinalized(t *testing.T) {
|
||||
|
||||
// After finalize: the node serves a cert that decodes and verifies to the same
|
||||
// finality witness, so a peer can finalize on it with zero trust in this node.
|
||||
served, ok := rt.CertForBlock(blk.id)
|
||||
served, ok := rt.CertForBlock(blk)
|
||||
if !ok {
|
||||
t.Fatal("CertForBlock did not serve the finalized block's cert")
|
||||
}
|
||||
|
||||
+40
-30
@@ -38,26 +38,36 @@ import (
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// CertStore is the durable home for finality certs, keyed by the block each one
|
||||
// finalizes. The engine writes one at the sole finalizer and reads one to serve
|
||||
// CertStore is the durable home for finality certs, keyed by the DECISION each
|
||||
// one proves. The engine writes one at the sole finalizer and reads one to serve
|
||||
// a peer catching up.
|
||||
//
|
||||
// Put is idempotent per block. Get reports absence rather than error — a cert
|
||||
// THE KEY IS THE SIGNED IDENTITY, NOT THE ENVELOPE. A cert's signatures cover the
|
||||
// canonical execution commitment (chain.decision: the inner CanonicalID, or the
|
||||
// block's own id on a bare chain that records none) — never the outer proposervm
|
||||
// envelope that happened to carry it. Two envelopes wrapping the same inner block
|
||||
// are the same decision and are collapsed to duplicates everywhere else in the
|
||||
// engine; keyed by envelope, they were two store entries for one finality fact,
|
||||
// and a peer that held the sibling wrapper asked for a cert this node had and was
|
||||
// told no. Keyed by decision, the cert is found by anything that finalized the
|
||||
// same thing, which is exactly the set of nodes it is valid for.
|
||||
//
|
||||
// Put is idempotent per decision. Get reports absence rather than error — a cert
|
||||
// this node cannot produce is a cert the peer fetches elsewhere, which is the
|
||||
// same handling either way.
|
||||
type CertStore interface {
|
||||
// Put durably records cert as the finality proof for blockID at height. The
|
||||
// Put durably records cert as the finality proof for decision at height. The
|
||||
// height orders eviction: the store keeps the most recent maxServedCerts and
|
||||
// drops the lowest heights, so retention follows the chain rather than the
|
||||
// order writes happened to arrive.
|
||||
Put(blockID ids.ID, height uint64, cert []byte) error
|
||||
// Get returns the cert recorded for blockID, or ok=false when this node never
|
||||
Put(decision ids.ID, height uint64, cert []byte) error
|
||||
// Get returns the cert recorded for decision, or ok=false when this node never
|
||||
// finalized it, it has aged out of the window, or it cannot be read back.
|
||||
Get(blockID ids.ID) ([]byte, bool)
|
||||
Get(decision ids.ID) ([]byte, bool)
|
||||
}
|
||||
|
||||
// fileCerts is the one concrete CertStore: a directory holding one file per
|
||||
// cert, named "<height>-<blockID>" with the height zero-padded so lexical order
|
||||
// cert, named "<height>-<decision>" with the height zero-padded so lexical order
|
||||
// IS height order. That name carries everything the store needs, which is why
|
||||
// there is no index file — nothing to fall out of step with the directory, and
|
||||
// nothing whose corruption would lose certs that are sitting right there.
|
||||
@@ -67,9 +77,9 @@ type CertStore interface {
|
||||
type fileCerts struct {
|
||||
dir string
|
||||
|
||||
mu sync.Mutex
|
||||
order []certSlot // ascending height — the eviction order
|
||||
byBlock map[ids.ID]string // blockID -> file name
|
||||
mu sync.Mutex
|
||||
order []certSlot // ascending height — the eviction order
|
||||
byDecision map[ids.ID]string // decision -> file name
|
||||
}
|
||||
|
||||
type certSlot struct {
|
||||
@@ -81,11 +91,11 @@ type certSlot struct {
|
||||
// numeric sort by height. 20 digits covers the full uint64 range.
|
||||
const certNameWidth = 20
|
||||
|
||||
func certName(height uint64, blockID ids.ID) string {
|
||||
return fmt.Sprintf("%0*d-%s", certNameWidth, height, blockID)
|
||||
func certName(height uint64, decision ids.ID) string {
|
||||
return fmt.Sprintf("%0*d-%s", certNameWidth, height, decision)
|
||||
}
|
||||
|
||||
// parseCertName recovers (height, blockID) from a file name written by certName.
|
||||
// parseCertName recovers (height, decision) from a file name written by certName.
|
||||
// A name that does not round-trip is not ours; the caller ignores the file rather
|
||||
// than deleting it, so an operator's notes in the directory survive.
|
||||
func parseCertName(name string) (uint64, ids.ID, bool) {
|
||||
@@ -97,11 +107,11 @@ func parseCertName(name string) (uint64, ids.ID, bool) {
|
||||
if err != nil {
|
||||
return 0, ids.Empty, false
|
||||
}
|
||||
blockID, err := ids.FromString(name[dash+1:])
|
||||
decision, err := ids.FromString(name[dash+1:])
|
||||
if err != nil {
|
||||
return 0, ids.Empty, false
|
||||
}
|
||||
return height, blockID, true
|
||||
return height, decision, true
|
||||
}
|
||||
|
||||
// OpenCerts opens (or creates) the durable cert store at dir and recovers the
|
||||
@@ -112,7 +122,7 @@ func OpenCerts(dir string) (CertStore, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create cert store %s: %w", dir, err)
|
||||
}
|
||||
c := &fileCerts{dir: dir, byBlock: map[ids.ID]string{}}
|
||||
c := &fileCerts{dir: dir, byDecision: map[ids.ID]string{}}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
@@ -122,48 +132,48 @@ func OpenCerts(dir string) (CertStore, error) {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
height, blockID, ok := parseCertName(e.Name())
|
||||
height, decision, ok := parseCertName(e.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c.order = append(c.order, certSlot{height: height, id: blockID})
|
||||
c.byBlock[blockID] = e.Name()
|
||||
c.order = append(c.order, certSlot{height: height, id: decision})
|
||||
c.byDecision[decision] = e.Name()
|
||||
}
|
||||
sort.Slice(c.order, func(i, j int) bool { return c.order[i].height < c.order[j].height })
|
||||
c.evictLocked()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *fileCerts) Put(blockID ids.ID, height uint64, cert []byte) error {
|
||||
func (c *fileCerts) Put(decision ids.ID, height uint64, cert []byte) error {
|
||||
if len(cert) == 0 {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, exists := c.byBlock[blockID]; exists {
|
||||
if _, exists := c.byDecision[decision]; exists {
|
||||
return nil
|
||||
}
|
||||
name := certName(height, blockID)
|
||||
name := certName(height, decision)
|
||||
if err := writeFileAtomic(filepath.Join(c.dir, name), c.dir, cert); err != nil {
|
||||
return fmt.Errorf("write cert %s: %w", name, err)
|
||||
}
|
||||
c.byBlock[blockID] = name
|
||||
c.byDecision[decision] = name
|
||||
// Finality is monotonic, so appending keeps order ascending on the hot path.
|
||||
// A cert arriving out of order (a re-delivered older height) is rare enough
|
||||
// that re-sorting it into place costs nothing and keeps eviction honest.
|
||||
if n := len(c.order); n > 0 && c.order[n-1].height > height {
|
||||
c.order = append(c.order, certSlot{height: height, id: blockID})
|
||||
c.order = append(c.order, certSlot{height: height, id: decision})
|
||||
sort.Slice(c.order, func(i, j int) bool { return c.order[i].height < c.order[j].height })
|
||||
} else {
|
||||
c.order = append(c.order, certSlot{height: height, id: blockID})
|
||||
c.order = append(c.order, certSlot{height: height, id: decision})
|
||||
}
|
||||
c.evictLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fileCerts) Get(blockID ids.ID) ([]byte, bool) {
|
||||
func (c *fileCerts) Get(decision ids.ID) ([]byte, bool) {
|
||||
c.mu.Lock()
|
||||
name, ok := c.byBlock[blockID]
|
||||
name, ok := c.byDecision[decision]
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return nil, false
|
||||
@@ -182,11 +192,11 @@ func (c *fileCerts) evictLocked() {
|
||||
for len(c.order) > maxServedCerts {
|
||||
ev := c.order[0]
|
||||
c.order = c.order[1:]
|
||||
name, ok := c.byBlock[ev.id]
|
||||
name, ok := c.byDecision[ev.id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(c.byBlock, ev.id)
|
||||
delete(c.byDecision, ev.id)
|
||||
_ = os.Remove(filepath.Join(c.dir, name))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func blockAt(n int) ids.ID {
|
||||
// TestCertOutlivesTheProcess pins the property the store exists for: a height
|
||||
// this node decided stays serveable to a peer catching up after the node
|
||||
// restarts. The engine that finalized the block is thrown away between the write
|
||||
// and the read, which is what a restart does to certBytesByBlock.
|
||||
// and the read, which is what a restart does to certByDecision.
|
||||
func TestCertOutlivesTheProcess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blockID, cert := blockAt(1), []byte("finality-cert-for-15499")
|
||||
@@ -40,7 +40,7 @@ func TestCertOutlivesTheProcess(t *testing.T) {
|
||||
finalizer.mu.Unlock()
|
||||
finalizer.persistServedCert(blockID, 15499, cert)
|
||||
|
||||
if _, ok := finalizer.CertForBlock(blockID); !ok {
|
||||
if _, ok := finalizer.certFor(blockID); !ok {
|
||||
t.Fatal("the finalizing engine cannot serve the cert it just wrote")
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestCertOutlivesTheProcess(t *testing.T) {
|
||||
}
|
||||
restarted := New(WithCerts(reopened))
|
||||
|
||||
got, ok := restarted.CertForBlock(blockID)
|
||||
got, ok := restarted.certFor(blockID)
|
||||
if !ok {
|
||||
t.Fatal("cert did not survive the restart — a peer whose gap predates our restart could never be served its next block")
|
||||
}
|
||||
@@ -71,11 +71,11 @@ func TestCertsWithoutAStoreDieWithTheProcess(t *testing.T) {
|
||||
finalizer.storeServedCertLocked(blockID, cert)
|
||||
finalizer.mu.Unlock()
|
||||
finalizer.persistServedCert(blockID, 7, cert) // no store wired: a no-op
|
||||
if _, ok := finalizer.CertForBlock(blockID); !ok {
|
||||
if _, ok := finalizer.certFor(blockID); !ok {
|
||||
t.Fatal("in-process serve must still work without a store")
|
||||
}
|
||||
|
||||
if _, ok := New().CertForBlock(blockID); ok {
|
||||
if _, ok := New().certFor(blockID); ok {
|
||||
t.Fatal("a memory-only engine must not claim a cert it never assembled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,14 +103,18 @@ func (rt *Runtime) From(ctx context.Context, height uint64, max int) (Run, error
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
cert, ok := rt.Transitive.CertForBlock(id)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
// The block comes first: the cert is filed under the DECISION its signatures
|
||||
// cover, and the block is what names it (canonicalIDOf, inside CertForBlock).
|
||||
// Asking by the outer id from the recovery index would miss a cert this node
|
||||
// holds under a sibling wrapper of the same inner block.
|
||||
blk, err := rt.config.VM.GetBlock(ctx, id)
|
||||
if err != nil || blk == nil {
|
||||
break
|
||||
}
|
||||
cert, ok := rt.Transitive.CertForBlock(blk)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
run.Chain = append(run.Chain, Certified{Block: blk.Bytes(), Cert: cert})
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,10 @@ func servingNode(t *testing.T, count int) (*Runtime, []ids.ID) {
|
||||
rt.Transitive.recoveredAt = map[uint64]ids.ID{}
|
||||
}
|
||||
rt.Transitive.recoveredAt[uint64(h)] = blk.id
|
||||
if rt.Transitive.certBytesByBlock == nil {
|
||||
rt.Transitive.certBytesByBlock = map[ids.ID][]byte{}
|
||||
if rt.Transitive.certByDecision == nil {
|
||||
rt.Transitive.certByDecision = map[ids.ID][]byte{}
|
||||
}
|
||||
rt.Transitive.certBytesByBlock[blk.id] = []byte{byte(h)}
|
||||
rt.Transitive.certByDecision[blk.id] = []byte{byte(h)}
|
||||
rt.Transitive.mu.Unlock()
|
||||
ids_ = append(ids_, blk.id)
|
||||
}
|
||||
|
||||
@@ -16,15 +16,15 @@ import (
|
||||
func descentAt(from uint64, n int) (*Runtime, []ids.ID) {
|
||||
vm := &mockVM{blocks: map[ids.ID]*mockBlock{}}
|
||||
e := &Transitive{
|
||||
certBytesByBlock: map[ids.ID][]byte{},
|
||||
recoveredAt: map[uint64]ids.ID{},
|
||||
certByDecision: map[ids.ID][]byte{},
|
||||
recoveredAt: map[uint64]ids.ID{},
|
||||
}
|
||||
ids_ := make([]ids.ID, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
h := from + uint64(i)
|
||||
id := ids.GenerateTestID()
|
||||
vm.blocks[id] = &mockBlock{id: id, height: h, bytes: []byte{byte(h)}}
|
||||
e.certBytesByBlock[id] = []byte{'c', byte(h)}
|
||||
e.certByDecision[id] = []byte{'c', byte(h)}
|
||||
e.recoveredAt[h] = id
|
||||
ids_ = append(ids_, id)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func TestDescentStopsAtTheFirstHoleRatherThanSkippingIt(t *testing.T) {
|
||||
// 104 is finalized; 103 is not.
|
||||
orphan := ids.GenerateTestID()
|
||||
rt.config.VM.(*mockVM).blocks[orphan] = &mockBlock{id: orphan, height: 104, bytes: []byte{104}}
|
||||
rt.Transitive.certBytesByBlock[orphan] = []byte{'c', 104}
|
||||
rt.Transitive.certByDecision[orphan] = []byte{'c', 104}
|
||||
rt.Transitive.recoveredAt[104] = orphan
|
||||
|
||||
run, err := rt.From(context.Background(), 100, 10)
|
||||
@@ -91,7 +91,7 @@ func TestDescentStopsAtTheFirstHoleRatherThanSkippingIt(t *testing.T) {
|
||||
// progress.
|
||||
func TestDescentWithoutTheCertServesNothingAtThatHeight(t *testing.T) {
|
||||
rt, idsAt := descentAt(100, 3)
|
||||
delete(rt.Transitive.certBytesByBlock, idsAt[1]) // height 101 loses its cert
|
||||
delete(rt.Transitive.certByDecision, idsAt[1]) // height 101 loses its cert
|
||||
|
||||
run, err := rt.From(context.Background(), 100, 10)
|
||||
if err != nil {
|
||||
|
||||
+25
-11
@@ -544,22 +544,25 @@ type Transitive struct {
|
||||
// alongside the slashing window if retention ever matters.
|
||||
finalizedByCert map[ids.ID]struct{}
|
||||
|
||||
// certBytesByBlock persists the marshaled finality cert for each block this node
|
||||
// certByDecision persists the marshaled finality cert for each DECISION this node
|
||||
// finalized, so it can SERVE that cert to a peer catching up (CertForBlock). It
|
||||
// is written at the SOLE finalizer (acceptWithCertCore), so every finalize path
|
||||
// captures its cert in ONE place. certServedOrder is the companion FIFO of block
|
||||
// ids in finalize (== ascending height) order, used to evict the oldest cert once
|
||||
// the store passes maxServedCerts — a bounded sliding window, never an unbounded
|
||||
// map. A node lagging beyond the window bootstraps instead of catching up.
|
||||
// certs is the DURABLE backing for the same evidence. certBytesByBlock above is
|
||||
// captures its cert in ONE place. The key is the identity the signatures cover
|
||||
// (certCanonical), not the envelope that carried them, so two wrappers of one
|
||||
// inner block are one entry and a peer holding either one is answered.
|
||||
// certServedOrder is the companion FIFO of those ids in finalize (== ascending
|
||||
// height) order, used to evict the oldest cert once the store passes
|
||||
// maxServedCerts — a bounded sliding window, never an unbounded map. A node
|
||||
// lagging beyond the window bootstraps instead of catching up.
|
||||
// certs is the DURABLE backing for the same evidence. certByDecision above is
|
||||
// a process-lifetime read cache in front of it; certs is what survives a restart,
|
||||
// and therefore what decides whether a peer that fell behind can ever be served
|
||||
// its next block. nil keeps certs memory-only — correct for a single-validator or
|
||||
// test engine, and a liveness hazard on a real fleet (see cert_store.go).
|
||||
certs CertStore
|
||||
|
||||
certBytesByBlock map[ids.ID][]byte
|
||||
certServedOrder []ids.ID
|
||||
certByDecision map[ids.ID][]byte
|
||||
certServedOrder []ids.ID
|
||||
|
||||
// recoveredAt names, per finalized HEIGHT, the outer block id this node finalized
|
||||
// there — the deep index catch-up replay reads when the ledger's own byHeight has
|
||||
@@ -917,7 +920,7 @@ func NewWithConfig(cfg Config, opts ...Option) *Transitive {
|
||||
pendingBlocks: make(map[ids.ID]*PendingBlock),
|
||||
pendingOwnProposals: make(map[ProposalKey]*PendingBlock),
|
||||
finalizedByCert: make(map[ids.ID]struct{}),
|
||||
certBytesByBlock: make(map[ids.ID][]byte),
|
||||
certByDecision: make(map[ids.ID][]byte),
|
||||
committedSlot: make(map[SlotKey]ids.ID),
|
||||
catchupRequested: make(map[ids.ID]time.Time),
|
||||
certCatchupRequested: make(map[uint64]time.Time),
|
||||
@@ -4392,11 +4395,22 @@ func (t *Transitive) applyBranchFinalization(ctx context.Context, plan Plan, cer
|
||||
// certRejected=0 that kept lux-mainnet halted with every node holding data and
|
||||
// none able to catch up — and it only bites after a restart, when the
|
||||
// in-memory window that had been covering for it is gone.
|
||||
//
|
||||
// The cert is filed under the DECISION its signatures cover, never under
|
||||
// certifiedTip — the outer envelope this call happened to finalize. The two
|
||||
// differ exactly when a proposervm wraps an inner block, which is every block on
|
||||
// a live chain, and the difference is not cosmetic: HandleIncomingCert already
|
||||
// resolves an incoming cert ACROSS a differing envelope, so two honest nodes can
|
||||
// finalize one decision under sibling wrappers. Filed by envelope, each stored a
|
||||
// cert the other could not find, and a straggler holding either wrapper was told
|
||||
// no by a node that had the proof. Filed by decision, one entry answers both.
|
||||
var servedCert []byte
|
||||
var servedHeight uint64
|
||||
var servedDecision ids.ID
|
||||
if qc := cert.Cert(); qc != nil {
|
||||
if b, err := qc.MarshalBinary(); err == nil {
|
||||
t.storeServedCertLocked(certifiedTip, b)
|
||||
servedDecision = certCanonical(qc)
|
||||
t.storeServedCertLocked(servedDecision, b)
|
||||
servedCert = b
|
||||
servedHeight = qc.Position.Height
|
||||
}
|
||||
@@ -4404,7 +4418,7 @@ func (t *Transitive) applyBranchFinalization(ctx context.Context, plan Plan, cer
|
||||
t.mu.Unlock()
|
||||
// Both of these are carried out of the critical section on purpose: Reject calls
|
||||
// into the VM, and persisting the cert fsyncs. Neither belongs under the engine lock.
|
||||
t.persistServedCert(certifiedTip, servedHeight, servedCert)
|
||||
t.persistServedCert(servedDecision, servedHeight, servedCert)
|
||||
for _, vmb := range toReject {
|
||||
_ = vmb.Reject(ctx)
|
||||
}
|
||||
|
||||
@@ -73,16 +73,18 @@ func (c *busCatchup) RequestAncestors(_ ids.ID, _ ids.ID, _ ids.ID, _ ids.NodeID
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
certBytes, ok := server.rt.Transitive.CertForBlock(id)
|
||||
if !ok {
|
||||
continue // cert aged out of the served window — a real node bootstraps instead
|
||||
}
|
||||
server.vm.mu.Lock()
|
||||
blk := server.vm.blockByID[id]
|
||||
server.vm.mu.Unlock()
|
||||
if blk == nil {
|
||||
continue
|
||||
}
|
||||
// The block first: a cert is filed under the decision it proves, and the
|
||||
// block is what names it.
|
||||
certBytes, ok := server.rt.Transitive.CertForBlock(blk)
|
||||
if !ok {
|
||||
continue // cert aged out of the served window — a real node bootstraps instead
|
||||
}
|
||||
if !self.reachable() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/supranational/blst v0.3.16
|
||||
go.uber.org/mock v0.6.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
)
|
||||
@@ -132,7 +133,6 @@ require (
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
|
||||
@@ -74,6 +74,14 @@ var (
|
||||
// over by dedup.
|
||||
ErrWVSetDuplicateID = errors.New("quasar: duplicate validator_id in weighted validator set")
|
||||
|
||||
// ErrWVSetDuplicateKey is returned when two leaves share a public key.
|
||||
// One key belongs to at most one validator: counting distinct voters has
|
||||
// to count distinct SIGNERS, and a set that lets one holder appear under
|
||||
// several validator_ids clears a signer floor written to require several
|
||||
// holders. Refused at build, alongside the duplicate-identity check —
|
||||
// neither implies the other, and the set has to be a set on both axes.
|
||||
ErrWVSetDuplicateKey = errors.New("quasar: duplicate public key in weighted validator set")
|
||||
|
||||
// ErrWVSetZeroWeight is returned when a leaf carries zero voting weight.
|
||||
// A zero-weight signer contributes nothing to a quorum and is almost
|
||||
// always a misconfiguration; refusing it closes a silent
|
||||
@@ -278,6 +286,19 @@ func BuildWeightedValidatorSet(epoch uint64, leaves []WeightedValidatorLeaf) (*W
|
||||
}
|
||||
}
|
||||
|
||||
// Reject a public key that appears under more than one validator_id. Keys
|
||||
// are not adjacent after a sort by identity, so this pass carries a map;
|
||||
// it runs over the SORTED slice so the pair a set is refused on is the same
|
||||
// on every node.
|
||||
byKey := make(map[string]int, len(sorted))
|
||||
for i := range sorted {
|
||||
if first, dup := byKey[string(sorted[i].PublicKey)]; dup {
|
||||
return nil, fmt.Errorf("%w: %x and %x", ErrWVSetDuplicateKey,
|
||||
sorted[first].ValidatorID[:], sorted[i].ValidatorID[:])
|
||||
}
|
||||
byKey[string(sorted[i].PublicKey)] = i
|
||||
}
|
||||
|
||||
leafHashes := make([][48]byte, len(sorted))
|
||||
count := uint32(len(sorted))
|
||||
for i := range sorted {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package quasar
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -108,6 +109,28 @@ func TestWeightedMerkle_RejectsBadInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWeightedMerkle_OneKeyOneValidator is the second registration rule on this
|
||||
// tree. Distinct identities were already refused; distinct KEYS were not, so one
|
||||
// holder could appear as several validators, each with its own weight, and a
|
||||
// quorum written to require several holders was satisfied by one. Neither check
|
||||
// implies the other — these leaves have different identities and pass the first.
|
||||
func TestWeightedMerkle_OneKeyOneValidator(t *testing.T) {
|
||||
a, b := mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 20, 0x42, 1)
|
||||
b.PublicKey = append([]byte(nil), a.PublicKey...)
|
||||
|
||||
if _, err := BuildWeightedValidatorSet(7, []WeightedValidatorLeaf{a, b}); !errors.Is(err, ErrWVSetDuplicateKey) {
|
||||
t.Fatalf("one key under two validator_ids: got %v, want ErrWVSetDuplicateKey", err)
|
||||
}
|
||||
// Order must not decide the answer: every node builds the same set.
|
||||
if _, err := BuildWeightedValidatorSet(7, []WeightedValidatorLeaf{b, a}); !errors.Is(err, ErrWVSetDuplicateKey) {
|
||||
t.Fatalf("reversed: got %v, want ErrWVSetDuplicateKey", err)
|
||||
}
|
||||
// Distinct keys still build.
|
||||
if _, err := BuildWeightedValidatorSet(7, []WeightedValidatorLeaf{mkLeaf(0x01, 10, 0x42, 1), mkLeaf(0x02, 20, 0x42, 1)}); err != nil {
|
||||
t.Fatalf("a set that obeys the rule was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedMerkle_InclusionSoundness(t *testing.T) {
|
||||
// Every leaf at every set size must produce a verifying inclusion proof,
|
||||
// and a NON-member must be rejected. Exercise sizes 1..9 to cover both
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package pop is the proof a registrant gives that it holds the key it is
|
||||
// registering — AND that the key names the node it is registering it for.
|
||||
//
|
||||
// THE PUBKEY-ONLY PROOF IS NOT ENOUGH. The IETF proof of possession signs the
|
||||
// public key under its own ciphersuite; it proves someone holds the secret, and
|
||||
// nothing else. It is a statement about a key, so it travels with the key: an
|
||||
// honest validator publishes its key and its proof, and anyone can re-register
|
||||
// that pair under a SECOND node identity, because the proof never mentions the
|
||||
// first. Counting signers then counts identities the key holder does not have,
|
||||
// and a floor written to require many distinct signers is cleared by one.
|
||||
//
|
||||
// Binding the node closes it. The registrant signs its own identity followed by
|
||||
// its own key, so a proof is only valid for the one pair it was made for. Lifting
|
||||
// the key to another identity needs a proof over that identity, which needs the
|
||||
// secret.
|
||||
//
|
||||
// THE MESSAGE, byte for byte:
|
||||
//
|
||||
// offset 0 .. 19 node — ids.NodeID, 20 bytes
|
||||
// offset 20 .. 67 key — compressed G1 pubkey, 48 bytes
|
||||
// total 68 bytes
|
||||
//
|
||||
// In that order, with no separator and no length prefix. Both fields are fixed
|
||||
// width, so the concatenation reads back apart into exactly the pair that made
|
||||
// it — the ambiguity that lengths exist to prevent (see the FPC seed preimage,
|
||||
// where the fields are variable width) cannot arise here.
|
||||
//
|
||||
// WHY 20 BYTES OF NODE. The registered identity of a Lux validator IS its
|
||||
// ids.NodeID: 20 bytes, the value AddValidatorTx names, the value a cert's votes
|
||||
// carry, the value slashing evidence keys on, and the value the validator set
|
||||
// maps. A proof that bound anything else would bind something the chain does not
|
||||
// use to decide who signed. The consensus Id is 32 bytes and names a block or a
|
||||
// chain, never a node; there is no canonical NodeID -> Id direction to borrow
|
||||
// (ids.ID.ShortID() truncates 32 down to 20, and nothing defines the inverse), so
|
||||
// widening to 32 would mean inventing a second identity for a node that already
|
||||
// has one. Two names for one thing is what forks. The raw 20 bytes are bound —
|
||||
// not the typed form (ids.TypedNodeID, scheme byte ‖ 20), because the scheme byte
|
||||
// is a transport annotation and the 20 bytes are what the set and the certs carry.
|
||||
//
|
||||
// THE CIPHERSUITE is BLS12-381 min_pk with the proof-of-possession domain:
|
||||
//
|
||||
// DST BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_
|
||||
// pubkey compressed G1, 48 bytes
|
||||
// proof compressed G2, 96 bytes
|
||||
//
|
||||
// The _POP_ tag, never the vote's ..._NUL_. Separate domains are what keep a vote
|
||||
// from being replayed as a proof and a proof from being replayed as a vote: the
|
||||
// two hash the same bytes onto different points, so a signature under one is
|
||||
// noise under the other. Verify enforces the order the standard fixes —
|
||||
// encoding, then possession — and the caller enforces uniqueness after it.
|
||||
//
|
||||
// This package is the oracle: Rust and C++ reproduce these bytes, and
|
||||
// vectors/pop.json in luxfi/conformance freezes them.
|
||||
package pop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
const (
|
||||
// NodeLen is the width of the node identity in the message: ids.NodeID.
|
||||
NodeLen = ids.NodeIDLen
|
||||
|
||||
// KeyLen is the width of the public key in the message: BLS12-381 min_pk,
|
||||
// compressed G1.
|
||||
KeyLen = bls.PublicKeyLen
|
||||
|
||||
// ProofLen is the width of the proof itself: BLS12-381 min_pk, compressed G2.
|
||||
ProofLen = bls.SignatureLen
|
||||
|
||||
// MessageLen is the whole preimage: node ‖ key, both fixed width.
|
||||
MessageLen = NodeLen + KeyLen
|
||||
|
||||
// DST is the domain separation tag the proof is signed under: the IETF
|
||||
// proof-of-possession ciphersuite, distinct from the vote's ..._NUL_. Named
|
||||
// here so the standard is readable at the place it is enforced; asserted
|
||||
// against the library's own constant by the tests, and frozen by the golden
|
||||
// proof in vectors/pop.json, which moves if the domain ever does.
|
||||
DST = "BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrKey is returned when the public key is not a canonical compressed
|
||||
// BLS12-381 G1 point: wrong width, non-canonical encoding, off-curve, outside
|
||||
// the prime-order subgroup, or the identity.
|
||||
ErrKey = errors.New("pop: public key is not a canonical compressed BLS12-381 G1 point")
|
||||
|
||||
// ErrProof is returned when the proof is not a canonical compressed
|
||||
// BLS12-381 G2 point, by the same measures.
|
||||
ErrProof = errors.New("pop: proof is not a canonical compressed BLS12-381 G2 point")
|
||||
|
||||
// ErrPossession is returned when the bytes decode but the proof does not
|
||||
// verify: it was made for a different node, for a different key, under a
|
||||
// different domain, or by someone who does not hold the secret. All four are
|
||||
// one answer — this key is not admitted under this identity.
|
||||
ErrPossession = errors.New("pop: proof does not bind this node to this key")
|
||||
|
||||
// ErrSecretKey is returned when there is no secret key to prove with.
|
||||
ErrSecretKey = errors.New("pop: no secret key")
|
||||
)
|
||||
|
||||
// Message returns the exact bytes a node-bound proof signs: node ‖ key, 68 bytes.
|
||||
// The key must already be a canonical compressed G1 encoding; Message does not
|
||||
// decode it, because it is called on the signing side from a key this node just
|
||||
// serialized and on the verifying side from a key Verify has already decoded.
|
||||
func Message(node ids.NodeID, key []byte) ([]byte, error) {
|
||||
if len(key) != KeyLen {
|
||||
return nil, fmt.Errorf("%w: got %d bytes, want %d", ErrKey, len(key), KeyLen)
|
||||
}
|
||||
msg := make([]byte, 0, MessageLen)
|
||||
msg = append(msg, node[:]...)
|
||||
msg = append(msg, key...)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// Sign produces this node's proof for its own key: the 96-byte compressed G2
|
||||
// signature over Message(node, key) under the proof-of-possession domain. BLS
|
||||
// signing is deterministic, so one (secret key, node) pair has exactly one proof
|
||||
// — which is what lets a vector freeze it.
|
||||
func Sign(sk *bls.SecretKey, node ids.NodeID) ([]byte, error) {
|
||||
if sk == nil {
|
||||
return nil, ErrSecretKey
|
||||
}
|
||||
key := bls.PublicKeyToCompressedBytes(sk.PublicKey())
|
||||
msg, err := Message(node, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig, err := sk.SignProofOfPossession(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pop: sign: %w", err)
|
||||
}
|
||||
return bls.SignatureToBytes(sig), nil
|
||||
}
|
||||
|
||||
// Verify admits (node, key) if and only if proof is a node-bound proof of
|
||||
// possession for exactly that pair.
|
||||
//
|
||||
// The order is the standard's: ENCODING first — both points are decoded, which
|
||||
// rejects wrong widths, non-canonical encodings, off-curve points, points outside
|
||||
// the prime-order subgroup, and the identity — and only then POSSESSION, the
|
||||
// pairing check. Nothing is admitted on a point that was never a point, and no
|
||||
// pairing is computed on one either.
|
||||
//
|
||||
// The key is required to be the canonical compressed encoding of the point it
|
||||
// decodes to, so the 48 bytes that go into the message are not a choice the
|
||||
// registrant gets to make. Uniqueness — one key, one node — is the caller's
|
||||
// check, because it is a property of the set and not of this pair.
|
||||
func Verify(node ids.NodeID, key, proof []byte) error {
|
||||
if len(key) != KeyLen {
|
||||
return fmt.Errorf("%w: got %d bytes, want %d", ErrKey, len(key), KeyLen)
|
||||
}
|
||||
if len(proof) != ProofLen {
|
||||
return fmt.Errorf("%w: got %d bytes, want %d", ErrProof, len(proof), ProofLen)
|
||||
}
|
||||
pk, err := bls.PublicKeyFromCompressedBytes(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrKey, err)
|
||||
}
|
||||
// One point, one encoding. A decoder that accepted a second spelling of the
|
||||
// same key would let a registrant choose which 48 bytes the message carries,
|
||||
// and a proof over one spelling would not verify against the other.
|
||||
if canonical := bls.PublicKeyToCompressedBytes(pk); !bytes.Equal(canonical, key) {
|
||||
return fmt.Errorf("%w: encoding is not canonical", ErrKey)
|
||||
}
|
||||
sig, err := bls.SignatureFromBytes(proof)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrProof, err)
|
||||
}
|
||||
msg, err := Message(node, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bls.VerifyProofOfPossession(pk, sig, msg) {
|
||||
return ErrPossession
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package pop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// key returns a deterministic secret key, so a failure is reproducible and the
|
||||
// bytes a test asserts are the bytes a vector freezes.
|
||||
func key(t *testing.T, seed byte) *bls.SecretKey {
|
||||
t.Helper()
|
||||
s := make([]byte, 32)
|
||||
for i := range s {
|
||||
s[i] = seed
|
||||
}
|
||||
sk, err := bls.SecretKeyFromSeed(s)
|
||||
if err != nil {
|
||||
t.Fatalf("SecretKeyFromSeed(%d): %v", seed, err)
|
||||
}
|
||||
return sk
|
||||
}
|
||||
|
||||
func node(b byte) ids.NodeID {
|
||||
var n ids.NodeID
|
||||
for i := range n {
|
||||
n[i] = b
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestTheMessageIsNodeThenKey pins the layout the standard fixes: 20 bytes of
|
||||
// node then 48 bytes of key, in that order, nothing between them and nothing
|
||||
// around them.
|
||||
func TestTheMessageIsNodeThenKey(t *testing.T) {
|
||||
n := node(0x11)
|
||||
pk := bls.PublicKeyToCompressedBytes(key(t, 1).PublicKey())
|
||||
|
||||
msg, err := Message(n, pk)
|
||||
if err != nil {
|
||||
t.Fatalf("Message: %v", err)
|
||||
}
|
||||
if len(msg) != MessageLen || MessageLen != 68 {
|
||||
t.Fatalf("message is %d bytes (MessageLen=%d), want 68", len(msg), MessageLen)
|
||||
}
|
||||
if NodeLen != 20 || KeyLen != 48 || ProofLen != 96 {
|
||||
t.Fatalf("widths moved: node=%d key=%d proof=%d, want 20/48/96", NodeLen, KeyLen, ProofLen)
|
||||
}
|
||||
if !bytes.Equal(msg[:NodeLen], n[:]) {
|
||||
t.Fatal("bytes 0..19 are not the node identity")
|
||||
}
|
||||
if !bytes.Equal(msg[NodeLen:], pk) {
|
||||
t.Fatal("bytes 20..67 are not the compressed public key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheDomainIsTheProofSuite holds the DST this package names to the one the
|
||||
// crypto library calls the proof-of-possession ciphersuite. Naming it twice is
|
||||
// how a domain drifts; this is the assertion that keeps the two the same string.
|
||||
func TestTheDomainIsTheProofSuite(t *testing.T) {
|
||||
if got := bls.CiphersuiteProofOfPossession.String(); got != DST {
|
||||
t.Fatalf("DST is not the library's proof suite:\n got %q\nwant %q", got, DST)
|
||||
}
|
||||
if DST != "BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_" {
|
||||
t.Fatalf("the proof domain moved: %q", DST)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAProofVerifiesForItsOwnPair is the round trip, and the determinism the
|
||||
// vector depends on: BLS signing carries no nonce, so one (key, node) pair has
|
||||
// exactly one proof.
|
||||
func TestAProofVerifiesForItsOwnPair(t *testing.T) {
|
||||
sk := key(t, 7)
|
||||
n := node(0x22)
|
||||
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
|
||||
|
||||
proof, err := Sign(sk, n)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if len(proof) != ProofLen {
|
||||
t.Fatalf("proof is %d bytes, want %d", len(proof), ProofLen)
|
||||
}
|
||||
if err := Verify(n, pk, proof); err != nil {
|
||||
t.Fatalf("a proof does not verify for the pair it was made for: %v", err)
|
||||
}
|
||||
again, err := Sign(sk, n)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign again: %v", err)
|
||||
}
|
||||
if !bytes.Equal(proof, again) {
|
||||
t.Fatal("signing is not deterministic — a vector could not freeze this")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAProofCannotBeLifted is the whole reason the node is in the message. The
|
||||
// pubkey-only proof is a statement about a key, so it travels with the key and
|
||||
// re-registers under any identity. This one does not.
|
||||
func TestAProofCannotBeLifted(t *testing.T) {
|
||||
sk := key(t, 9)
|
||||
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
|
||||
mine, theirs := node(0x01), node(0x02)
|
||||
|
||||
proof, err := Sign(sk, mine)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if err := Verify(mine, pk, proof); err != nil {
|
||||
t.Fatalf("own pair: %v", err)
|
||||
}
|
||||
if err := Verify(theirs, pk, proof); err == nil {
|
||||
t.Fatal("a proof made for one node verified under another — the key is not bound")
|
||||
}
|
||||
// One bit of the node is enough.
|
||||
near := mine
|
||||
near[NodeLen-1] ^= 0x01
|
||||
if err := Verify(near, pk, proof); err == nil {
|
||||
t.Fatal("a one-bit change to the node identity still verified")
|
||||
}
|
||||
}
|
||||
|
||||
// TestThePubkeyOnlyProofIsRefused records what this supersedes: the IETF proof
|
||||
// signs the public key alone, and that is exactly the proof an attacker already
|
||||
// holds for every honest validator's published key.
|
||||
func TestThePubkeyOnlyProofIsRefused(t *testing.T) {
|
||||
sk := key(t, 11)
|
||||
n := node(0x33)
|
||||
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
|
||||
|
||||
sig, err := sk.SignProofOfPossession(pk) // the old message: the key, and nothing else
|
||||
if err != nil {
|
||||
t.Fatalf("SignProofOfPossession: %v", err)
|
||||
}
|
||||
if err := Verify(n, pk, bls.SignatureToBytes(sig)); err == nil {
|
||||
t.Fatal("a pubkey-only proof was accepted as node-bound")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheDomainsDoNotCross is domain separation, checked in both directions: a
|
||||
// vote over the proof's bytes is not a proof, and a proof is not a vote.
|
||||
func TestTheDomainsDoNotCross(t *testing.T) {
|
||||
sk := key(t, 13)
|
||||
n := node(0x44)
|
||||
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
|
||||
msg, err := Message(n, pk)
|
||||
if err != nil {
|
||||
t.Fatalf("Message: %v", err)
|
||||
}
|
||||
|
||||
vote, err := sk.Sign(msg) // the vote domain, ..._NUL_
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if err := Verify(n, pk, bls.SignatureToBytes(vote)); err == nil {
|
||||
t.Fatal("a vote over the proof's message was accepted as a proof")
|
||||
}
|
||||
|
||||
proof, err := Sign(sk, n)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
sig, err := bls.SignatureFromBytes(proof)
|
||||
if err != nil {
|
||||
t.Fatalf("SignatureFromBytes: %v", err)
|
||||
}
|
||||
pub, err := bls.PublicKeyFromCompressedBytes(pk)
|
||||
if err != nil {
|
||||
t.Fatalf("PublicKeyFromCompressedBytes: %v", err)
|
||||
}
|
||||
if bls.Verify(pub, sig, msg) {
|
||||
t.Fatal("a proof verified as a vote — the two domains are not separated")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAProofIsNotAKeysToo: a proof made by one key must not admit another.
|
||||
func TestAProofDoesNotTravelToAnotherKey(t *testing.T) {
|
||||
mine, other := key(t, 21), key(t, 22)
|
||||
n := node(0x55)
|
||||
|
||||
proof, err := Sign(mine, n)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
otherPK := bls.PublicKeyToCompressedBytes(other.PublicKey())
|
||||
if err := Verify(n, otherPK, proof); err == nil {
|
||||
t.Fatal("a proof made by one key admitted another")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMalformedInputIsRefusedBeforeAnyPairing walks the encoding gate: every
|
||||
// shape that is not a canonical point is an ErrKey/ErrProof, not a pairing.
|
||||
func TestMalformedInputIsRefusedBeforeAnyPairing(t *testing.T) {
|
||||
sk := key(t, 31)
|
||||
n := node(0x66)
|
||||
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
|
||||
proof, err := Sign(sk, n)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
|
||||
garbageKey := make([]byte, KeyLen)
|
||||
for i := range garbageKey {
|
||||
garbageKey[i] = 0xAB
|
||||
}
|
||||
garbageProof := make([]byte, ProofLen)
|
||||
for i := range garbageProof {
|
||||
garbageProof[i] = 0xCD
|
||||
}
|
||||
// The compressed encoding of the identity: valid bytes, and never a validator.
|
||||
identityKey := make([]byte, KeyLen)
|
||||
identityKey[0] = 0xC0
|
||||
identityProof := make([]byte, ProofLen)
|
||||
identityProof[0] = 0xC0
|
||||
// The malformed "infinity set, compression clear" encoding, which is a panic
|
||||
// in a raw decoder and must be an error here.
|
||||
trapKey := make([]byte, KeyLen)
|
||||
trapKey[0] = 0x40
|
||||
trapProof := make([]byte, ProofLen)
|
||||
trapProof[0] = 0x40
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
key, proof []byte
|
||||
}{
|
||||
{"no key", nil, proof},
|
||||
{"short key", pk[:KeyLen-1], proof},
|
||||
{"long key", append(append([]byte{}, pk...), 0), proof},
|
||||
{"garbage key", garbageKey, proof},
|
||||
{"identity key", identityKey, proof},
|
||||
{"infinity-bit trap key", trapKey, proof},
|
||||
{"no proof", pk, nil},
|
||||
{"empty proof", pk, []byte{}},
|
||||
{"short proof", pk, proof[:ProofLen-1]},
|
||||
{"long proof", pk, append(append([]byte{}, proof...), 0)},
|
||||
{"garbage proof", pk, garbageProof},
|
||||
{"identity proof", pk, identityProof},
|
||||
{"infinity-bit trap proof", pk, trapProof},
|
||||
{"zero proof", pk, make([]byte, ProofLen)},
|
||||
{"zero key", make([]byte, KeyLen), proof},
|
||||
} {
|
||||
if err := Verify(n, c.key, c.proof); err == nil {
|
||||
t.Fatalf("%s was admitted", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOneBitOfTheProofIsEnough: the proof is not a checksum.
|
||||
func TestFlippingTheProofRefusesIt(t *testing.T) {
|
||||
sk := key(t, 41)
|
||||
n := node(0x77)
|
||||
pk := bls.PublicKeyToCompressedBytes(sk.PublicKey())
|
||||
proof, err := Sign(sk, n)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
for _, i := range []int{ProofLen - 1, ProofLen / 2} {
|
||||
bad := append([]byte{}, proof...)
|
||||
bad[i] ^= 0x01
|
||||
if err := Verify(n, pk, bad); err == nil {
|
||||
t.Fatalf("a proof with byte %d flipped was admitted", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignRefusesWithoutASecret keeps the nil path off the happy path.
|
||||
func TestSignRefusesWithoutASecret(t *testing.T) {
|
||||
if _, err := Sign(nil, node(0x88)); err == nil {
|
||||
t.Fatal("signing with no secret key returned a proof")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// registration.go — who is admitted to the validator set, and on what evidence.
|
||||
//
|
||||
// A certificate is only as sound as the set it is checked against. Verification
|
||||
// can be perfect and still be checked against a set that counts one key holder
|
||||
// as many signers, so two rules are enforced here, in this order:
|
||||
//
|
||||
// ENCODING the key is a canonical compressed BLS12-381 G1 point;
|
||||
// POSSESSION a node-bound proof (validator/pop) binds this key to this node;
|
||||
// UNIQUENESS the key is registered to no other node.
|
||||
//
|
||||
// Then, and only then, weight is counted. The order is not decoration: a pairing
|
||||
// check on bytes that are not a point is undefined, and weight counted before
|
||||
// uniqueness is weight counted twice.
|
||||
//
|
||||
// UNIQUENESS IS WHY THE MERGE IS GONE. FlattenValidatorSet used to fold two
|
||||
// validators that shared a public key into ONE entry carrying both node ids and
|
||||
// their summed weight. That fold is the only thing that ever made
|
||||
// many-nodes-one-key representable, so a floor written to require many DISTINCT
|
||||
// signers was satisfied by one holder registering one key under several ids. The
|
||||
// state does not exist to normalize any more: a repeated key is refused, and a
|
||||
// canonical validator carries exactly one node id.
|
||||
|
||||
package validators
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/luxfi/consensus/validator/pop"
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/math"
|
||||
"github.com/luxfi/validators"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDuplicateKey is returned when two node ids present the same public key.
|
||||
// One key, one node: counting distinct voters has to count distinct signers.
|
||||
ErrDuplicateKey = errors.New("validators: public key is registered to more than one node")
|
||||
|
||||
// ErrNoKey is returned when a registration carries no public key. A validator
|
||||
// with no key cannot sign, so it cannot be admitted through the proof path.
|
||||
ErrNoKey = errors.New("validators: registration carries no public key")
|
||||
|
||||
// ErrPossession is returned when a registration's proof does not bind its key
|
||||
// to its node. It wraps the reason from validator/pop.
|
||||
ErrPossession = errors.New("validators: registration is not proven")
|
||||
)
|
||||
|
||||
// Registration is one validator asking to be counted: the identity it claims,
|
||||
// the key it will sign under, the proof binding the two, and the weight staked
|
||||
// behind it.
|
||||
type Registration struct {
|
||||
NodeID ids.NodeID
|
||||
// Key is the BLS12-381 min_pk public key, compressed G1, 48 bytes.
|
||||
Key []byte
|
||||
// Proof is the node-bound proof of possession over NodeID ‖ Key, compressed
|
||||
// G2, 96 bytes. See validator/pop for the exact preimage and domain.
|
||||
Proof []byte
|
||||
Weight uint64
|
||||
}
|
||||
|
||||
// Register admits a registration set and returns it in canonical order.
|
||||
//
|
||||
// This is the admission rule of the standard, written once. Every registration
|
||||
// is checked in the fixed order — encoding, possession, uniqueness — and the set
|
||||
// is admitted whole or not at all: a single unproven or duplicated key fails the
|
||||
// call rather than being dropped from an otherwise-good set, because a set that
|
||||
// silently loses a signer is a set whose weight no longer describes it.
|
||||
//
|
||||
// Ordering and PublicKeyBytes match FlattenValidatorSet exactly, so an admitted
|
||||
// set and a flattened one are the same set.
|
||||
func Register(rs []Registration) (CanonicalValidatorSet, error) {
|
||||
// Deterministic order in, deterministic error out: which duplicate a set is
|
||||
// refused on must not depend on the order a caller happened to build a slice.
|
||||
sorted := slices.Clone(rs)
|
||||
slices.SortStableFunc(sorted, func(a, b Registration) int {
|
||||
return bytes.Compare(a.NodeID[:], b.NodeID[:])
|
||||
})
|
||||
|
||||
var (
|
||||
out = make([]*CanonicalValidator, 0, len(sorted))
|
||||
byKey = make(map[string]ids.NodeID, len(sorted))
|
||||
totalWeight uint64
|
||||
)
|
||||
for _, r := range sorted {
|
||||
if len(r.Key) == 0 {
|
||||
return CanonicalValidatorSet{}, fmt.Errorf("%w: %s", ErrNoKey, r.NodeID)
|
||||
}
|
||||
// ENCODING, then POSSESSION — both inside pop.Verify, in that order.
|
||||
if err := pop.Verify(r.NodeID, r.Key, r.Proof); err != nil {
|
||||
return CanonicalValidatorSet{}, fmt.Errorf("%w: %s: %w", ErrPossession, r.NodeID, err)
|
||||
}
|
||||
pk, err := bls.PublicKeyFromCompressedBytes(r.Key)
|
||||
if err != nil {
|
||||
return CanonicalValidatorSet{}, fmt.Errorf("%s: %w", r.NodeID, err)
|
||||
}
|
||||
// UNIQUENESS. Keyed on the canonical compressed encoding, which pop.Verify
|
||||
// has already established is the only spelling of this point.
|
||||
if first, dup := byKey[string(r.Key)]; dup {
|
||||
return CanonicalValidatorSet{}, fmt.Errorf("%w: %s and %s", ErrDuplicateKey, first, r.NodeID)
|
||||
}
|
||||
byKey[string(r.Key)] = r.NodeID
|
||||
|
||||
// WEIGHT, last.
|
||||
sum, err := math.Add64(totalWeight, r.Weight)
|
||||
if err != nil {
|
||||
return CanonicalValidatorSet{}, fmt.Errorf("%w: %w", ErrWeightOverflow, err)
|
||||
}
|
||||
totalWeight = sum
|
||||
out = append(out, &CanonicalValidator{
|
||||
PublicKey: pk,
|
||||
PublicKeyBytes: bls.PublicKeyToUncompressedBytes(pk),
|
||||
Weight: r.Weight,
|
||||
NodeIDs: []ids.NodeID{r.NodeID},
|
||||
})
|
||||
}
|
||||
slices.SortFunc(out, (*CanonicalValidator).Compare)
|
||||
return CanonicalValidatorSet{Validators: out, TotalWeight: totalWeight}, nil
|
||||
}
|
||||
|
||||
// FlattenValidatorSet converts vdrSet into canonical order and returns the total
|
||||
// weight, which — as upstream — includes validators that carry no public key and
|
||||
// therefore cannot sign.
|
||||
//
|
||||
// It replaces the re-export of validators.FlattenValidatorSet for one reason: the
|
||||
// upstream MERGES two node ids that share a public key into a single validator
|
||||
// with a summed weight, and that state is retired. A repeated key is now an
|
||||
// error, so every returned CanonicalValidator carries exactly one node id.
|
||||
//
|
||||
// It does NOT check possession, because this input has no proof to check: it is
|
||||
// a set the chain already admitted, and the proof lives at admission. Register is
|
||||
// where the full order runs. Until the P-chain's registration proof becomes
|
||||
// node-bound, uniqueness is the half of the rule this side can hold — and it is
|
||||
// the half that decides whether counting signers counts holders.
|
||||
//
|
||||
// An undecodable key is skipped rather than refused, exactly as upstream: this
|
||||
// function reads a set it did not admit, and refusing the whole set on one bad
|
||||
// key would trade a signer for a halt. Strictness belongs at admission.
|
||||
func FlattenValidatorSet(vdrSet map[ids.NodeID]*GetValidatorOutput) (CanonicalValidatorSet, error) {
|
||||
// Iterate in node-id order. The upstream ranged over the map and sorted at the
|
||||
// end, which is fine for a total but not for an error: which duplicate pair a
|
||||
// set is refused on must be the same on every node.
|
||||
nodeIDs := make([]ids.NodeID, 0, len(vdrSet))
|
||||
for nodeID := range vdrSet {
|
||||
nodeIDs = append(nodeIDs, nodeID)
|
||||
}
|
||||
slices.SortFunc(nodeIDs, func(a, b ids.NodeID) int { return bytes.Compare(a[:], b[:]) })
|
||||
|
||||
var (
|
||||
out = make([]*CanonicalValidator, 0, len(nodeIDs))
|
||||
byKey = make(map[string]ids.NodeID, len(nodeIDs))
|
||||
totalWeight uint64
|
||||
err error
|
||||
)
|
||||
for _, nodeID := range nodeIDs {
|
||||
vdr := vdrSet[nodeID]
|
||||
if vdr == nil {
|
||||
continue
|
||||
}
|
||||
totalWeight, err = math.Add64(totalWeight, vdr.Weight)
|
||||
if err != nil {
|
||||
return CanonicalValidatorSet{}, fmt.Errorf("%w: %w", ErrWeightOverflow, err)
|
||||
}
|
||||
if len(vdr.PublicKey) == 0 {
|
||||
continue
|
||||
}
|
||||
pk, err := bls.PublicKeyFromCompressedBytes(vdr.PublicKey)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pkBytes := bls.PublicKeyToUncompressedBytes(pk)
|
||||
if first, dup := byKey[string(pkBytes)]; dup {
|
||||
return CanonicalValidatorSet{}, fmt.Errorf("%w: %s and %s", ErrDuplicateKey, first, nodeID)
|
||||
}
|
||||
byKey[string(pkBytes)] = nodeID
|
||||
out = append(out, &CanonicalValidator{
|
||||
PublicKey: pk,
|
||||
PublicKeyBytes: pkBytes,
|
||||
Weight: vdr.Weight,
|
||||
NodeIDs: []ids.NodeID{nodeID},
|
||||
})
|
||||
}
|
||||
slices.SortFunc(out, (*CanonicalValidator).Compare)
|
||||
return CanonicalValidatorSet{Validators: out, TotalWeight: totalWeight}, nil
|
||||
}
|
||||
|
||||
// The upstream flatten is kept referenced so a version bump that changes it is a
|
||||
// visible diff here rather than a silent divergence: this package now owns the
|
||||
// rule, and the assertion is that it still owns the same signature.
|
||||
var _ func(map[ids.NodeID]*GetValidatorOutput) (validators.CanonicalValidatorSet, error) = validators.FlattenValidatorSet
|
||||
@@ -0,0 +1,352 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package validators
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/consensus/validator/pop"
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/validators"
|
||||
)
|
||||
|
||||
func secret(t *testing.T, seed byte) *bls.SecretKey {
|
||||
t.Helper()
|
||||
s := make([]byte, 32)
|
||||
for i := range s {
|
||||
s[i] = seed
|
||||
}
|
||||
sk, err := bls.SecretKeyFromSeed(s)
|
||||
if err != nil {
|
||||
t.Fatalf("SecretKeyFromSeed(%d): %v", seed, err)
|
||||
}
|
||||
return sk
|
||||
}
|
||||
|
||||
func nodeID(b byte) ids.NodeID {
|
||||
var n ids.NodeID
|
||||
for i := range n {
|
||||
n[i] = b
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func registration(t *testing.T, seed, id byte, weight uint64) Registration {
|
||||
t.Helper()
|
||||
sk := secret(t, seed)
|
||||
n := nodeID(id)
|
||||
proof, err := pop.Sign(sk, n)
|
||||
if err != nil {
|
||||
t.Fatalf("pop.Sign: %v", err)
|
||||
}
|
||||
return Registration{
|
||||
NodeID: n,
|
||||
Key: bls.PublicKeyToCompressedBytes(sk.PublicKey()),
|
||||
Proof: proof,
|
||||
Weight: weight,
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterAdmitsProvenValidators is the happy path, and it pins the shape
|
||||
// the retirement of the merge guarantees: one node id per validator, always.
|
||||
func TestRegisterAdmitsProvenValidators(t *testing.T) {
|
||||
set, err := Register([]Registration{
|
||||
registration(t, 1, 0x01, 100),
|
||||
registration(t, 2, 0x02, 200),
|
||||
registration(t, 3, 0x03, 300),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
if len(set.Validators) != 3 {
|
||||
t.Fatalf("admitted %d validators, want 3", len(set.Validators))
|
||||
}
|
||||
if set.TotalWeight != 600 {
|
||||
t.Fatalf("total weight %d, want 600", set.TotalWeight)
|
||||
}
|
||||
for _, v := range set.Validators {
|
||||
if len(v.NodeIDs) != 1 {
|
||||
t.Fatalf("a validator carries %d node ids — the merge is back", len(v.NodeIDs))
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(set.Validators); i++ {
|
||||
if bytes.Compare(set.Validators[i-1].PublicKeyBytes, set.Validators[i].PublicKeyBytes) >= 0 {
|
||||
t.Fatal("the admitted set is not in canonical order")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOneKeyOneNode is the second rule, and the reason possession alone is not
|
||||
// enough: the holder of a key can mint a VALID node-bound proof for any identity
|
||||
// it likes, so both registrations below are individually sound. What refuses them
|
||||
// is uniqueness — counting distinct voters has to count distinct signers.
|
||||
func TestOneKeyOneNode(t *testing.T) {
|
||||
sk := secret(t, 5)
|
||||
key := bls.PublicKeyToCompressedBytes(sk.PublicKey())
|
||||
|
||||
one, two := nodeID(0x10), nodeID(0x20)
|
||||
proofOne, err := pop.Sign(sk, one)
|
||||
if err != nil {
|
||||
t.Fatalf("pop.Sign: %v", err)
|
||||
}
|
||||
proofTwo, err := pop.Sign(sk, two)
|
||||
if err != nil {
|
||||
t.Fatalf("pop.Sign: %v", err)
|
||||
}
|
||||
// Both proofs are real: possession holds for each pair on its own.
|
||||
if err := pop.Verify(one, key, proofOne); err != nil {
|
||||
t.Fatalf("first proof is not sound: %v", err)
|
||||
}
|
||||
if err := pop.Verify(two, key, proofTwo); err != nil {
|
||||
t.Fatalf("second proof is not sound: %v", err)
|
||||
}
|
||||
|
||||
_, err = Register([]Registration{
|
||||
{NodeID: one, Key: key, Proof: proofOne, Weight: 1},
|
||||
{NodeID: two, Key: key, Proof: proofTwo, Weight: 1},
|
||||
})
|
||||
if !errors.Is(err, ErrDuplicateKey) {
|
||||
t.Fatalf("one key admitted under two nodes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterRefusesTheUnproven walks the possession gate.
|
||||
func TestRegisterRefusesTheUnproven(t *testing.T) {
|
||||
good := registration(t, 6, 0x30, 1)
|
||||
|
||||
lifted := registration(t, 6, 0x31, 1) // same key, different node…
|
||||
lifted.Proof = good.Proof // …carrying the first node's proof
|
||||
|
||||
pubkeyOnly := registration(t, 7, 0x32, 1)
|
||||
sig, err := secret(t, 7).SignProofOfPossession(pubkeyOnly.Key)
|
||||
if err != nil {
|
||||
t.Fatalf("SignProofOfPossession: %v", err)
|
||||
}
|
||||
pubkeyOnly.Proof = bls.SignatureToBytes(sig)
|
||||
|
||||
noProof := registration(t, 8, 0x33, 1)
|
||||
noProof.Proof = nil
|
||||
|
||||
noKey := registration(t, 9, 0x34, 1)
|
||||
noKey.Key = nil
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
r Registration
|
||||
want error
|
||||
}{
|
||||
{"a proof lifted from another node", lifted, ErrPossession},
|
||||
{"the pubkey-only proof", pubkeyOnly, ErrPossession},
|
||||
{"no proof at all", noProof, ErrPossession},
|
||||
{"no key at all", noKey, ErrNoKey},
|
||||
} {
|
||||
if _, err := Register([]Registration{c.r}); !errors.Is(err, c.want) {
|
||||
t.Fatalf("%s: got %v, want %v", c.name, err, c.want)
|
||||
}
|
||||
// And it must not slip in beside a good one either.
|
||||
if _, err := Register([]Registration{good, c.r}); !errors.Is(err, c.want) {
|
||||
t.Fatalf("%s beside a good registration: got %v, want %v", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterRefusesTheSetWhole: a bad registration fails the call rather than
|
||||
// being dropped, so an admitted set's weight always describes the set.
|
||||
func TestRegisterRefusesTheSetWhole(t *testing.T) {
|
||||
bad := registration(t, 12, 0x40, 5)
|
||||
bad.Proof = make([]byte, pop.ProofLen)
|
||||
set, err := Register([]Registration{registration(t, 13, 0x41, 7), bad})
|
||||
if err == nil {
|
||||
t.Fatal("an unproven registration was dropped instead of refused")
|
||||
}
|
||||
if len(set.Validators) != 0 || set.TotalWeight != 0 {
|
||||
t.Fatal("a refused call returned a partial set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterIsOrderIndependent: which duplicate a set is refused on must be
|
||||
// the same on every node, whatever order the caller built the slice in.
|
||||
func TestRegisterIsOrderIndependent(t *testing.T) {
|
||||
sk := secret(t, 15)
|
||||
key := bls.PublicKeyToCompressedBytes(sk.PublicKey())
|
||||
one, two := nodeID(0x50), nodeID(0x51)
|
||||
p1, _ := pop.Sign(sk, one)
|
||||
p2, _ := pop.Sign(sk, two)
|
||||
a := Registration{NodeID: one, Key: key, Proof: p1, Weight: 1}
|
||||
b := Registration{NodeID: two, Key: key, Proof: p2, Weight: 1}
|
||||
|
||||
_, forward := Register([]Registration{a, b})
|
||||
_, backward := Register([]Registration{b, a})
|
||||
if forward == nil || backward == nil {
|
||||
t.Fatal("a duplicate key was admitted")
|
||||
}
|
||||
if forward.Error() != backward.Error() {
|
||||
t.Fatalf("the refusal depends on input order:\n %q\n %q", forward, backward)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// FlattenValidatorSet
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func output(id byte, sk *bls.SecretKey, weight uint64) (ids.NodeID, *GetValidatorOutput) {
|
||||
n := nodeID(id)
|
||||
var key []byte
|
||||
if sk != nil {
|
||||
key = bls.PublicKeyToCompressedBytes(sk.PublicKey())
|
||||
}
|
||||
return n, &GetValidatorOutput{NodeID: n, PublicKey: key, Weight: weight, Light: weight}
|
||||
}
|
||||
|
||||
// TestFlattenRefusesTheMerge is the retirement itself. Upstream folded two node
|
||||
// ids that shared a key into ONE validator carrying both ids and their summed
|
||||
// weight; that fold is the only thing that ever made many-nodes-one-key
|
||||
// representable, and a signer floor written to require many holders was cleared
|
||||
// by one. The state is now an error.
|
||||
func TestFlattenRefusesTheMerge(t *testing.T) {
|
||||
sk := secret(t, 17)
|
||||
in := map[ids.NodeID]*GetValidatorOutput{}
|
||||
for _, id := range []byte{0x60, 0x61} {
|
||||
n, o := output(id, sk, 10)
|
||||
in[n] = o
|
||||
}
|
||||
|
||||
// What the upstream does with it, recorded so the change is visible: one
|
||||
// validator, two node ids, twenty weight.
|
||||
merged, err := validators.FlattenValidatorSet(in)
|
||||
if err != nil {
|
||||
t.Fatalf("upstream flatten: %v", err)
|
||||
}
|
||||
if len(merged.Validators) != 1 || len(merged.Validators[0].NodeIDs) != 2 {
|
||||
t.Fatalf("upstream no longer merges — re-read this test: %+v", merged.Validators)
|
||||
}
|
||||
|
||||
if _, err := FlattenValidatorSet(in); !errors.Is(err, ErrDuplicateKey) {
|
||||
t.Fatalf("the merge is still reachable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenMatchesUpstreamWhenKeysAreDistinct is the compatibility proof: on
|
||||
// every set that already obeys one-key-one-node — which is every set the chain
|
||||
// holds today — this returns exactly what the network has always computed,
|
||||
// including the canonical ORDER that decides warp signature bit indices.
|
||||
func TestFlattenMatchesUpstreamWhenKeysAreDistinct(t *testing.T) {
|
||||
in := map[ids.NodeID]*GetValidatorOutput{}
|
||||
for i, seed := range []byte{31, 32, 33, 34, 35} {
|
||||
n, o := output(byte(0x70+i), secret(t, seed), uint64(100*(i+1)))
|
||||
in[n] = o
|
||||
}
|
||||
// A validator with no key at all: it cannot sign, and its weight still counts.
|
||||
n, o := output(0x7f, nil, 55)
|
||||
in[n] = o
|
||||
|
||||
mine, err := FlattenValidatorSet(in)
|
||||
if err != nil {
|
||||
t.Fatalf("FlattenValidatorSet: %v", err)
|
||||
}
|
||||
theirs, err := validators.FlattenValidatorSet(in)
|
||||
if err != nil {
|
||||
t.Fatalf("upstream flatten: %v", err)
|
||||
}
|
||||
if mine.TotalWeight != theirs.TotalWeight {
|
||||
t.Fatalf("total weight diverged: %d vs %d", mine.TotalWeight, theirs.TotalWeight)
|
||||
}
|
||||
if mine.TotalWeight != 1500+55 {
|
||||
t.Fatalf("a keyless validator's weight stopped counting: %d", mine.TotalWeight)
|
||||
}
|
||||
if len(mine.Validators) != len(theirs.Validators) {
|
||||
t.Fatalf("validator count diverged: %d vs %d", len(mine.Validators), len(theirs.Validators))
|
||||
}
|
||||
for i := range mine.Validators {
|
||||
a, b := mine.Validators[i], theirs.Validators[i]
|
||||
if !bytes.Equal(a.PublicKeyBytes, b.PublicKeyBytes) {
|
||||
t.Fatalf("canonical order diverged at %d", i)
|
||||
}
|
||||
if a.Weight != b.Weight {
|
||||
t.Fatalf("weight diverged at %d: %d vs %d", i, a.Weight, b.Weight)
|
||||
}
|
||||
if len(a.NodeIDs) != 1 || a.NodeIDs[0] != b.NodeIDs[0] {
|
||||
t.Fatalf("node ids diverged at %d: %v vs %v", i, a.NodeIDs, b.NodeIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenRefusesTheSameDuplicateEveryTime: map iteration order must not
|
||||
// decide which pair a set is refused on, or two nodes disagree about a set.
|
||||
func TestFlattenRefusesTheSameDuplicateEveryTime(t *testing.T) {
|
||||
sk := secret(t, 41)
|
||||
in := map[ids.NodeID]*GetValidatorOutput{}
|
||||
for _, id := range []byte{0x91, 0x92, 0x93, 0x94} {
|
||||
n, o := output(id, sk, 1)
|
||||
in[n] = o
|
||||
}
|
||||
_, first := FlattenValidatorSet(in)
|
||||
if first == nil {
|
||||
t.Fatal("four nodes sharing one key were admitted")
|
||||
}
|
||||
for i := 0; i < 64; i++ {
|
||||
if _, err := FlattenValidatorSet(in); err == nil || err.Error() != first.Error() {
|
||||
t.Fatalf("the refusal moved with map order: %q then %q", first, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenStillSkipsAnUndecodableKey: this function reads a set it did not
|
||||
// admit, so one unreadable key costs a signer, never the whole set. Strictness
|
||||
// lives at Register.
|
||||
func TestFlattenStillSkipsAnUndecodableKey(t *testing.T) {
|
||||
in := map[ids.NodeID]*GetValidatorOutput{}
|
||||
n, o := output(0xA1, secret(t, 51), 10)
|
||||
in[n] = o
|
||||
bad := nodeID(0xA2)
|
||||
in[bad] = &GetValidatorOutput{NodeID: bad, PublicKey: bytes.Repeat([]byte{0xEE}, bls.PublicKeyLen), Weight: 90}
|
||||
|
||||
set, err := FlattenValidatorSet(in)
|
||||
if err != nil {
|
||||
t.Fatalf("an undecodable key failed the whole set: %v", err)
|
||||
}
|
||||
if len(set.Validators) != 1 {
|
||||
t.Fatalf("admitted %d validators, want 1", len(set.Validators))
|
||||
}
|
||||
if set.TotalWeight != 100 {
|
||||
t.Fatalf("total weight %d, want 100 — the skipped validator's stake still counts", set.TotalWeight)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenAndRegisterAgree: two doors, one set. An admitted set and a
|
||||
// flattened one must be the same bytes in the same order.
|
||||
func TestFlattenAndRegisterAgree(t *testing.T) {
|
||||
rs := []Registration{
|
||||
registration(t, 61, 0xB1, 3),
|
||||
registration(t, 62, 0xB2, 5),
|
||||
registration(t, 63, 0xB3, 7),
|
||||
}
|
||||
in := map[ids.NodeID]*GetValidatorOutput{}
|
||||
for _, r := range rs {
|
||||
in[r.NodeID] = &GetValidatorOutput{NodeID: r.NodeID, PublicKey: r.Key, Weight: r.Weight, Light: r.Weight}
|
||||
}
|
||||
|
||||
admitted, err := Register(rs)
|
||||
if err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
flattened, err := FlattenValidatorSet(in)
|
||||
if err != nil {
|
||||
t.Fatalf("FlattenValidatorSet: %v", err)
|
||||
}
|
||||
if admitted.TotalWeight != flattened.TotalWeight {
|
||||
t.Fatalf("weights disagree: %d vs %d", admitted.TotalWeight, flattened.TotalWeight)
|
||||
}
|
||||
for i := range admitted.Validators {
|
||||
if !bytes.Equal(admitted.Validators[i].PublicKeyBytes, flattened.Validators[i].PublicKeyBytes) {
|
||||
t.Fatalf("the two doors order the set differently at %d", i)
|
||||
}
|
||||
if admitted.Validators[i].NodeIDs[0] != flattened.Validators[i].NodeIDs[0] {
|
||||
t.Fatalf("the two doors name different nodes at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ package validators
|
||||
|
||||
import (
|
||||
"github.com/luxfi/crypto/bls"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/validators"
|
||||
)
|
||||
@@ -26,10 +25,8 @@ type CanonicalValidatorSet = validators.CanonicalValidatorSet
|
||||
// CanonicalValidator is an alias for validators.CanonicalValidator
|
||||
type CanonicalValidator = validators.CanonicalValidator
|
||||
|
||||
// FlattenValidatorSet re-exports validators.FlattenValidatorSet
|
||||
func FlattenValidatorSet(vdrSet map[ids.NodeID]*GetValidatorOutput) (CanonicalValidatorSet, error) {
|
||||
return validators.FlattenValidatorSet(vdrSet)
|
||||
}
|
||||
// FlattenValidatorSet lives in registration.go: this package enforces one key
|
||||
// per node, so the upstream merge of duplicate keys is not re-exported.
|
||||
|
||||
// FilterValidators re-exports validators.FilterValidators
|
||||
func FilterValidators(
|
||||
|
||||
Reference in New Issue
Block a user