mirror of
https://github.com/luxfi/keys.git
synced 2026-07-26 23:58:11 +00:00
257 lines
10 KiB
Go
257 lines
10 KiB
Go
// 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
|
|||
|
|
}
|