mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
config: blank *-content flag falls through to *-file in pemBytesOrFile (#139)
pemBytesOrFile short-circuited when a *-content flag was set but empty (v.IsSet true, value ""), returning empty bytes instead of consulting the corresponding *-file path. For strict-PQ staking keys (--staking-mldsa-key-file-content / --staking-mldsa-key-file) this silently degraded a validator to a classical ECDSA NodeID: StakingMLDSAPub stayed empty, IsStrictPQ() was false, and the node booted with no error. The genesis validator set then never matched the live NodeIDs and the P-chain produced 0 blocks -- the strict-PQ activation outage (the #48 failure class). Treat a set-but-empty content flag identically to an absent one and fall through to the *-file path. The same helper backs the ML-KEM handshake keys, so both paths are fixed. Restore the regression guard TestLoadStakingMLDSA_EmptyContentFallsThroughToPath (fails on main, passes here) alongside the other loadStakingMLDSA cases. Verified: CGO_ENABLED=0 go test ./config/
This commit is contained in:
+11
-8
@@ -775,15 +775,18 @@ func loadPEMBlock(path, expectType string) ([]byte, error) {
|
||||
// pemBytesOrFile resolves either a base64-encoded PEM in
|
||||
// contentKey (highest precedence, matches the
|
||||
// --foo-file-content / --foo-file pattern the existing TLS
|
||||
// loaders use) or a filesystem path in pathKey. Empty return =
|
||||
// neither was set; caller decides whether that's a fatal config
|
||||
// error or a "fall through to classical-compat" path.
|
||||
// loaders use) or a filesystem path in pathKey. A set-but-empty
|
||||
// contentKey is treated as absent and falls through to pathKey, so a
|
||||
// blank content flag and a missing one behave identically. Empty
|
||||
// return = neither was provided; caller decides whether that's a fatal
|
||||
// config error or a "fall through to classical-compat" path.
|
||||
func pemBytesOrFile(v *viper.Viper, contentKey, pathKey, expectType string) ([]byte, string, error) {
|
||||
if v.IsSet(contentKey) {
|
||||
raw := v.GetString(contentKey)
|
||||
if raw == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
// A non-empty *-content flag wins. A set-but-empty one (e.g. an env var or
|
||||
// a deploy template that rendered to "") is treated identically to an
|
||||
// absent flag: fall through to the *-file path below. Short-circuiting on
|
||||
// the empty value here would silently degrade a strict-PQ validator to a
|
||||
// classical ECDSA NodeID — the strict-PQ activation outage this guards.
|
||||
if raw := v.GetString(contentKey); raw != "" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode %s base64: %w", contentKey, err)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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 is the regression guard
|
||||
// for the strict-PQ activation outage: 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. The previous behavior short-circuited
|
||||
// on the empty content value and returned no key, so StakingMLDSAPub stayed
|
||||
// empty, IsStrictPQ() was false, and the node booted under an ECDSA NodeID with
|
||||
// no error — silently degrading a strict-PQ validator 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_NeitherIsClassicalCompat: no key material at all (and a
|
||||
// default pub path that does not exist) is classical-compat — empty, no error.
|
||||
func TestLoadStakingMLDSA_NeitherIsClassicalCompat(t *testing.T) {
|
||||
// Point DataDir at an empty dir so the default pub path resolves to a
|
||||
// non-existent file rather than picking up a developer's ~/.lux key.
|
||||
v, err := BuildViper(BuildFlagSet(), []string{
|
||||
"--" + DataDirKey + "=" + t.TempDir(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
priv, pub, _, _, err := loadStakingMLDSA(v)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, priv)
|
||||
require.Empty(t, pub)
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
Reference in New Issue
Block a user