mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
node: signed-checkpoint bootstrap anchor (INVARIANT 4) + consensus v1.35.16
Harden the bootstrap checkpoint — the one path that bypasses the beacon quorum — into a cryptographically SIGNED weak-subjectivity anchor. - Checkpoint carries an authority Signature over a domain-separated canonical (id,height); CheckpointVerifier (injected, backed by a proven primitive — Ed25519/BLS, never custom crypto) authenticates it. The policy stays crypto-free exactly like AncestrySource/heightOf. - AcceptsFrontier trusts a pinned checkpoint ONLY when the configured authority signed that exact (id,height). Present-but-unsigned, signed-by-a-non-authority, signature-transplanted-to-another-(id,height), and no-verifier-wired all FAIL CLOSED. A compromised flag cannot inject a false sync anchor without also forging the authority's signature. It is a cryptographic vouch, NEVER a ⅔-live-stake tally. - No production path pins a checkpoint yet (opt-in operator escape hatch), so the invariant is enforced for when it is activated; nothing to wire now. Folds consensus v1.35.16 (single ⅔ formula + leaf-lock doctrine) into the node. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -24,6 +24,7 @@ package chains
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -127,9 +128,48 @@ type AncestrySource interface {
|
||||
// Checkpoint is an operator-pinned (id, height) the recovering node may anchor to when too few
|
||||
// beacons respond to form a quorum — the EXPLICIT override for INVARIANT 2's "1 of N reachable"
|
||||
// case. Absent (nil) ⇒ the default policy REJECTS rather than trusting a captured minority.
|
||||
//
|
||||
// INVARIANT 4 (a checkpoint is a SIGNED weak-subjectivity anchor, not a bare config value): the
|
||||
// checkpoint carries a cryptographic Signature by the configured checkpoint AUTHORITY over its
|
||||
// (id, height), and AcceptsFrontier trusts it ONLY when CheckpointVerifier authenticates that
|
||||
// signature. A (id,height) present in a flag/config but UNSIGNED — or signed by a non-authority key
|
||||
// — is REJECTED (fail closed). This is the crucial hardening: the checkpoint is the one path that
|
||||
// bypasses the beacon quorum, so a compromised flag must NOT be able to inject a false sync anchor
|
||||
// without ALSO forging the authority's signature. It is a cryptographic vouch, NEVER a ⅔-live-stake
|
||||
// tally (that conflation is the very deadlock BootstrapTrust exists to avoid).
|
||||
type Checkpoint struct {
|
||||
ID ids.ID
|
||||
Height uint64
|
||||
// Signature is the checkpoint authority's signature over this checkpoint's canonical (id,height)
|
||||
// bytes. Verified by CheckpointVerifier before the anchor is trusted; an empty signature is
|
||||
// never accepted.
|
||||
Signature []byte
|
||||
}
|
||||
|
||||
// CheckpointVerifier authenticates a Checkpoint's Signature against the configured checkpoint
|
||||
// AUTHORITY key(s). The node injects a real implementation backed by a PROVEN primitive (Ed25519 /
|
||||
// BLS — never custom crypto); the policy stays free of any crypto dependency, exactly like
|
||||
// AncestrySource and heightOf. A nil verifier means no signed anchor is configured, so any
|
||||
// Checkpoint is untrusted and the below-floor case fails closed.
|
||||
type CheckpointVerifier interface {
|
||||
// VerifyCheckpoint reports whether sig is a valid signature over (id, height) by the configured
|
||||
// checkpoint authority. It MUST reject an empty signature and be signature-safe (constant-time
|
||||
// compare on the primitive). It is the sole authority on whether a pinned anchor may be trusted.
|
||||
VerifyCheckpoint(id ids.ID, height uint64, sig []byte) bool
|
||||
}
|
||||
|
||||
// CanonicalCheckpointMessage is the exact byte string a checkpoint authority signs and
|
||||
// CheckpointVerifier authenticates: a domain-separated, fixed-layout encoding of (id, height) so a
|
||||
// signature can never be transplanted from another context. 8-byte big-endian height after the
|
||||
// 32-byte id, under a distinct domain tag.
|
||||
func CanonicalCheckpointMessage(id ids.ID, height uint64) []byte {
|
||||
const domain = "lux-bootstrap-checkpoint-v1\x00"
|
||||
msg := make([]byte, 0, len(domain)+len(id)+8)
|
||||
msg = append(msg, domain...)
|
||||
msg = append(msg, id[:]...)
|
||||
var h [8]byte
|
||||
binary.BigEndian.PutUint64(h[:], height)
|
||||
return append(msg, h[:]...)
|
||||
}
|
||||
|
||||
// Ratio is an exact rational threshold (e.g. 2/3, 3/4). A value clears it iff
|
||||
@@ -197,8 +237,12 @@ type BootstrapPolicy struct {
|
||||
// network, or a fleet unanimously AT the tip).
|
||||
MinFrontierHeight uint64
|
||||
// Checkpoint is the OPTIONAL operator override for the below-floor case (INVARIANT 2). nil ⇒
|
||||
// reject below the floor.
|
||||
// reject below the floor. When set, it is trusted ONLY if CheckpointVerifier authenticates its
|
||||
// signature (INVARIANT 4).
|
||||
Checkpoint *Checkpoint
|
||||
// CheckpointVerifier authenticates the Checkpoint's authority signature (INVARIANT 4). nil ⇒ a
|
||||
// configured Checkpoint is NOT trusted (fail closed) — a bare (id,height) is never enough.
|
||||
CheckpointVerifier CheckpointVerifier
|
||||
// NamingWindow bounds the ancestry fetched per anchor; MaxAnchors bounds how many distinct
|
||||
// reported tips are resolved. Both default to the package constants when zero.
|
||||
NamingWindow int
|
||||
@@ -330,6 +374,15 @@ func (p *BootstrapPolicy) AcceptsFrontier(ctx context.Context, replies []BeaconR
|
||||
// operator explicitly pinned a checkpoint to anchor from.
|
||||
if !p.floorMet(responders, responderWeight) {
|
||||
if p.Checkpoint != nil {
|
||||
// INVARIANT 4: the checkpoint bypasses the beacon quorum, so trust it ONLY when the
|
||||
// configured authority SIGNED this exact (id,height). A checkpoint present in config but
|
||||
// unsigned, or signed by a non-authority key, is REJECTED (fail closed) — a compromised
|
||||
// flag cannot inject a false sync anchor without also forging the authority's signature.
|
||||
if p.CheckpointVerifier == nil || len(p.Checkpoint.Signature) == 0 ||
|
||||
!p.CheckpointVerifier.VerifyCheckpoint(p.Checkpoint.ID, p.Checkpoint.Height, p.Checkpoint.Signature) {
|
||||
return nil, fmt.Errorf("%w: a checkpoint is pinned but its authority signature did not verify",
|
||||
ErrInsufficientBootstrapResponses)
|
||||
}
|
||||
return &Frontier{
|
||||
ID: p.Checkpoint.ID,
|
||||
Height: p.Checkpoint.Height,
|
||||
|
||||
@@ -16,6 +16,7 @@ package chains
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -343,26 +344,54 @@ func TestBootstrapTrust_G_FinalityUnchanged(t *testing.T) {
|
||||
|
||||
// ----- checkpoint override (complements B) ----------------------------------
|
||||
|
||||
// edCheckpointAuthority is a test checkpoint authority backed by Ed25519 — a PROVEN primitive, no
|
||||
// custom crypto. It signs a checkpoint's canonical (id,height) message and verifies against its own
|
||||
// public key, rejecting an empty signature and any key that is not the configured authority.
|
||||
type edCheckpointAuthority struct {
|
||||
priv ed25519.PrivateKey
|
||||
pub ed25519.PublicKey
|
||||
}
|
||||
|
||||
func newEdCheckpointAuthority(t *testing.T) *edCheckpointAuthority {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
require.NoError(t, err)
|
||||
return &edCheckpointAuthority{priv: priv, pub: pub}
|
||||
}
|
||||
|
||||
func (a *edCheckpointAuthority) sign(id ids.ID, height uint64) []byte {
|
||||
return ed25519.Sign(a.priv, CanonicalCheckpointMessage(id, height))
|
||||
}
|
||||
|
||||
// VerifyCheckpoint implements CheckpointVerifier: authenticate against the authority's public key.
|
||||
func (a *edCheckpointAuthority) VerifyCheckpoint(id ids.ID, height uint64, sig []byte) bool {
|
||||
return len(sig) != 0 && ed25519.Verify(a.pub, CanonicalCheckpointMessage(id, height), sig)
|
||||
}
|
||||
|
||||
// TestBootstrapTrust_CheckpointOverride: below the response floor (1 of 5), the DEFAULT is reject
|
||||
// (test B), but an operator who pins a checkpoint gets the explicit override — the node anchors to
|
||||
// the pinned (id,height) instead of trusting the lone beacon. This is the sanctioned escape hatch
|
||||
// for a deeply-partitioned node, NEVER an open-ended ≥1-beacon acceptance.
|
||||
// (test B), but an operator who pins a SIGNED checkpoint gets the explicit override — the node
|
||||
// anchors to the authenticated (id,height) instead of trusting the lone beacon. This is the
|
||||
// sanctioned escape hatch for a deeply-partitioned node, NEVER an open-ended ≥1-beacon acceptance,
|
||||
// and (INVARIANT 4) NEVER a bare unsigned config value.
|
||||
func TestBootstrapTrust_CheckpointOverride(t *testing.T) {
|
||||
beacons := nodeIDs(5)
|
||||
authority := newEdCheckpointAuthority(t)
|
||||
ckptID := ids.GenerateTestID()
|
||||
const ckptHeight = uint64(1_082_796)
|
||||
policy := &BootstrapPolicy{
|
||||
TrustedBeacons: equalBeacons(beacons, equalStake),
|
||||
MinResponses: 3,
|
||||
Checkpoint: &Checkpoint{ID: ckptID, Height: 1_082_796},
|
||||
TrustedBeacons: equalBeacons(beacons, equalStake),
|
||||
MinResponses: 3,
|
||||
Checkpoint: &Checkpoint{ID: ckptID, Height: ckptHeight, Signature: authority.sign(ckptID, ckptHeight)},
|
||||
CheckpointVerifier: authority,
|
||||
}
|
||||
// 1 reachable beacon — below the floor — but a checkpoint is pinned.
|
||||
// 1 reachable beacon — below the floor — but a SIGNED checkpoint is pinned.
|
||||
f, err := policy.AcceptsFrontier(context.Background(), []BeaconReply{
|
||||
reply(beacons[0], ids.GenerateTestID(), equalStake),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, f.FromCheckpoint, "below the floor with a pinned checkpoint → anchor to the checkpoint")
|
||||
require.True(t, f.FromCheckpoint, "below the floor with a SIGNED checkpoint → anchor to the checkpoint")
|
||||
require.Equal(t, ckptID, f.ID)
|
||||
require.Equal(t, uint64(1_082_796), f.Height)
|
||||
require.Equal(t, ckptHeight, f.Height)
|
||||
|
||||
// Without the checkpoint the same 1-of-5 is rejected (the default — never trust the lone beacon).
|
||||
policy.Checkpoint = nil
|
||||
@@ -372,6 +401,56 @@ func TestBootstrapTrust_CheckpointOverride(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses)
|
||||
}
|
||||
|
||||
// TestBootstrapTrust_CheckpointMustBeSigned is INVARIANT 4: a checkpoint that is present but not
|
||||
// AUTHENTICATED is REJECTED (fail closed). A compromised flag/config that pins a false (id,height)
|
||||
// cannot inject a sync anchor without the authority's signature. Four rejection modes, one accept.
|
||||
func TestBootstrapTrust_CheckpointMustBeSigned(t *testing.T) {
|
||||
beacons := nodeIDs(5)
|
||||
authority := newEdCheckpointAuthority(t)
|
||||
attacker := newEdCheckpointAuthority(t) // a DIFFERENT key — not the configured authority
|
||||
ckptID := ids.GenerateTestID()
|
||||
const h = uint64(500_000)
|
||||
lone := []BeaconReply{reply(beacons[0], ids.GenerateTestID(), equalStake)} // 1-of-5, below floor
|
||||
|
||||
base := func() *BootstrapPolicy {
|
||||
return &BootstrapPolicy{TrustedBeacons: equalBeacons(beacons, equalStake), MinResponses: 3, CheckpointVerifier: authority}
|
||||
}
|
||||
|
||||
// (1) UNSIGNED checkpoint (empty signature) → rejected even with a verifier wired.
|
||||
p := base()
|
||||
p.Checkpoint = &Checkpoint{ID: ckptID, Height: h}
|
||||
_, err := p.AcceptsFrontier(context.Background(), lone)
|
||||
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses, "an UNSIGNED checkpoint must be rejected")
|
||||
|
||||
// (2) signed by a NON-AUTHORITY (attacker) key → rejected.
|
||||
p = base()
|
||||
p.Checkpoint = &Checkpoint{ID: ckptID, Height: h, Signature: attacker.sign(ckptID, h)}
|
||||
_, err = p.AcceptsFrontier(context.Background(), lone)
|
||||
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses, "a checkpoint signed by a non-authority key must be rejected")
|
||||
|
||||
// (3) authority signature over a DIFFERENT (id,height) — replay onto a forged anchor → rejected.
|
||||
p = base()
|
||||
forgedID := ids.GenerateTestID()
|
||||
p.Checkpoint = &Checkpoint{ID: forgedID, Height: h, Signature: authority.sign(ckptID, h)} // sig binds ckptID, not forgedID
|
||||
_, err = p.AcceptsFrontier(context.Background(), lone)
|
||||
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses, "a signature transplanted to a different (id,height) must be rejected")
|
||||
|
||||
// (4) NO verifier configured → any checkpoint is untrusted (fail closed).
|
||||
p = base()
|
||||
p.CheckpointVerifier = nil
|
||||
p.Checkpoint = &Checkpoint{ID: ckptID, Height: h, Signature: authority.sign(ckptID, h)}
|
||||
_, err = p.AcceptsFrontier(context.Background(), lone)
|
||||
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses, "no verifier ⇒ even a validly-signed checkpoint is untrusted")
|
||||
|
||||
// (accept) authority signs the exact pinned (id,height) → trusted.
|
||||
p = base()
|
||||
p.Checkpoint = &Checkpoint{ID: ckptID, Height: h, Signature: authority.sign(ckptID, h)}
|
||||
f, err := p.AcceptsFrontier(context.Background(), lone)
|
||||
require.NoError(t, err)
|
||||
require.True(t, f.FromCheckpoint)
|
||||
require.Equal(t, ckptID, f.ID)
|
||||
}
|
||||
|
||||
// ----- safety guard for the global ancestor-tolerant tally ------------------
|
||||
|
||||
// TestBootstrapTrust_ForkAtSharedGenesisFailsSafe is the load-bearing guard for the
|
||||
|
||||
@@ -26,7 +26,7 @@ require (
|
||||
github.com/huin/goupnp v1.3.0
|
||||
github.com/jackpal/gateway v1.1.1
|
||||
github.com/jackpal/go-nat-pmp v1.0.2
|
||||
github.com/luxfi/consensus v1.35.15
|
||||
github.com/luxfi/consensus v1.35.16
|
||||
github.com/luxfi/crypto v1.19.26
|
||||
github.com/luxfi/database v1.20.4
|
||||
github.com/luxfi/ids v1.3.0
|
||||
|
||||
@@ -339,6 +339,8 @@ github.com/luxfi/consensus v1.35.14 h1:HvaQLanNlC1bUwk6xun32pwz/qZ0Ia44lO36DFrrd
|
||||
github.com/luxfi/consensus v1.35.14/go.mod h1:2ON+tN8hsAqd13DmsGk1Epfz6EQ0s4lv/rmOUsh7Gpk=
|
||||
github.com/luxfi/consensus v1.35.15 h1:iy10lEjMHhul6f4ioSZ05XbHOOU8pilteuvZu7jMefs=
|
||||
github.com/luxfi/consensus v1.35.15/go.mod h1:2ON+tN8hsAqd13DmsGk1Epfz6EQ0s4lv/rmOUsh7Gpk=
|
||||
github.com/luxfi/consensus v1.35.16 h1:5hRLqVy9RQH7ZRApO6kiLwLs+9HACGNNZ4wkjt0XM4I=
|
||||
github.com/luxfi/consensus v1.35.16/go.mod h1:2ON+tN8hsAqd13DmsGk1Epfz6EQ0s4lv/rmOUsh7Gpk=
|
||||
github.com/luxfi/constants v1.5.8 h1:iNP9AWNUcM4Tps7jYnx49CwtCWAC9mYRxJfGou2za0g=
|
||||
github.com/luxfi/constants v1.5.8/go.mod h1:Pu5jWHdnUtQRbWC43yTUjU/pbIIKMDOd2a2yroSfo48=
|
||||
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
|
||||
|
||||
Reference in New Issue
Block a user