diff --git a/go.mod b/go.mod index 2d4541f..295ce63 100644 --- a/go.mod +++ b/go.mod @@ -16,9 +16,11 @@ require ( require ( 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/decred/dcrd/dcrec/secp256k1/v4 v4.4.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/luxfi/accel v1.2.4 // 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/math v1.5.1 // 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/mock v0.1.1 // indirect github.com/luxfi/sampler v1.1.0 // 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 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 gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/pq_validator.go b/pq_validator.go new file mode 100644 index 0000000..64f1a53 --- /dev/null +++ b/pq_validator.go @@ -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/ +// ML-KEM-768: m/44'/60'/4'/0/ +// +// 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'/'/0/. + 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 + } +} diff --git a/pq_validator_test.go b/pq_validator_test.go new file mode 100644 index 0000000..0ac30f1 --- /dev/null +++ b/pq_validator_test.go @@ -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 +} diff --git a/service_identity.go b/service_identity.go index 85d014e..d15d99c 100644 --- a/service_identity.go +++ b/service_identity.go @@ -161,8 +161,22 @@ type ServiceIdentity struct { PublicKey []byte // 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 + + // 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 @@ -236,6 +250,10 @@ func NewServiceIdentity(mnemonic, servicePath string) (*ServiceIdentity, error) FullDigest: full, PublicKey: pubBytes, 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 } @@ -254,19 +272,19 @@ func NewServiceIdentity(mnemonic, servicePath string) (*ServiceIdentity, error) // rejects an envelope signed by a different identity — even a key with // the same NodeID prefix. 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?)") } digest := envelopeDigest(s.FullDigest, envelope) - priv, err := mldsa.PrivateKeyFromBytes(mldsa.MLDSA65, s.privateKey) - if err != nil { - return nil, fmt.Errorf("keys: parse private key: %w", err) - } - // FIPS 204 §5.2 hedged sign — circl reads its own randomness - // internally. The EnvelopeDomain context byte string is bound - // into the signature so a cross-protocol replay of the same key - // against a different envelope shape rejects. - sig, err := priv.SignCtx(nil, digest, []byte(EnvelopeDomain)) + // Reuse the cached signer — do NOT re-parse s.privateKey here. A + // per-call PrivateKeyFromBytes builds a transient that aliases + // s.privateKey and arms a Zeroize finalizer; once the transient goes + // unreachable the finalizer zeroes the shared key mid-run. See the + // signer field doc. circl reads its own randomness for the FIPS 204 + // §5.2 hedged sign; the EnvelopeDomain context binds the signature to + // this envelope shape so a cross-protocol replay of the same key + // against a different shape rejects. + sig, err := s.signer.SignCtx(nil, digest, []byte(EnvelopeDomain)) if err != nil { return nil, fmt.Errorf("keys: sign: %w", err) } @@ -307,6 +325,13 @@ func (s *ServiceIdentity) Wipe() { s.privateKey[i] = 0 } 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 diff --git a/service_identity_test.go b/service_identity_test.go new file mode 100644 index 0000000..d964813 --- /dev/null +++ b/service_identity_test.go @@ -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) + } +} diff --git a/staking_identity.go b/staking_identity.go new file mode 100644 index 0000000..88a22f7 --- /dev/null +++ b/staking_identity.go @@ -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 +} diff --git a/staking_identity_test.go b/staking_identity_test.go new file mode 100644 index 0000000..53834c0 --- /dev/null +++ b/staking_identity_test.go @@ -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[:]...) +}