Files

257 lines
10 KiB
Go
Raw Permalink Normal View History

2026-07-26 10:13:48 -07:00
// 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
}