Files
zeekayandHanzo Dev af808b25ad 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]>
2026-09-01 08:10:24 -07:00

187 lines
8.1 KiB
Go

// 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
}