mirror of
https://github.com/luxfi/keys.git
synced 2026-07-26 23:58:11 +00:00
chore: sync working tree
Commits 7 outstanding change(s) that were sitting uncommitted. No build artifacts and no secrets in the changeset (both checked).
This commit is contained in:
@@ -16,9 +16,11 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.6 // indirect
|
github.com/btcsuite/btcd/btcutil v1.1.6 // indirect
|
||||||
|
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||||
github.com/gorilla/rpc v1.2.1 // indirect
|
github.com/gorilla/rpc v1.2.1 // indirect
|
||||||
|
github.com/grandcat/zeroconf v1.0.0 // indirect
|
||||||
github.com/holiman/uint256 v1.3.2 // indirect
|
github.com/holiman/uint256 v1.3.2 // indirect
|
||||||
github.com/luxfi/accel v1.2.4 // indirect
|
github.com/luxfi/accel v1.2.4 // indirect
|
||||||
github.com/luxfi/cache v1.3.1 // indirect
|
github.com/luxfi/cache v1.3.1 // indirect
|
||||||
@@ -27,14 +29,18 @@ require (
|
|||||||
github.com/luxfi/geth v1.20.1 // indirect
|
github.com/luxfi/geth v1.20.1 // indirect
|
||||||
github.com/luxfi/math v1.5.1 // indirect
|
github.com/luxfi/math v1.5.1 // indirect
|
||||||
github.com/luxfi/math/big v0.1.0 // indirect
|
github.com/luxfi/math/big v0.1.0 // indirect
|
||||||
|
github.com/luxfi/mdns v0.1.1 // indirect
|
||||||
github.com/luxfi/metric v1.8.1 // indirect
|
github.com/luxfi/metric v1.8.1 // indirect
|
||||||
github.com/luxfi/mock v0.1.1 // indirect
|
github.com/luxfi/mock v0.1.1 // indirect
|
||||||
github.com/luxfi/sampler v1.1.0 // indirect
|
github.com/luxfi/sampler v1.1.0 // indirect
|
||||||
github.com/luxfi/vm v1.3.1 // indirect
|
github.com/luxfi/vm v1.3.1 // indirect
|
||||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
github.com/luxfi/zap v1.2.6 // indirect
|
||||||
|
github.com/miekg/dns v1.1.72 // indirect
|
||||||
|
github.com/mr-tron/base58 v1.3.0 // indirect
|
||||||
github.com/supranational/blst v0.3.16 // indirect
|
github.com/supranational/blst v0.3.16 // indirect
|
||||||
go.uber.org/mock v0.6.0 // indirect
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
|
||||||
|
golang.org/x/net v0.55.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
gonum.org/v1/gonum v0.17.0 // indirect
|
gonum.org/v1/gonum v0.17.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
|||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
// Copyright (C) 2024-2026, Lux Industries Inc. All rights reserved.
|
||||||
|
// See the file LICENSE for licensing terms.
|
||||||
|
|
||||||
|
// pq_validator.go — deterministic strict-PQ validator identity derivation.
|
||||||
|
//
|
||||||
|
// A strict-PQ validator's identity is two FIPS-standard keys:
|
||||||
|
//
|
||||||
|
// ML-DSA-65 (FIPS 204) the staking-signature key that ANCHORS the NodeID
|
||||||
|
// (ids.NodeIDSchemeMLDSA65.DeriveMLDSA over its pubkey)
|
||||||
|
// ML-KEM-768 (FIPS 203) the handshake KEM key peers encapsulate to
|
||||||
|
//
|
||||||
|
// Both are derived DETERMINISTICALLY from the one BIP-39 deploy mnemonic and
|
||||||
|
// a validator index, exactly like the classical TLS+BLS keys that
|
||||||
|
// DeriveValidatorFromMnemonic produces. Determinism is the whole point: a
|
||||||
|
// restarted or replacement pod re-derives the SAME NodeID with nothing
|
||||||
|
// custodied on disk. Without it, staking/pq.go's mldsa.GenerateKey(rand.Reader,
|
||||||
|
// …) mints a fresh key — hence a DIFFERENT validator — on every boot.
|
||||||
|
//
|
||||||
|
// Derivation tree (coin type 60, the same tree DeriveValidatorFromMnemonic
|
||||||
|
// funds — accounts 0=EC, 1=TLS, 2=BLS are taken; the strict-PQ keys take the
|
||||||
|
// next free accounts):
|
||||||
|
//
|
||||||
|
// ML-DSA-65 : m/44'/60'/3'/0/<index>
|
||||||
|
// ML-KEM-768: m/44'/60'/4'/0/<index>
|
||||||
|
//
|
||||||
|
// The 32-byte BIP-32 leaf key is domain-separated and HKDF-expanded into the
|
||||||
|
// deterministic keygen stream — the identical idiom service_identity.go uses
|
||||||
|
// (cryptographer-reviewed: same HKDF-Expand output drives keygen to the same
|
||||||
|
// keypair byte-for-byte). The domain strings below are DISTINCT from every
|
||||||
|
// other derivation string in this package so a validator staking key is never
|
||||||
|
// the same byte-string as a service-auth key, even at a colliding BIP-32 leaf.
|
||||||
|
//
|
||||||
|
// ENTROPY CEILING (I1). These are FIPS 204/203 keys at NIST Level 3, but when
|
||||||
|
// derived here their effective root entropy is bounded by the BIP-39 mnemonic
|
||||||
|
// that seeds the whole tree. A 12-word (128-bit) deploy mnemonic caps the
|
||||||
|
// unpredictability of every derived ML-DSA-65 / ML-KEM-768 key at 128 bits: an
|
||||||
|
// adversary who can brute-force the 2^128 mnemonic space recovers ALL derived
|
||||||
|
// validator keys, regardless of the primitives' nominal ~192-bit strength. The
|
||||||
|
// 128-bit floor is acceptable for the classical-adversary threat model, but a
|
||||||
|
// deployment that wants the PQ keys' full nominal strength as its root-secret
|
||||||
|
// floor must seed the tree from a 24-word (256-bit) mnemonic. GenerateValidatorPQ
|
||||||
|
// (CSPRNG) has no such ceiling — its keys carry the primitives' full entropy.
|
||||||
|
|
||||||
|
package keys
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
mldsa "github.com/luxfi/crypto/mldsa"
|
||||||
|
mlkem "github.com/luxfi/crypto/mlkem"
|
||||||
|
"github.com/luxfi/ids"
|
||||||
|
"golang.org/x/crypto/hkdf"
|
||||||
|
"golang.org/x/crypto/sha3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BIP-44 account indices for the strict-PQ validator keys. Chosen to sit
|
||||||
|
// immediately after the classical accounts (0=EC, 1=TLS, 2=BLS) in the same
|
||||||
|
// coin-type-60 tree so one mnemonic covers a validator's whole identity.
|
||||||
|
const (
|
||||||
|
pqMLDSAAccount uint32 = 3
|
||||||
|
pqMLKEMAccount uint32 = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// Domain-separation strings for the deterministic keygen seeds. Pinned at v1;
|
||||||
|
// bumping either invalidates every strict-PQ NodeID derived under it (a
|
||||||
|
// hardfork of the derivation encoding — the correct behaviour for a change).
|
||||||
|
// DISTINCT from serviceIdentityDomain / HybridPQDomain so a validator key and
|
||||||
|
// a service-auth key derived from the same mnemonic never coincide.
|
||||||
|
const (
|
||||||
|
validatorMLDSADomain = "lux-validator-mldsa65-v1"
|
||||||
|
validatorMLKEMDomain = "lux-validator-mlkem768-v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidAccountIndex is returned when the validator index is out of the
|
||||||
|
// supported range. The cap mirrors DeriveValidatorsFromMnemonic's 1..100
|
||||||
|
// convention: a validator index far above the plausible fleet size is almost
|
||||||
|
// always a misconfiguration, and refusing it early beats silently deriving an
|
||||||
|
// unexpected NodeID.
|
||||||
|
var ErrInvalidAccountIndex = errors.New("keys: validator account index out of range (0..9999)")
|
||||||
|
|
||||||
|
// maxValidatorIndex bounds the non-hardened BIP-32 leaf index. Generous
|
||||||
|
// (10k validators) but finite — a uint32 typo (e.g. reading an address as an
|
||||||
|
// index) is caught rather than pointing the node at a silent, wrong NodeID.
|
||||||
|
const maxValidatorIndex uint32 = 9999
|
||||||
|
|
||||||
|
// PQValidatorKey is the strict-PQ staking identity material for one validator,
|
||||||
|
// derived deterministically from a BIP-39 mnemonic. The private fields hold
|
||||||
|
// live key material — call Wipe() when the derived node.StakingConfig no longer
|
||||||
|
// needs them.
|
||||||
|
type PQValidatorKey struct {
|
||||||
|
// MLDSAPriv / MLDSAPub — FIPS 204 ML-DSA-65. MLDSAPub is the NodeID anchor
|
||||||
|
// under ids.NodeIDSchemeMLDSA65; MLDSAPriv signs blocks/handshakes.
|
||||||
|
MLDSAPriv []byte
|
||||||
|
MLDSAPub []byte
|
||||||
|
|
||||||
|
// MLKEMPriv / MLKEMPub — FIPS 203 ML-KEM-768. MLKEMPub is published in the
|
||||||
|
// validator-set entry so peers encapsulate to it; MLKEMPriv stays local.
|
||||||
|
MLKEMPriv []byte
|
||||||
|
MLKEMPub []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveValidatorPQ derives the strict-PQ staking identity (ML-DSA-65 +
|
||||||
|
// ML-KEM-768) for a validator at accountIndex from a BIP-39 mnemonic.
|
||||||
|
//
|
||||||
|
// Pure function: given the same (mnemonic, accountIndex) it returns the same
|
||||||
|
// keys, byte-for-byte, on every call and every machine. No randomness, no I/O,
|
||||||
|
// no clock reads. This is what makes a strict-PQ NodeID stable across restarts
|
||||||
|
// without custody.
|
||||||
|
func DeriveValidatorPQ(mnemonic string, accountIndex uint32) (*PQValidatorKey, error) {
|
||||||
|
if accountIndex > maxValidatorIndex {
|
||||||
|
return nil, ErrInvalidAccountIndex
|
||||||
|
}
|
||||||
|
// deriveMnemonicKeyForPath validates the mnemonic (BIP-39) internally and
|
||||||
|
// walks m/44'/60'/<account>'/0/<index>.
|
||||||
|
mldsaLeaf, err := deriveMnemonicKeyForPath(mnemonic, pqMLDSAAccount, accountIndex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("keys: derive ML-DSA leaf: %w", err)
|
||||||
|
}
|
||||||
|
defer zero(mldsaLeaf)
|
||||||
|
mlkemLeaf, err := deriveMnemonicKeyForPath(mnemonic, pqMLKEMAccount, accountIndex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("keys: derive ML-KEM leaf: %w", err)
|
||||||
|
}
|
||||||
|
defer zero(mlkemLeaf)
|
||||||
|
|
||||||
|
// ML-DSA-65: HKDF-expand the domain-separated leaf into the FIPS 204 keygen
|
||||||
|
// stream. Deterministic — same seed → same keypair.
|
||||||
|
mldsaReader := hkdf.New(sha3.New256, pqSeed(mldsaLeaf, validatorMLDSADomain), nil, []byte(validatorMLDSADomain))
|
||||||
|
mldsaPriv, err := mldsa.GenerateKey(mldsaReader, mldsa.MLDSA65)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("keys: ML-DSA-65 keygen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ML-KEM-768: same deterministic construction, distinct domain.
|
||||||
|
mlkemReader := hkdf.New(sha3.New256, pqSeed(mlkemLeaf, validatorMLKEMDomain), nil, []byte(validatorMLKEMDomain))
|
||||||
|
mlkemPub, mlkemPriv, err := mlkem.GenerateKeyPair(mlkemReader, mlkem.MLKEM768)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("keys: ML-KEM-768 keygen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PQValidatorKey{
|
||||||
|
MLDSAPriv: append([]byte(nil), mldsaPriv.Bytes()...),
|
||||||
|
MLDSAPub: append([]byte(nil), mldsaPriv.PublicKey.Bytes()...),
|
||||||
|
MLKEMPriv: append([]byte(nil), mlkemPriv.Bytes()...),
|
||||||
|
MLKEMPub: append([]byte(nil), mlkemPub.Bytes()...),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateValidatorPQ mints a FRESH strict-PQ validator identity from the system
|
||||||
|
// CSPRNG (crypto/rand). It is the orthogonal sibling of DeriveValidatorPQ — use
|
||||||
|
// it for the custody model, where a node holds a UNIQUE, non-derived identity
|
||||||
|
// that is then persisted to KMS (StakingIdentity.Marshal → SaveAt). A fresh key
|
||||||
|
// has NO stable NodeID across restarts on its own; custody in KMS is what makes
|
||||||
|
// it stable. Prefer DeriveValidatorPQ (one seed, N paths) unless a per-node
|
||||||
|
// unique key is specifically required.
|
||||||
|
func GenerateValidatorPQ() (*PQValidatorKey, error) {
|
||||||
|
mldsaPriv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("keys: ML-DSA-65 keygen: %w", err)
|
||||||
|
}
|
||||||
|
mlkemPub, mlkemPriv, err := mlkem.GenerateKeyPair(rand.Reader, mlkem.MLKEM768)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("keys: ML-KEM-768 keygen: %w", err)
|
||||||
|
}
|
||||||
|
return &PQValidatorKey{
|
||||||
|
MLDSAPriv: append([]byte(nil), mldsaPriv.Bytes()...),
|
||||||
|
MLDSAPub: append([]byte(nil), mldsaPriv.PublicKey.Bytes()...),
|
||||||
|
MLKEMPriv: append([]byte(nil), mlkemPriv.Bytes()...),
|
||||||
|
MLKEMPub: append([]byte(nil), mlkemPub.Bytes()...),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrictPQNodeID returns the NodeID this key anchors, under the SAME derivation
|
||||||
|
// the node applies at boot (node.StakingConfig.DeriveNodeID):
|
||||||
|
// ids.NodeIDSchemeMLDSA65.DeriveMLDSA(chainID, MLDSAPub). Pass ids.Empty for
|
||||||
|
// the primary-network validator identity (the node uses ids.Empty).
|
||||||
|
func (k *PQValidatorKey) StrictPQNodeID(chainID ids.ID) (ids.NodeID, error) {
|
||||||
|
if k == nil || len(k.MLDSAPub) == 0 {
|
||||||
|
return ids.EmptyNodeID, errors.New("keys: PQ validator key has no ML-DSA public key")
|
||||||
|
}
|
||||||
|
id, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(chainID, k.MLDSAPub)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wipe zeroes the private key material in place. Idempotent; safe on nil.
|
||||||
|
func (k *PQValidatorKey) Wipe() {
|
||||||
|
if k == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
zero(k.MLDSAPriv)
|
||||||
|
zero(k.MLKEMPriv)
|
||||||
|
k.MLDSAPriv = nil
|
||||||
|
k.MLKEMPriv = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pqSeed compresses a BIP-32 leaf key into a 32-byte deterministic seed under
|
||||||
|
// a domain string. SHAKE256 with SP 800-185 left_encode framing on each field
|
||||||
|
// so the (domain, leaf) concatenation is unambiguous.
|
||||||
|
func pqSeed(leafKey []byte, domain string) []byte {
|
||||||
|
h := sha3.NewShake256()
|
||||||
|
_, _ = h.Write(leftEncode(uint64(len(domain)) * 8))
|
||||||
|
_, _ = h.Write([]byte(domain))
|
||||||
|
_, _ = h.Write(leftEncode(uint64(len(leafKey)) * 8))
|
||||||
|
_, _ = h.Write(leafKey)
|
||||||
|
out := make([]byte, 32)
|
||||||
|
_, _ = h.Read(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// zero overwrites a byte slice in place. Best-effort — Go's GC offers no
|
||||||
|
// harder guarantee, but it shortens the window a private key sits readable in
|
||||||
|
// process memory.
|
||||||
|
func zero(b []byte) {
|
||||||
|
for i := range b {
|
||||||
|
b[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Copyright (C) 2024-2026, Lux Industries Inc. All rights reserved.
|
||||||
|
// See the file LICENSE for licensing terms.
|
||||||
|
|
||||||
|
package keys
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
mldsa "github.com/luxfi/crypto/mldsa"
|
||||||
|
mlkem "github.com/luxfi/crypto/mlkem"
|
||||||
|
"github.com/luxfi/ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validMnemonic is the canonical BIP-39 all-zero-entropy English test vector.
|
||||||
|
const validMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
|
// Deterministic: same (mnemonic, index) → identical strict-PQ keys, every call.
|
||||||
|
// This is the invariant that makes a strict-PQ NodeID stable across restarts
|
||||||
|
// with nothing custodied on disk.
|
||||||
|
func TestDeriveValidatorPQ_Deterministic(t *testing.T) {
|
||||||
|
a, err := DeriveValidatorPQ(validMnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derive a: %v", err)
|
||||||
|
}
|
||||||
|
b, err := DeriveValidatorPQ(validMnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derive b: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(a.MLDSAPriv, b.MLDSAPriv) || !bytes.Equal(a.MLDSAPub, b.MLDSAPub) {
|
||||||
|
t.Fatal("ML-DSA-65 derivation is not deterministic")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(a.MLKEMPriv, b.MLKEMPriv) || !bytes.Equal(a.MLKEMPub, b.MLKEMPub) {
|
||||||
|
t.Fatal("ML-KEM-768 derivation is not deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The derived key material must be the real FIPS 204 / FIPS 203 keypairs: the
|
||||||
|
// public key parses and matches the one derivable from the private key.
|
||||||
|
func TestDeriveValidatorPQ_WellFormedKeys(t *testing.T) {
|
||||||
|
k, err := DeriveValidatorPQ(validMnemonic, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derive: %v", err)
|
||||||
|
}
|
||||||
|
// ML-DSA-65: pub derivable from priv must equal the stored pub.
|
||||||
|
priv, err := mldsa.PrivateKeyFromBytes(mldsa.MLDSA65, k.MLDSAPriv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse ML-DSA priv: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(priv.PublicKey.Bytes(), k.MLDSAPub) {
|
||||||
|
t.Fatal("ML-DSA-65 stored pub != pub derived from priv")
|
||||||
|
}
|
||||||
|
// ML-DSA-65 sign/verify round-trip over the derived key.
|
||||||
|
msg := []byte("strict-pq staking identity")
|
||||||
|
sig, err := priv.Sign(nil, msg, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign: %v", err)
|
||||||
|
}
|
||||||
|
pub, err := mldsa.PublicKeyFromBytes(k.MLDSAPub, mldsa.MLDSA65)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse ML-DSA pub: %v", err)
|
||||||
|
}
|
||||||
|
if !pub.VerifySignature(msg, sig) {
|
||||||
|
t.Fatal("ML-DSA-65 signature over derived key failed to verify")
|
||||||
|
}
|
||||||
|
// ML-KEM-768: both halves parse at the FIPS 203 sizes.
|
||||||
|
if len(k.MLKEMPub) != mlkem.MLKEM768PublicKeySize {
|
||||||
|
t.Fatalf("ML-KEM pub size = %d, want %d", len(k.MLKEMPub), mlkem.MLKEM768PublicKeySize)
|
||||||
|
}
|
||||||
|
if len(k.MLKEMPriv) != mlkem.MLKEM768PrivateKeySize {
|
||||||
|
t.Fatalf("ML-KEM priv size = %d, want %d", len(k.MLKEMPriv), mlkem.MLKEM768PrivateKeySize)
|
||||||
|
}
|
||||||
|
if _, err := mlkem.PrivateKeyFromBytes(k.MLKEMPriv, mlkem.MLKEM768); err != nil {
|
||||||
|
t.Fatalf("parse ML-KEM priv: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The NodeID the derived key anchors matches the node's boot derivation exactly
|
||||||
|
// (ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, MLDSAPub)) — a re-derived key
|
||||||
|
// yields the same NodeID, which is the whole point of custody-free stability.
|
||||||
|
func TestDeriveValidatorPQ_NodeIDRoundTrip(t *testing.T) {
|
||||||
|
k, err := DeriveValidatorPQ(validMnemonic, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derive: %v", err)
|
||||||
|
}
|
||||||
|
got, err := k.StrictPQNodeID(ids.Empty)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NodeID: %v", err)
|
||||||
|
}
|
||||||
|
// Independently compute what node.StakingConfig.DeriveNodeID(ids.Empty)
|
||||||
|
// would produce for a strict-PQ node holding this pub.
|
||||||
|
want, _, err := ids.NodeIDSchemeMLDSA65.DeriveMLDSA(ids.Empty, k.MLDSAPub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reference DeriveMLDSA: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("NodeID mismatch: helper=%s reference=%s", got, want)
|
||||||
|
}
|
||||||
|
// A freshly re-derived key from the same mnemonic+index yields the SAME
|
||||||
|
// NodeID — the restart-stability guarantee.
|
||||||
|
k2, err := DeriveValidatorPQ(validMnemonic, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-derive: %v", err)
|
||||||
|
}
|
||||||
|
got2, _ := k2.StrictPQNodeID(ids.Empty)
|
||||||
|
if got != got2 {
|
||||||
|
t.Fatal("re-derived key produced a different NodeID (custody-free stability broken)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct validator indices produce distinct keys and distinct NodeIDs — a
|
||||||
|
// fleet derived from one mnemonic must not collapse onto one identity.
|
||||||
|
func TestDeriveValidatorPQ_IndexSeparation(t *testing.T) {
|
||||||
|
k0, _ := DeriveValidatorPQ(validMnemonic, 0)
|
||||||
|
k1, _ := DeriveValidatorPQ(validMnemonic, 1)
|
||||||
|
if bytes.Equal(k0.MLDSAPub, k1.MLDSAPub) {
|
||||||
|
t.Fatal("different indices produced the same ML-DSA public key")
|
||||||
|
}
|
||||||
|
if bytes.Equal(k0.MLKEMPub, k1.MLKEMPub) {
|
||||||
|
t.Fatal("different indices produced the same ML-KEM public key")
|
||||||
|
}
|
||||||
|
n0, _ := k0.StrictPQNodeID(ids.Empty)
|
||||||
|
n1, _ := k1.StrictPQNodeID(ids.Empty)
|
||||||
|
if n0 == n1 {
|
||||||
|
t.Fatal("different indices produced the same NodeID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain separation: the validator ML-DSA key must NEVER equal the service-auth
|
||||||
|
// ML-DSA key derived from the same mnemonic (distinct derivation strings + tree
|
||||||
|
// positions). A collision would let a KMS-auth credential masquerade as a
|
||||||
|
// staking key, or vice versa.
|
||||||
|
func TestDeriveValidatorPQ_DomainSeparatedFromServiceIdentity(t *testing.T) {
|
||||||
|
vk, err := DeriveValidatorPQ(validMnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derive validator pq: %v", err)
|
||||||
|
}
|
||||||
|
svc, err := NewServiceIdentity(validMnemonic, "luxd/staking-bootstrap")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derive service identity: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(vk.MLDSAPub, svc.PublicKey) {
|
||||||
|
t.Fatal("validator ML-DSA key collides with service-auth ML-DSA key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invalid BIP-39 phrase is rejected before any derivation.
|
||||||
|
func TestDeriveValidatorPQ_InvalidMnemonic(t *testing.T) {
|
||||||
|
if _, err := DeriveValidatorPQ("not a valid bip39 phrase at all", 0); err == nil {
|
||||||
|
t.Fatal("expected error for invalid mnemonic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An out-of-range index is rejected (misconfiguration guard).
|
||||||
|
func TestDeriveValidatorPQ_IndexBounds(t *testing.T) {
|
||||||
|
if _, err := DeriveValidatorPQ(validMnemonic, maxValidatorIndex+1); err == nil {
|
||||||
|
t.Fatal("expected ErrInvalidAccountIndex for out-of-range index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wipe zeroes the private material.
|
||||||
|
func TestPQValidatorKey_Wipe(t *testing.T) {
|
||||||
|
k, err := DeriveValidatorPQ(validMnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derive: %v", err)
|
||||||
|
}
|
||||||
|
// Snapshot pub before wipe (pub must survive).
|
||||||
|
pub := append([]byte(nil), k.MLDSAPub...)
|
||||||
|
k.Wipe()
|
||||||
|
if k.MLDSAPriv != nil || k.MLKEMPriv != nil {
|
||||||
|
t.Fatal("Wipe did not nil the private slices")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(k.MLDSAPub, pub) {
|
||||||
|
t.Fatal("Wipe destroyed the public key (should be preserved)")
|
||||||
|
}
|
||||||
|
k.Wipe() // idempotent
|
||||||
|
}
|
||||||
+36
-11
@@ -161,8 +161,22 @@ type ServiceIdentity struct {
|
|||||||
PublicKey []byte
|
PublicKey []byte
|
||||||
|
|
||||||
// privateKey is the ML-DSA-65 private key bytes. Never exposed via
|
// privateKey is the ML-DSA-65 private key bytes. Never exposed via
|
||||||
// a getter; the only legal use is internal Sign().
|
// a getter; the only legal use is internal Sign() and the wiped-state
|
||||||
|
// sentinel.
|
||||||
privateKey []byte
|
privateKey []byte
|
||||||
|
|
||||||
|
// signer is the parsed ML-DSA-65 private key, cached ONCE at
|
||||||
|
// construction. Sign() reuses it instead of re-parsing s.privateKey
|
||||||
|
// on every call. This is deliberate and load-bearing: mldsa.
|
||||||
|
// PrivateKeyFromBytes stores its input slice by reference
|
||||||
|
// (secretKey: data) and arms a GC finalizer that Zeroize()s it. A
|
||||||
|
// per-call transient parse therefore ALIASES a byte slice whose
|
||||||
|
// finalizer, once the transient goes unreachable, zeroes the shared
|
||||||
|
// key mid-run — corrupting the next Sign (bites the CUSTODY+Save path
|
||||||
|
// that signs two envelopes per boot). Holding the parsed key on the
|
||||||
|
// struct keeps it reachable for the identity's whole lifetime, so the
|
||||||
|
// finalizer only fires at Wipe/teardown. Never exposed via a getter.
|
||||||
|
signer *mldsa.PrivateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServiceIdentity is the canonical constructor. mnemonic must be a
|
// NewServiceIdentity is the canonical constructor. mnemonic must be a
|
||||||
@@ -236,6 +250,10 @@ func NewServiceIdentity(mnemonic, servicePath string) (*ServiceIdentity, error)
|
|||||||
FullDigest: full,
|
FullDigest: full,
|
||||||
PublicKey: pubBytes,
|
PublicKey: pubBytes,
|
||||||
privateKey: privBytes,
|
privateKey: privBytes,
|
||||||
|
// Cache the freshly-generated key as the signer. Its secretKey is
|
||||||
|
// independent of privBytes (privBytes is a copy), and holding it on
|
||||||
|
// the struct keeps its Zeroize finalizer dormant until Wipe.
|
||||||
|
signer: priv,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,19 +272,19 @@ func NewServiceIdentity(mnemonic, servicePath string) (*ServiceIdentity, error)
|
|||||||
// rejects an envelope signed by a different identity — even a key with
|
// rejects an envelope signed by a different identity — even a key with
|
||||||
// the same NodeID prefix.
|
// the same NodeID prefix.
|
||||||
func (s *ServiceIdentity) Sign(envelope []byte) ([]byte, error) {
|
func (s *ServiceIdentity) Sign(envelope []byte) ([]byte, error) {
|
||||||
if s == nil || len(s.privateKey) == 0 {
|
if s == nil || s.signer == nil {
|
||||||
return nil, errors.New("keys: service identity is empty (wiped?)")
|
return nil, errors.New("keys: service identity is empty (wiped?)")
|
||||||
}
|
}
|
||||||
digest := envelopeDigest(s.FullDigest, envelope)
|
digest := envelopeDigest(s.FullDigest, envelope)
|
||||||
priv, err := mldsa.PrivateKeyFromBytes(mldsa.MLDSA65, s.privateKey)
|
// Reuse the cached signer — do NOT re-parse s.privateKey here. A
|
||||||
if err != nil {
|
// per-call PrivateKeyFromBytes builds a transient that aliases
|
||||||
return nil, fmt.Errorf("keys: parse private key: %w", err)
|
// s.privateKey and arms a Zeroize finalizer; once the transient goes
|
||||||
}
|
// unreachable the finalizer zeroes the shared key mid-run. See the
|
||||||
// FIPS 204 §5.2 hedged sign — circl reads its own randomness
|
// signer field doc. circl reads its own randomness for the FIPS 204
|
||||||
// internally. The EnvelopeDomain context byte string is bound
|
// §5.2 hedged sign; the EnvelopeDomain context binds the signature to
|
||||||
// into the signature so a cross-protocol replay of the same key
|
// this envelope shape so a cross-protocol replay of the same key
|
||||||
// against a different envelope shape rejects.
|
// against a different shape rejects.
|
||||||
sig, err := priv.SignCtx(nil, digest, []byte(EnvelopeDomain))
|
sig, err := s.signer.SignCtx(nil, digest, []byte(EnvelopeDomain))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("keys: sign: %w", err)
|
return nil, fmt.Errorf("keys: sign: %w", err)
|
||||||
}
|
}
|
||||||
@@ -307,6 +325,13 @@ func (s *ServiceIdentity) Wipe() {
|
|||||||
s.privateKey[i] = 0
|
s.privateKey[i] = 0
|
||||||
}
|
}
|
||||||
s.privateKey = nil
|
s.privateKey = nil
|
||||||
|
// Zeroize the cached signer's own key material (a distinct allocation
|
||||||
|
// from privateKey) and drop the reference so a subsequent Sign fails
|
||||||
|
// closed rather than signing with a zeroed key.
|
||||||
|
if s.signer != nil {
|
||||||
|
s.signer.Zeroize()
|
||||||
|
s.signer = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// envelopeDigest computes the canonical SHAKE256 digest a signature
|
// envelopeDigest computes the canonical SHAKE256 digest a signature
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||||
|
// See the file LICENSE for licensing terms.
|
||||||
|
|
||||||
|
package keys
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// svcMnemonic is the canonical BIP-39 all-zero-entropy English test vector.
|
||||||
|
const svcMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
|
// NewServiceIdentity is a pure function: same (mnemonic, path) → same NodeID.
|
||||||
|
func TestServiceIdentity_Deterministic(t *testing.T) {
|
||||||
|
a, err := NewServiceIdentity(svcMnemonic, "luxd/staking-bootstrap")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a: %v", err)
|
||||||
|
}
|
||||||
|
defer a.Wipe()
|
||||||
|
b, err := NewServiceIdentity(svcMnemonic, "luxd/staking-bootstrap")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("b: %v", err)
|
||||||
|
}
|
||||||
|
defer b.Wipe()
|
||||||
|
if a.NodeID != b.NodeID {
|
||||||
|
t.Fatal("service identity NodeID is not deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// L1 regression: Sign must NOT re-parse s.privateKey into a transient
|
||||||
|
// mldsa.PrivateKey on every call. That transient aliases s.privateKey and arms
|
||||||
|
// a Zeroize GC finalizer; when it goes unreachable the finalizer zeroes the
|
||||||
|
// SHARED key mid-run, so the NEXT Sign signs with (or over) a corrupted key.
|
||||||
|
// This is exactly the CUSTODY+Save boot path, which signs two envelopes (GetAt
|
||||||
|
// then PutAt) from one identity.
|
||||||
|
//
|
||||||
|
// The test signs, then aggressively drives GC + finalizers between signs, then
|
||||||
|
// signs again. With the cached-signer fix BOTH signatures are produced over the
|
||||||
|
// intact key and verify. With the aliasing regression, the second Sign operates
|
||||||
|
// on a zeroed key and the check fails.
|
||||||
|
func TestServiceIdentity_SignSurvivesGCBetweenCalls(t *testing.T) {
|
||||||
|
id, err := NewServiceIdentity(svcMnemonic, "hanzo/kms-operator")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
defer id.Wipe()
|
||||||
|
|
||||||
|
env1 := []byte("envelope-one: OpSecretGet")
|
||||||
|
sig1, err := id.Sign(env1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force any finalizer-bearing transient created during the first Sign to be
|
||||||
|
// collected and its finalizer to run. Under the regression this zeroes the
|
||||||
|
// shared key; under the fix there is no such transient.
|
||||||
|
forceFinalizers()
|
||||||
|
|
||||||
|
env2 := []byte("envelope-two: OpSecretPut")
|
||||||
|
sig2, err := id.Sign(env2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second sign after GC failed (key zeroed by aliased transient finalizer?): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := VerifyServiceEnvelope(id.PublicKey, id.FullDigest, env1, sig1); err != nil {
|
||||||
|
t.Fatalf("first signature did not verify (key corrupted mid-run): %v", err)
|
||||||
|
}
|
||||||
|
if err := VerifyServiceEnvelope(id.PublicKey, id.FullDigest, env2, sig2); err != nil {
|
||||||
|
t.Fatalf("second signature did not verify (key corrupted mid-run): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signing many more times across GC pressure must all verify — the cached
|
||||||
|
// signer is stable for the identity's whole lifetime.
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
forceFinalizers()
|
||||||
|
msg := []byte{byte(i), 'x'}
|
||||||
|
sig, serr := id.Sign(msg)
|
||||||
|
if serr != nil {
|
||||||
|
t.Fatalf("sign %d: %v", i, serr)
|
||||||
|
}
|
||||||
|
if verr := VerifyServiceEnvelope(id.PublicKey, id.FullDigest, msg, sig); verr != nil {
|
||||||
|
t.Fatalf("sign %d did not verify: %v", i, verr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After Wipe the identity fails closed: Sign returns an error rather than
|
||||||
|
// signing with a zeroed key, and the cached signer is dropped.
|
||||||
|
func TestServiceIdentity_WipeFailsClosed(t *testing.T) {
|
||||||
|
id, err := NewServiceIdentity(svcMnemonic, "luxd/staking-bootstrap")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := id.Sign([]byte("pre-wipe")); err != nil {
|
||||||
|
t.Fatalf("pre-wipe sign: %v", err)
|
||||||
|
}
|
||||||
|
id.Wipe()
|
||||||
|
if id.signer != nil {
|
||||||
|
t.Fatal("Wipe did not drop the cached signer")
|
||||||
|
}
|
||||||
|
if _, err := id.Sign([]byte("post-wipe")); err == nil {
|
||||||
|
t.Fatal("Sign after Wipe must fail closed")
|
||||||
|
}
|
||||||
|
id.Wipe() // idempotent
|
||||||
|
}
|
||||||
|
|
||||||
|
// forceFinalizers drives the GC and gives the finalizer goroutine a window to
|
||||||
|
// run. runtime.GC identifies unreachable objects; finalizers run asynchronously
|
||||||
|
// on a dedicated goroutine, so we loop with a brief yield to make their
|
||||||
|
// execution overwhelmingly likely within the test.
|
||||||
|
func forceFinalizers() {
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
runtime.GC()
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// Copyright (C) 2024-2026, Lux Industries Inc. All rights reserved.
|
||||||
|
// See the file LICENSE for licensing terms.
|
||||||
|
|
||||||
|
// staking_identity.go — the portable, complete staking identity of one node,
|
||||||
|
// plus its deterministic wire codec.
|
||||||
|
//
|
||||||
|
// A StakingIdentity bundles BOTH ChainSecurityProfiles' materials:
|
||||||
|
//
|
||||||
|
// classical : TLS cert/key (NodeID anchor + transport) + BLS signer (consensus)
|
||||||
|
// strict-PQ : ML-DSA-65 (NodeID anchor + sign) + ML-KEM-768 (handshake KEM)
|
||||||
|
//
|
||||||
|
// It is the unit a node CUSTODIES in KMS so a restart or replacement pod
|
||||||
|
// recovers the SAME NodeID under whichever profile the chain runs. Two ways to
|
||||||
|
// obtain the same stable identity:
|
||||||
|
//
|
||||||
|
// 1. derive it — StakingIdentityFromValidatorKey(DeriveValidatorFromMnemonic,
|
||||||
|
// DeriveValidatorPQ). Nothing to persist; re-derivable from the
|
||||||
|
// mnemonic. This is the canonical Lux "one seed, N paths" model.
|
||||||
|
// 2. custody it — generate once, Marshal, store the blob in KMS, Unmarshal on
|
||||||
|
// the next boot. For nodes that hold a unique, non-derived key.
|
||||||
|
//
|
||||||
|
// SEPARATION OF CONCERNS: this type owns ONLY (de)serialization — pure
|
||||||
|
// functions, no I/O, no KMS, no network. The KMS transport that carries a
|
||||||
|
// marshalled blob lives in the consumer (luxd staking-init), so luxfi/keys
|
||||||
|
// keeps NO edge to luxfi/kms. The module graph stays acyclic: kms → keys, never
|
||||||
|
// keys → kms. (kms/pkg/mnemonic already composes keys with the ZAP client on
|
||||||
|
// the far side of that edge.)
|
||||||
|
//
|
||||||
|
// INTEGRITY: Marshal appends a SHA-256 checksum over the framed body. This is a
|
||||||
|
// framing/corruption guard (truncation, bit-rot, wrong-blob) — NOT a keyed MAC
|
||||||
|
// and NOT confidentiality. The authenticated-encryption boundary is KMS itself
|
||||||
|
// (AES-256-GCM payload + ML-KEM-wrapped DEK, AAD-bound to path/name/env). The
|
||||||
|
// checksum is defense-in-depth on top of that, so a mangled blob is rejected
|
||||||
|
// rather than mis-parsed into a wrong (and unusable) NodeID.
|
||||||
|
|
||||||
|
package keys
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stakingIdentityMagic tags the wire format so a blob from another producer (or
|
||||||
|
// a raw mnemonic accidentally routed here) is rejected at byte 0.
|
||||||
|
var stakingIdentityMagic = []byte("LUXSTAKEID")
|
||||||
|
|
||||||
|
// stakingIdentityVersion is the only supported codec version. Bump on any wire
|
||||||
|
// change; Unmarshal refuses unknown versions rather than guessing. v2 adds the
|
||||||
|
// NodeLabel field (index 7) — an operator-supplied label bound INTO the
|
||||||
|
// integrity-checksummed blob so a stored custody identity is tied to one node
|
||||||
|
// and a second node pointed at the same KMS path detects the mismatch on load
|
||||||
|
// (equivocation tripwire; the primary control is server-side path-scoped write
|
||||||
|
// authz). Forward-only: there is no v1 fallback.
|
||||||
|
const stakingIdentityVersion byte = 2
|
||||||
|
|
||||||
|
// stakingIdentityFieldCount is the fixed number of length-prefixed fields.
|
||||||
|
const stakingIdentityFieldCount = 8
|
||||||
|
|
||||||
|
// maxStakingField caps any single field at 64 KiB. ML-DSA-65 private keys are
|
||||||
|
// ~4 KB and TLS PEM a few KB, so 64 KiB is generous; the cap turns a hostile or
|
||||||
|
// corrupt length prefix into a bounded rejection instead of a huge allocation.
|
||||||
|
const maxStakingField = 64 * 1024
|
||||||
|
|
||||||
|
// ErrStakingIdentityCodec is the base error for every malformed-blob rejection.
|
||||||
|
// Wrapped with a specific cause; match with errors.Is.
|
||||||
|
var ErrStakingIdentityCodec = errors.New("keys: staking identity codec")
|
||||||
|
|
||||||
|
// StakingIdentity is the complete staking identity of one node. Any field may
|
||||||
|
// be empty (a classical-only or strict-PQ-only custody); Marshal preserves
|
||||||
|
// exactly what is set and Unmarshal restores it byte-for-byte.
|
||||||
|
type StakingIdentity struct {
|
||||||
|
// Classical.
|
||||||
|
TLSCertPEM []byte // PEM-encoded staking certificate (public — NodeID anchor)
|
||||||
|
TLSKeyPEM []byte // PEM-encoded staking private key (SECRET)
|
||||||
|
BLSSigner []byte // raw BLS secret key bytes (SECRET)
|
||||||
|
|
||||||
|
// Strict-PQ.
|
||||||
|
MLDSAPriv []byte // ML-DSA-65 private key (SECRET)
|
||||||
|
MLDSAPub []byte // ML-DSA-65 public key (public — strict-PQ NodeID anchor)
|
||||||
|
MLKEMPriv []byte // ML-KEM-768 private key (SECRET)
|
||||||
|
MLKEMPub []byte // ML-KEM-768 public key (public — handshake)
|
||||||
|
|
||||||
|
// Custody binding. NodeLabel is an operator-supplied label (e.g. the
|
||||||
|
// node's hostname or fleet index) bound into the blob so a CUSTODY load
|
||||||
|
// can reject an identity minted for a DIFFERENT node — the equivocation
|
||||||
|
// tripwire for two nodes accidentally sharing one KMS identity path.
|
||||||
|
// Public, not secret; empty for DERIVE identities (which are re-derivable,
|
||||||
|
// never stored, and so carry no shared-path risk). Preserved verbatim by
|
||||||
|
// the codec.
|
||||||
|
NodeLabel []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// fields returns the seven field slices in canonical, fixed order. The order is
|
||||||
|
// the wire order and MUST never be reordered without a version bump.
|
||||||
|
func (s *StakingIdentity) fields() [][]byte {
|
||||||
|
return [][]byte{
|
||||||
|
s.TLSCertPEM,
|
||||||
|
s.TLSKeyPEM,
|
||||||
|
s.BLSSigner,
|
||||||
|
s.MLDSAPriv,
|
||||||
|
s.MLDSAPub,
|
||||||
|
s.MLKEMPriv,
|
||||||
|
s.MLKEMPub,
|
||||||
|
s.NodeLabel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasClassical reports whether the classical materials needed to run a
|
||||||
|
// classical-compat validator are present (TLS cert + key + BLS signer).
|
||||||
|
func (s *StakingIdentity) HasClassical() bool {
|
||||||
|
return len(s.TLSCertPEM) > 0 && len(s.TLSKeyPEM) > 0 && len(s.BLSSigner) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasStrictPQ reports whether the strict-PQ materials needed to run a strict-PQ
|
||||||
|
// validator are present (ML-DSA-65 pair + ML-KEM-768 pair).
|
||||||
|
func (s *StakingIdentity) HasStrictPQ() bool {
|
||||||
|
return len(s.MLDSAPriv) > 0 && len(s.MLDSAPub) > 0 &&
|
||||||
|
len(s.MLKEMPriv) > 0 && len(s.MLKEMPub) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal serializes the identity into the canonical wire blob:
|
||||||
|
//
|
||||||
|
// magic("LUXSTAKEID") || version(1) || fieldCount(1) ||
|
||||||
|
// { u32 len || bytes } × 8 (canonical order, NodeLabel last) ||
|
||||||
|
// sha256(everything above) // 32-byte integrity tail
|
||||||
|
//
|
||||||
|
// Deterministic: the same StakingIdentity always marshals to identical bytes,
|
||||||
|
// so a save→load round-trip and a hash-compare are stable.
|
||||||
|
func (s *StakingIdentity) Marshal() ([]byte, error) {
|
||||||
|
fields := s.fields()
|
||||||
|
if len(fields) != stakingIdentityFieldCount {
|
||||||
|
return nil, fmt.Errorf("%w: field count %d != %d",
|
||||||
|
ErrStakingIdentityCodec, len(fields), stakingIdentityFieldCount)
|
||||||
|
}
|
||||||
|
body := make([]byte, 0, 64)
|
||||||
|
body = append(body, stakingIdentityMagic...)
|
||||||
|
body = append(body, stakingIdentityVersion, byte(stakingIdentityFieldCount))
|
||||||
|
var lenBuf [4]byte
|
||||||
|
for _, f := range fields {
|
||||||
|
if len(f) > maxStakingField {
|
||||||
|
return nil, fmt.Errorf("%w: field length %d exceeds cap %d",
|
||||||
|
ErrStakingIdentityCodec, len(f), maxStakingField)
|
||||||
|
}
|
||||||
|
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(f)))
|
||||||
|
body = append(body, lenBuf[:]...)
|
||||||
|
body = append(body, f...)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(body)
|
||||||
|
return append(body, sum[:]...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalStakingIdentity parses a blob produced by Marshal, verifying the
|
||||||
|
// magic, version, field count, per-field length caps, and the trailing SHA-256
|
||||||
|
// checksum (constant-time compare) before returning any field. A checksum
|
||||||
|
// mismatch, truncation, trailing garbage, or oversize field is a hard error —
|
||||||
|
// the node fails closed rather than boot with a half-parsed identity.
|
||||||
|
func UnmarshalStakingIdentity(blob []byte) (*StakingIdentity, error) {
|
||||||
|
if len(blob) < len(stakingIdentityMagic)+2+sha256.Size {
|
||||||
|
return nil, fmt.Errorf("%w: blob too short (%d bytes)", ErrStakingIdentityCodec, len(blob))
|
||||||
|
}
|
||||||
|
// Split body || checksum and verify integrity FIRST (constant-time).
|
||||||
|
body := blob[:len(blob)-sha256.Size]
|
||||||
|
got := blob[len(blob)-sha256.Size:]
|
||||||
|
want := sha256.Sum256(body)
|
||||||
|
if subtle.ConstantTimeCompare(got, want[:]) != 1 {
|
||||||
|
return nil, fmt.Errorf("%w: integrity checksum mismatch", ErrStakingIdentityCodec)
|
||||||
|
}
|
||||||
|
|
||||||
|
off := 0
|
||||||
|
if subtle.ConstantTimeCompare(body[:len(stakingIdentityMagic)], stakingIdentityMagic) != 1 {
|
||||||
|
return nil, fmt.Errorf("%w: bad magic", ErrStakingIdentityCodec)
|
||||||
|
}
|
||||||
|
off += len(stakingIdentityMagic)
|
||||||
|
if body[off] != stakingIdentityVersion {
|
||||||
|
return nil, fmt.Errorf("%w: unsupported version %d", ErrStakingIdentityCodec, body[off])
|
||||||
|
}
|
||||||
|
off++
|
||||||
|
if body[off] != byte(stakingIdentityFieldCount) {
|
||||||
|
return nil, fmt.Errorf("%w: field count %d != %d", ErrStakingIdentityCodec, body[off], stakingIdentityFieldCount)
|
||||||
|
}
|
||||||
|
off++
|
||||||
|
|
||||||
|
out := make([][]byte, stakingIdentityFieldCount)
|
||||||
|
for i := 0; i < stakingIdentityFieldCount; i++ {
|
||||||
|
if off+4 > len(body) {
|
||||||
|
return nil, fmt.Errorf("%w: truncated length prefix (field %d)", ErrStakingIdentityCodec, i)
|
||||||
|
}
|
||||||
|
n := binary.BigEndian.Uint32(body[off : off+4])
|
||||||
|
off += 4
|
||||||
|
if n > maxStakingField {
|
||||||
|
return nil, fmt.Errorf("%w: field %d length %d exceeds cap %d", ErrStakingIdentityCodec, i, n, maxStakingField)
|
||||||
|
}
|
||||||
|
if off+int(n) > len(body) {
|
||||||
|
return nil, fmt.Errorf("%w: truncated field %d (need %d bytes)", ErrStakingIdentityCodec, i, n)
|
||||||
|
}
|
||||||
|
// Copy out of the caller's buffer so the returned identity owns its
|
||||||
|
// memory (and the caller can wipe/reuse the input blob).
|
||||||
|
out[i] = append([]byte(nil), body[off:off+int(n)]...)
|
||||||
|
off += int(n)
|
||||||
|
}
|
||||||
|
if off != len(body) {
|
||||||
|
return nil, fmt.Errorf("%w: %d trailing bytes after last field", ErrStakingIdentityCodec, len(body)-off)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StakingIdentity{
|
||||||
|
TLSCertPEM: out[0],
|
||||||
|
TLSKeyPEM: out[1],
|
||||||
|
BLSSigner: out[2],
|
||||||
|
MLDSAPriv: out[3],
|
||||||
|
MLDSAPub: out[4],
|
||||||
|
MLKEMPriv: out[5],
|
||||||
|
MLKEMPub: out[6],
|
||||||
|
NodeLabel: out[7],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wipe zeroes every SECRET field in place (TLS key, BLS signer, ML-DSA private,
|
||||||
|
// ML-KEM private). Public materials are left intact — they are not sensitive
|
||||||
|
// and the caller may still need the NodeID anchors for logging. Idempotent;
|
||||||
|
// safe on nil.
|
||||||
|
func (s *StakingIdentity) Wipe() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
zero(s.TLSKeyPEM)
|
||||||
|
zero(s.BLSSigner)
|
||||||
|
zero(s.MLDSAPriv)
|
||||||
|
zero(s.MLKEMPriv)
|
||||||
|
s.TLSKeyPEM = nil
|
||||||
|
s.BLSSigner = nil
|
||||||
|
s.MLDSAPriv = nil
|
||||||
|
s.MLKEMPriv = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StakingIdentityFromValidatorKey assembles a StakingIdentity from the classical
|
||||||
|
// ValidatorKey (DeriveValidatorFromMnemonic / GenerateValidatorKey) and, when
|
||||||
|
// non-nil, the strict-PQ PQValidatorKey (DeriveValidatorPQ). Pure — it copies
|
||||||
|
// the referenced key bytes; the sources may be wiped afterward.
|
||||||
|
func StakingIdentityFromValidatorKey(vk *ValidatorKey, pq *PQValidatorKey) *StakingIdentity {
|
||||||
|
s := &StakingIdentity{}
|
||||||
|
if vk != nil {
|
||||||
|
s.TLSCertPEM = append([]byte(nil), vk.StakerCert...)
|
||||||
|
s.TLSKeyPEM = append([]byte(nil), vk.StakerKey...)
|
||||||
|
s.BLSSigner = append([]byte(nil), vk.BLSSecretKey...)
|
||||||
|
}
|
||||||
|
if pq != nil {
|
||||||
|
s.MLDSAPriv = append([]byte(nil), pq.MLDSAPriv...)
|
||||||
|
s.MLDSAPub = append([]byte(nil), pq.MLDSAPub...)
|
||||||
|
s.MLKEMPriv = append([]byte(nil), pq.MLKEMPriv...)
|
||||||
|
s.MLKEMPub = append([]byte(nil), pq.MLKEMPub...)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Copyright (C) 2024-2026, Lux Industries Inc. All rights reserved.
|
||||||
|
// See the file LICENSE for licensing terms.
|
||||||
|
|
||||||
|
package keys
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sample returns a fully-populated StakingIdentity with recognisable field
|
||||||
|
// bytes so a round-trip can assert exact preservation.
|
||||||
|
func sample() *StakingIdentity {
|
||||||
|
return &StakingIdentity{
|
||||||
|
TLSCertPEM: []byte("-----BEGIN CERTIFICATE-----\ncert\n-----END CERTIFICATE-----"),
|
||||||
|
TLSKeyPEM: []byte("-----BEGIN PRIVATE KEY-----\ntlskey\n-----END PRIVATE KEY-----"),
|
||||||
|
BLSSigner: bytes.Repeat([]byte{0xB1}, 32),
|
||||||
|
MLDSAPriv: bytes.Repeat([]byte{0xD5}, 4032),
|
||||||
|
MLDSAPub: bytes.Repeat([]byte{0xD6}, 1952),
|
||||||
|
MLKEMPriv: bytes.Repeat([]byte{0xE7}, 2400),
|
||||||
|
MLKEMPub: bytes.Repeat([]byte{0xE8}, 1184),
|
||||||
|
NodeLabel: []byte("luxd-validator-2"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalIdentity(a, b *StakingIdentity) bool {
|
||||||
|
return bytes.Equal(a.TLSCertPEM, b.TLSCertPEM) &&
|
||||||
|
bytes.Equal(a.TLSKeyPEM, b.TLSKeyPEM) &&
|
||||||
|
bytes.Equal(a.BLSSigner, b.BLSSigner) &&
|
||||||
|
bytes.Equal(a.MLDSAPriv, b.MLDSAPriv) &&
|
||||||
|
bytes.Equal(a.MLDSAPub, b.MLDSAPub) &&
|
||||||
|
bytes.Equal(a.MLKEMPriv, b.MLKEMPriv) &&
|
||||||
|
bytes.Equal(a.MLKEMPub, b.MLKEMPub) &&
|
||||||
|
bytes.Equal(a.NodeLabel, b.NodeLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal → Unmarshal preserves every field byte-for-byte, and Marshal is
|
||||||
|
// deterministic (same identity → identical blob).
|
||||||
|
func TestStakingIdentity_RoundTrip(t *testing.T) {
|
||||||
|
in := sample()
|
||||||
|
blob, err := in.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
blob2, err := in.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal 2: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(blob, blob2) {
|
||||||
|
t.Fatal("Marshal is not deterministic")
|
||||||
|
}
|
||||||
|
out, err := UnmarshalStakingIdentity(blob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if !equalIdentity(in, out) {
|
||||||
|
t.Fatal("round-trip did not preserve all fields")
|
||||||
|
}
|
||||||
|
if !out.HasClassical() || !out.HasStrictPQ() {
|
||||||
|
t.Fatal("expected both profiles present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The v2 NodeLabel field round-trips verbatim, is covered by the integrity
|
||||||
|
// checksum, and an empty label (the DERIVE case) round-trips too.
|
||||||
|
func TestStakingIdentity_NodeLabel(t *testing.T) {
|
||||||
|
in := sample()
|
||||||
|
in.NodeLabel = []byte("luxd-mainnet-node-07")
|
||||||
|
blob, err := in.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
out, err := UnmarshalStakingIdentity(blob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if string(out.NodeLabel) != "luxd-mainnet-node-07" {
|
||||||
|
t.Fatalf("NodeLabel not preserved: %q", out.NodeLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tampering with the label bytes is rejected by the checksum — the label
|
||||||
|
// cannot be swapped to point one node's blob at another node's label
|
||||||
|
// without failing integrity. The label lives just before the 32-byte tail.
|
||||||
|
bad := append([]byte(nil), blob...)
|
||||||
|
bad[len(bad)-sha256.Size-1] ^= 0xFF
|
||||||
|
if _, err := UnmarshalStakingIdentity(bad); err == nil {
|
||||||
|
t.Fatal("tampered NodeLabel was not rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty label (DERIVE identities carry none) round-trips.
|
||||||
|
noLabel := sample()
|
||||||
|
noLabel.NodeLabel = nil
|
||||||
|
nb, err := noLabel.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal no-label: %v", err)
|
||||||
|
}
|
||||||
|
back, err := UnmarshalStakingIdentity(nb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unmarshal no-label: %v", err)
|
||||||
|
}
|
||||||
|
if len(back.NodeLabel) != 0 {
|
||||||
|
t.Fatalf("expected empty NodeLabel, got %q", back.NodeLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty fields (classical-only, strict-PQ-only) round-trip exactly.
|
||||||
|
func TestStakingIdentity_PartialProfiles(t *testing.T) {
|
||||||
|
classicalOnly := &StakingIdentity{
|
||||||
|
TLSCertPEM: []byte("cert"), TLSKeyPEM: []byte("key"), BLSSigner: []byte("bls"),
|
||||||
|
}
|
||||||
|
pqOnly := &StakingIdentity{
|
||||||
|
MLDSAPriv: []byte("dp"), MLDSAPub: []byte("dP"),
|
||||||
|
MLKEMPriv: []byte("kp"), MLKEMPub: []byte("kP"),
|
||||||
|
}
|
||||||
|
for name, in := range map[string]*StakingIdentity{"classical": classicalOnly, "pq": pqOnly} {
|
||||||
|
blob, err := in.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s marshal: %v", name, err)
|
||||||
|
}
|
||||||
|
out, err := UnmarshalStakingIdentity(blob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s unmarshal: %v", name, err)
|
||||||
|
}
|
||||||
|
if !equalIdentity(in, out) {
|
||||||
|
t.Fatalf("%s round-trip mismatch", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !classicalOnly.HasClassical() || classicalOnly.HasStrictPQ() {
|
||||||
|
t.Fatal("classicalOnly profile flags wrong")
|
||||||
|
}
|
||||||
|
if pqOnly.HasClassical() || !pqOnly.HasStrictPQ() {
|
||||||
|
t.Fatal("pqOnly profile flags wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single flipped byte anywhere in the blob is rejected by the integrity
|
||||||
|
// checksum — the node fails closed rather than boot a half-parsed identity.
|
||||||
|
func TestStakingIdentity_TamperRejected(t *testing.T) {
|
||||||
|
blob, err := sample().Marshal()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
for _, pos := range []int{0, 11, 20, len(blob) / 2, len(blob) - 1} {
|
||||||
|
bad := append([]byte(nil), blob...)
|
||||||
|
bad[pos] ^= 0xFF
|
||||||
|
if _, err := UnmarshalStakingIdentity(bad); err == nil {
|
||||||
|
t.Fatalf("flip at %d was not rejected", pos)
|
||||||
|
} else if !errors.Is(err, ErrStakingIdentityCodec) {
|
||||||
|
t.Fatalf("flip at %d: wrong error type: %v", pos, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncation is rejected (short blob and body cut before the checksum).
|
||||||
|
func TestStakingIdentity_TruncationRejected(t *testing.T) {
|
||||||
|
blob, _ := sample().Marshal()
|
||||||
|
for _, cut := range []int{0, 5, len(blob) - 40, len(blob) - 1} {
|
||||||
|
if cut < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := UnmarshalStakingIdentity(blob[:cut]); err == nil {
|
||||||
|
t.Fatalf("truncation to %d bytes was not rejected", cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trailing garbage after a valid blob is rejected (checksum covers exact body).
|
||||||
|
func TestStakingIdentity_TrailingGarbageRejected(t *testing.T) {
|
||||||
|
blob, _ := sample().Marshal()
|
||||||
|
if _, err := UnmarshalStakingIdentity(append(blob, 0x00)); err == nil {
|
||||||
|
t.Fatal("trailing byte was not rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hostile oversize length prefix is rejected before allocation.
|
||||||
|
func TestStakingIdentity_OversizeFieldRejected(t *testing.T) {
|
||||||
|
big := &StakingIdentity{TLSCertPEM: bytes.Repeat([]byte{1}, maxStakingField+1)}
|
||||||
|
if _, err := big.Marshal(); err == nil {
|
||||||
|
t.Fatal("Marshal accepted an oversize field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blob with the wrong magic is rejected even if its checksum is valid.
|
||||||
|
func TestStakingIdentity_BadMagicRejected(t *testing.T) {
|
||||||
|
blob, _ := sample().Marshal()
|
||||||
|
// Corrupt magic, then re-checksum so only the magic check can catch it.
|
||||||
|
bad := append([]byte(nil), blob...)
|
||||||
|
bad[0] = 'X'
|
||||||
|
// Recompute checksum over the tampered body so integrity passes.
|
||||||
|
body := bad[:len(bad)-32]
|
||||||
|
if _, err := UnmarshalStakingIdentity(reChecksum(body)); err == nil {
|
||||||
|
t.Fatal("bad magic with valid checksum was not rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wipe zeroes secrets, preserves public materials.
|
||||||
|
func TestStakingIdentity_Wipe(t *testing.T) {
|
||||||
|
in := sample()
|
||||||
|
pub := append([]byte(nil), in.MLDSAPub...)
|
||||||
|
in.Wipe()
|
||||||
|
if in.TLSKeyPEM != nil || in.BLSSigner != nil || in.MLDSAPriv != nil || in.MLKEMPriv != nil {
|
||||||
|
t.Fatal("Wipe left a secret slice non-nil")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(in.MLDSAPub, pub) || len(in.TLSCertPEM) == 0 || len(in.MLKEMPub) == 0 {
|
||||||
|
t.Fatal("Wipe destroyed public material")
|
||||||
|
}
|
||||||
|
in.Wipe() // idempotent
|
||||||
|
}
|
||||||
|
|
||||||
|
// StakingIdentityFromValidatorKey copies source bytes (wiping the source after
|
||||||
|
// does not corrupt the bundle).
|
||||||
|
func TestStakingIdentityFromValidatorKey_Copies(t *testing.T) {
|
||||||
|
pq, err := DeriveValidatorPQ(validMnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derive pq: %v", err)
|
||||||
|
}
|
||||||
|
vk := &ValidatorKey{
|
||||||
|
StakerCert: []byte("cert"),
|
||||||
|
StakerKey: []byte("key"),
|
||||||
|
BLSSecretKey: []byte("bls"),
|
||||||
|
}
|
||||||
|
bundle := StakingIdentityFromValidatorKey(vk, pq)
|
||||||
|
pqPub := append([]byte(nil), pq.MLDSAPub...)
|
||||||
|
pq.Wipe()
|
||||||
|
if !bytes.Equal(bundle.MLDSAPub, pqPub) {
|
||||||
|
t.Fatal("bundle shares memory with source (source wipe leaked)")
|
||||||
|
}
|
||||||
|
if !bundle.HasClassical() || !bundle.HasStrictPQ() {
|
||||||
|
t.Fatal("bundle missing a profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reChecksum recomputes the SHA-256 tail for a hand-tampered body so a test can
|
||||||
|
// isolate a non-integrity check (e.g. magic/version) from the checksum guard.
|
||||||
|
func reChecksum(body []byte) []byte {
|
||||||
|
sum := sha256.Sum256(body)
|
||||||
|
return append(append([]byte(nil), body...), sum[:]...)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user