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:
zeekay
2026-07-26 10:19:07 -07:00
parent bd35f7be35
commit 376dd086c3
7 changed files with 1054 additions and 13 deletions
+8 -2
View File
@@ -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
View File
@@ -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
}
}
+177
View File
@@ -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
View File
@@ -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
+118
View File
@@ -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)
}
}
+256
View File
@@ -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
}
+240
View File
@@ -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[:]...)
}