Files
node/config/staking_mldsa_test.go
zeekay 7cf87b230b node-unified is the node
The module already called itself github.com/luxfi/node while containing no node
in Go: cmd/luxd was twenty-one lines that printed the size of a few types, and
the Go column of the matrix measured a VM plugin. A repo that fetches the thing
it claims to be has not replaced it, so the Go sources move in — 1515 files,
the daemon and everything under it — and the build reaches outside this module
for nothing.

Rust and C++ sit beside it unchanged and are still measured against it by the
differential. That is the point of one repo: the reference implementation and
the two ports are the same checkout, so parity is a thing you can run rather
than a thing you assert.

Verified: builds, and the closure carries no lux-private — checked at build time
rather than believed. The daemon starts twelve chains on a local network
(P X C Q Z A B G K M F, plus D through its plugin) and reports healthy.
2026-09-07 16:15:25 -07:00

184 lines
7.1 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package config
import (
"crypto/rand"
"encoding/base64"
"encoding/pem"
"os"
"path/filepath"
"testing"
"github.com/luxfi/crypto/mldsa"
"github.com/stretchr/testify/require"
)
// writeMLDSAFixture generates an ML-DSA-65 keypair, PEM-encodes both halves
// with the block types loadStakingMLDSA expects, writes them under dir, and
// returns (privPath, pubPath, privPEM, pubPEM).
func writeMLDSAFixture(t *testing.T, dir string) (privPath, pubPath string, privPEM, pubPEM []byte) {
t.Helper()
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(t, err)
privPEM = pem.EncodeToMemory(&pem.Block{Type: "ML-DSA-65 PRIVATE KEY", Bytes: priv.Bytes()})
pubPEM = pem.EncodeToMemory(&pem.Block{Type: "ML-DSA-65 PUBLIC KEY", Bytes: priv.PublicKey.Bytes()})
require.NoError(t, os.MkdirAll(dir, 0o755))
privPath = filepath.Join(dir, "mldsa.key")
pubPath = filepath.Join(dir, "mldsa.pub")
require.NoError(t, os.WriteFile(privPath, privPEM, 0o600))
require.NoError(t, os.WriteFile(pubPath, pubPEM, 0o644))
return privPath, pubPath, privPEM, pubPEM
}
// TestLoadStakingMLDSA_PathForm: both keys via explicit --staking-mldsa-*-file
// paths. The canonical strict-PQ deploy form.
func TestLoadStakingMLDSA_PathForm(t *testing.T) {
dir := t.TempDir()
privPath, pubPath, _, _ := writeMLDSAFixture(t, dir)
v, err := BuildViper(BuildFlagSet(), []string{
"--" + StakingMLDSAKeyPathKey + "=" + privPath,
"--" + StakingMLDSAPubKeyPathKey + "=" + pubPath,
})
require.NoError(t, err)
priv, pub, _, _, err := loadStakingMLDSA(v)
require.NoError(t, err)
require.NotNil(t, priv)
require.NotEmpty(t, pub)
}
// TestLoadStakingMLDSA_ContentForm: keys via base64 PEM --*-content flags.
func TestLoadStakingMLDSA_ContentForm(t *testing.T) {
dir := t.TempDir()
_, _, privPEM, pubPEM := writeMLDSAFixture(t, dir)
v, err := BuildViper(BuildFlagSet(), []string{
"--" + StakingMLDSAKeyContentKey + "=" + base64.StdEncoding.EncodeToString(privPEM),
"--" + StakingMLDSAPubKeyContentKey + "=" + base64.StdEncoding.EncodeToString(pubPEM),
})
require.NoError(t, err)
priv, pub, _, _, err := loadStakingMLDSA(v)
require.NoError(t, err)
require.NotNil(t, priv)
require.NotEmpty(t, pub)
}
// TestLoadStakingMLDSA_EmptyContentFallsThroughToPath pins the fall-through:
// when a deploy passes the *-content flag blank (e.g. an env/template that
// rendered to "") alongside a valid *-file path, the loader MUST consult the
// path. Short-circuiting on the empty content value returns no key, so
// StakingMLDSAPub stays empty, IsStrictPQ() is false, and the node boots under
// an ECDSA NodeID with no error — a strict-PQ validator silently degraded to
// classical-compat.
func TestLoadStakingMLDSA_EmptyContentFallsThroughToPath(t *testing.T) {
dir := t.TempDir()
privPath, pubPath, _, _ := writeMLDSAFixture(t, dir)
v, err := BuildViper(BuildFlagSet(), []string{
"--" + StakingMLDSAKeyPathKey + "=" + privPath,
"--" + StakingMLDSAKeyContentKey + "=", // blank content
"--" + StakingMLDSAPubKeyPathKey + "=" + pubPath,
"--" + StakingMLDSAPubKeyContentKey + "=", // blank content
})
require.NoError(t, err)
priv, pub, gotPrivPath, gotPubPath, err := loadStakingMLDSA(v)
require.NoError(t, err)
require.NotNil(t, priv, "blank content must fall through to the path")
require.NotEmpty(t, pub, "blank content must fall through to the path")
require.Equal(t, privPath, gotPrivPath)
require.Equal(t, pubPath, gotPubPath)
}
// TestLoadStakingMLDSA_NothingSuppliedMints: a node handed no staking identity
// makes one, at the default paths, exactly as it does for the classical TLS
// keypair. This is the ordinary first boot. Under a strict-PQ profile the
// network layer refuses to start without this key, so minting it here is what
// lets a fresh node come up instead of stopping to ask for `pqkeygen`.
func TestLoadStakingMLDSA_NothingSuppliedMints(t *testing.T) {
// Point DataDir at an empty dir so the default paths resolve under it
// rather than picking up a developer's ~/.lux key.
v, err := BuildViper(BuildFlagSet(), []string{
"--" + DataDirKey + "=" + t.TempDir(),
})
require.NoError(t, err)
priv, pub, privPath, pubPath, err := loadStakingMLDSA(v)
require.NoError(t, err)
require.NotNil(t, priv)
require.NotEmpty(t, pub)
require.FileExists(t, privPath)
require.FileExists(t, pubPath)
}
// TestLoadStakingMLDSA_MintSurvivesRestart: the mint happens once. The NodeID
// of a strict-PQ node is derived from this key, so a second boot that minted
// again would hand the node a new identity and leave its stake behind on the
// old one. The key on disk wins every time after the first.
func TestLoadStakingMLDSA_MintSurvivesRestart(t *testing.T) {
dir := t.TempDir()
boot := func() ([]byte, []byte) {
v, err := BuildViper(BuildFlagSet(), []string{"--" + DataDirKey + "=" + dir})
require.NoError(t, err)
_, pub, privPath, _, err := loadStakingMLDSA(v)
require.NoError(t, err)
onDisk, err := os.ReadFile(privPath)
require.NoError(t, err)
return pub, onDisk
}
firstPub, firstPriv := boot()
secondPub, secondPriv := boot()
require.Equal(t, firstPub, secondPub, "a restart must not change the NodeID")
require.Equal(t, firstPriv, secondPriv, "a restart must not rewrite the key")
}
// TestLoadStakingMLDSA_NamedButMissingDoesNotMint: an operator who names a key
// is holding it. If the path is empty the answer is not to invent a different
// key there — that would swap a validator's identity for a fresh one and file
// it under success. The loader stands down and reports nothing, which leaves a
// strict-PQ node to refuse at the network layer and say `pqkeygen`.
func TestLoadStakingMLDSA_NamedButMissingDoesNotMint(t *testing.T) {
dir := t.TempDir()
privPath := filepath.Join(dir, "held-elsewhere.key")
pubPath := filepath.Join(dir, "held-elsewhere.pub")
v, err := BuildViper(BuildFlagSet(), []string{
"--" + DataDirKey + "=" + dir,
"--" + StakingMLDSAKeyPathKey + "=" + privPath,
"--" + StakingMLDSAPubKeyPathKey + "=" + pubPath,
})
require.NoError(t, err)
priv, pub, _, _, err := loadStakingMLDSA(v)
require.NoError(t, err)
require.Nil(t, priv)
require.Empty(t, pub)
require.NoFileExists(t, privPath, "a named key must never be minted over")
require.NoFileExists(t, pubPath, "a named key must never be minted over")
}
// TestLoadStakingMLDSA_PrivOnlyIsFatal: a private key with no public key is a
// loud config error — a strict-PQ validator must never silently degrade.
func TestLoadStakingMLDSA_PrivOnlyIsFatal(t *testing.T) {
base := t.TempDir()
_, _, privPEM, _ := writeMLDSAFixture(t, filepath.Join(base, "staking"))
v, err := BuildViper(BuildFlagSet(), []string{
"--" + DataDirKey + "=" + base, // default pub path under here will be missing... but priv content wins
"--" + StakingMLDSAKeyContentKey + "=" + base64.StdEncoding.EncodeToString(privPEM),
"--" + StakingMLDSAPubKeyPathKey + "=" + filepath.Join(base, "does-not-exist.pub"),
})
require.NoError(t, err)
_, _, _, _, err = loadStakingMLDSA(v)
require.Error(t, err)
require.Contains(t, err.Error(), "both private and public key are required")
}