mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
Consumer-side rename to follow the luxfi/pulsar package drop of the -M suffix. All 25 touched files migrate the import path github.com/luxfi/pulsar/ref/go/pkg/pulsarm → .../pulsar and the in-file symbol references pulsarm.* → pulsar.* (plus the local witness_pulsarm_test.go filename → witness_pulsar_test.go). Build: go build ./... PASS. Test: full ./... sweep — 28 packages PASS (protocol/quasar 15.9s, engine 10.7s, config 0.9s, pkg/wire 1.5s).
726 lines
26 KiB
Go
726 lines
26 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package config
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// =============================================================================
|
|
// E2E PQ enums — stable integers, canonical strings, post-quantum classification
|
|
// =============================================================================
|
|
|
|
// TestWalletSchemeID_StableIntegers locks the wire bytes for the wallet
|
|
// signature scheme enum. Renumbering breaks every wallet address ever
|
|
// derived under this byte; new schemes claim the next free integer.
|
|
func TestWalletSchemeID_StableIntegers(t *testing.T) {
|
|
cases := []struct {
|
|
s WalletSchemeID
|
|
want uint8
|
|
}{
|
|
{WalletSchemeInvalid, 0x00},
|
|
{WalletSchemeMLDSA65, 0x42},
|
|
{WalletSchemeMLDSA87, 0x43},
|
|
{WalletSchemeECDSAUnsafe, 0x90},
|
|
}
|
|
for _, c := range cases {
|
|
if got := uint8(c.s); got != c.want {
|
|
t.Errorf("WalletSchemeID %q = 0x%02x, want 0x%02x", c.s.String(), got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWalletSchemeID_String pins the canonical wire name.
|
|
func TestWalletSchemeID_String(t *testing.T) {
|
|
cases := []struct {
|
|
s WalletSchemeID
|
|
want string
|
|
}{
|
|
{WalletSchemeInvalid, "invalid"},
|
|
{WalletSchemeMLDSA65, "ml-dsa-65"},
|
|
{WalletSchemeMLDSA87, "ml-dsa-87"},
|
|
{WalletSchemeECDSAUnsafe, "ecdsa-unsafe-classical-forbidden-in-pq"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := c.s.String(); got != c.want {
|
|
t.Errorf("WalletSchemeID(0x%02x).String() = %q, want %q", uint8(c.s), got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWalletSchemeID_IsPostQuantum — ML-DSA-65 / ML-DSA-87 are PQ;
|
|
// ECDSA and Invalid are not.
|
|
func TestWalletSchemeID_IsPostQuantum(t *testing.T) {
|
|
for _, s := range []WalletSchemeID{WalletSchemeMLDSA65, WalletSchemeMLDSA87} {
|
|
if !s.IsPostQuantum() {
|
|
t.Errorf("%q must be IsPostQuantum=true", s.String())
|
|
}
|
|
}
|
|
for _, s := range []WalletSchemeID{WalletSchemeInvalid, WalletSchemeECDSAUnsafe} {
|
|
if s.IsPostQuantum() {
|
|
t.Errorf("%q must be IsPostQuantum=false", s.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWalletSchemeID_IsForbiddenInPQMode — the 0x90 classical marker
|
|
// is forbidden; everything else is not.
|
|
func TestWalletSchemeID_IsForbiddenInPQMode(t *testing.T) {
|
|
if !WalletSchemeECDSAUnsafe.IsForbiddenInPQMode() {
|
|
t.Errorf("WalletSchemeECDSAUnsafe must be forbidden in PQ mode")
|
|
}
|
|
for _, s := range []WalletSchemeID{WalletSchemeInvalid, WalletSchemeMLDSA65, WalletSchemeMLDSA87} {
|
|
if s.IsForbiddenInPQMode() {
|
|
t.Errorf("%q must NOT be forbidden in PQ mode", s.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTxSchemeID_StableIntegers mirrors the wallet enum tests on the
|
|
// tx-auth axis. The two enums share the 0x00/0x42/0x43/0x90 pattern
|
|
// intentionally — the byte is independent on the wire but the pattern
|
|
// keeps audit readers aligned.
|
|
func TestTxSchemeID_StableIntegers(t *testing.T) {
|
|
cases := []struct {
|
|
s TxSchemeID
|
|
want uint8
|
|
}{
|
|
{TxSchemeInvalid, 0x00},
|
|
{TxSchemeMLDSA65, 0x42},
|
|
{TxSchemeMLDSA87, 0x43},
|
|
{TxSchemeECDSAUnsafe, 0x90},
|
|
}
|
|
for _, c := range cases {
|
|
if got := uint8(c.s); got != c.want {
|
|
t.Errorf("TxSchemeID %q = 0x%02x, want 0x%02x", c.s.String(), got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTxSchemeID_String pins the canonical wire name.
|
|
func TestTxSchemeID_String(t *testing.T) {
|
|
cases := []struct {
|
|
s TxSchemeID
|
|
want string
|
|
}{
|
|
{TxSchemeInvalid, "invalid"},
|
|
{TxSchemeMLDSA65, "ml-dsa-65"},
|
|
{TxSchemeMLDSA87, "ml-dsa-87"},
|
|
{TxSchemeECDSAUnsafe, "ecdsa-unsafe-classical-forbidden-in-pq"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := c.s.String(); got != c.want {
|
|
t.Errorf("TxSchemeID(0x%02x).String() = %q, want %q", uint8(c.s), got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTxSchemeID_IsPostQuantum / IsForbiddenInPQMode mirror the wallet tests.
|
|
func TestTxSchemeID_IsPostQuantum(t *testing.T) {
|
|
for _, s := range []TxSchemeID{TxSchemeMLDSA65, TxSchemeMLDSA87} {
|
|
if !s.IsPostQuantum() {
|
|
t.Errorf("%q must be IsPostQuantum=true", s.String())
|
|
}
|
|
}
|
|
for _, s := range []TxSchemeID{TxSchemeInvalid, TxSchemeECDSAUnsafe} {
|
|
if s.IsPostQuantum() {
|
|
t.Errorf("%q must be IsPostQuantum=false", s.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTxSchemeID_IsForbiddenInPQMode(t *testing.T) {
|
|
if !TxSchemeECDSAUnsafe.IsForbiddenInPQMode() {
|
|
t.Errorf("TxSchemeECDSAUnsafe must be forbidden in PQ mode")
|
|
}
|
|
for _, s := range []TxSchemeID{TxSchemeInvalid, TxSchemeMLDSA65, TxSchemeMLDSA87} {
|
|
if s.IsForbiddenInPQMode() {
|
|
t.Errorf("%q must NOT be forbidden in PQ mode", s.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestContractAuthID_StableIntegers pins the byte values. The enum has
|
|
// the widest spread because contract auth covers proof-carrying,
|
|
// multi-party, session-scoped, raw lattice, and classical primitives.
|
|
func TestContractAuthID_StableIntegers(t *testing.T) {
|
|
cases := []struct {
|
|
s ContractAuthID
|
|
want uint8
|
|
}{
|
|
{ContractAuthInvalid, 0x00},
|
|
{ContractAuthZChainProof, 0x10},
|
|
{ContractAuthMultisigMLDSA, 0x20},
|
|
{ContractAuthSessionPQ, 0x21},
|
|
{ContractAuthMLDSA65, 0x42},
|
|
{ContractAuthMLDSA87, 0x43},
|
|
{ContractAuthECDSAUnsafe, 0x90},
|
|
{ContractAuthBLSUnsafe, 0x91},
|
|
}
|
|
for _, c := range cases {
|
|
if got := uint8(c.s); got != c.want {
|
|
t.Errorf("ContractAuthID %q = 0x%02x, want 0x%02x", c.s.String(), got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestContractAuthID_String pins canonical wire names.
|
|
func TestContractAuthID_String(t *testing.T) {
|
|
cases := []struct {
|
|
s ContractAuthID
|
|
want string
|
|
}{
|
|
{ContractAuthInvalid, "invalid"},
|
|
{ContractAuthZChainProof, "z-chain-proof"},
|
|
{ContractAuthMultisigMLDSA, "multisig-ml-dsa"},
|
|
{ContractAuthSessionPQ, "session-pq"},
|
|
{ContractAuthMLDSA65, "ml-dsa-65"},
|
|
{ContractAuthMLDSA87, "ml-dsa-87"},
|
|
{ContractAuthECDSAUnsafe, "ecdsa-unsafe-classical-forbidden-in-pq"},
|
|
{ContractAuthBLSUnsafe, "bls-unsafe-classical-forbidden-in-pq"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := c.s.String(); got != c.want {
|
|
t.Errorf("ContractAuthID(0x%02x).String() = %q, want %q", uint8(c.s), got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestContractAuthID_IsPostQuantum — every non-classical primitive
|
|
// (proof-carrying, multi-party, session-PQ, raw ML-DSA) qualifies.
|
|
func TestContractAuthID_IsPostQuantum(t *testing.T) {
|
|
for _, s := range []ContractAuthID{
|
|
ContractAuthZChainProof,
|
|
ContractAuthMultisigMLDSA,
|
|
ContractAuthSessionPQ,
|
|
ContractAuthMLDSA65,
|
|
ContractAuthMLDSA87,
|
|
} {
|
|
if !s.IsPostQuantum() {
|
|
t.Errorf("%q must be IsPostQuantum=true", s.String())
|
|
}
|
|
}
|
|
for _, s := range []ContractAuthID{
|
|
ContractAuthInvalid,
|
|
ContractAuthECDSAUnsafe,
|
|
ContractAuthBLSUnsafe,
|
|
} {
|
|
if s.IsPostQuantum() {
|
|
t.Errorf("%q must be IsPostQuantum=false", s.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContractAuthID_IsForbiddenInPQMode(t *testing.T) {
|
|
for _, s := range []ContractAuthID{ContractAuthECDSAUnsafe, ContractAuthBLSUnsafe} {
|
|
if !s.IsForbiddenInPQMode() {
|
|
t.Errorf("%q must be forbidden in PQ mode", s.String())
|
|
}
|
|
}
|
|
for _, s := range []ContractAuthID{
|
|
ContractAuthInvalid,
|
|
ContractAuthZChainProof,
|
|
ContractAuthMultisigMLDSA,
|
|
ContractAuthSessionPQ,
|
|
ContractAuthMLDSA65,
|
|
ContractAuthMLDSA87,
|
|
} {
|
|
if s.IsForbiddenInPQMode() {
|
|
t.Errorf("%q must NOT be forbidden in PQ mode", s.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestKeyExchangeID_StableIntegers pins the KEM byte values.
|
|
func TestKeyExchangeID_StableIntegers(t *testing.T) {
|
|
cases := []struct {
|
|
s KeyExchangeID
|
|
want uint8
|
|
}{
|
|
{KeyExchangeInvalid, 0x00},
|
|
{KeyExchangeMLKEM768, 0x01},
|
|
{KeyExchangeMLKEM1024, 0x02},
|
|
{KeyExchangeX25519Unsafe, 0x90},
|
|
}
|
|
for _, c := range cases {
|
|
if got := uint8(c.s); got != c.want {
|
|
t.Errorf("KeyExchangeID %q = 0x%02x, want 0x%02x", c.s.String(), got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKeyExchangeID_String(t *testing.T) {
|
|
cases := []struct {
|
|
s KeyExchangeID
|
|
want string
|
|
}{
|
|
{KeyExchangeInvalid, "invalid"},
|
|
{KeyExchangeMLKEM768, "ml-kem-768"},
|
|
{KeyExchangeMLKEM1024, "ml-kem-1024"},
|
|
{KeyExchangeX25519Unsafe, "x25519-unsafe-classical-forbidden-in-pq"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := c.s.String(); got != c.want {
|
|
t.Errorf("KeyExchangeID(0x%02x).String() = %q, want %q", uint8(c.s), got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKeyExchangeID_IsPostQuantum(t *testing.T) {
|
|
for _, s := range []KeyExchangeID{KeyExchangeMLKEM768, KeyExchangeMLKEM1024} {
|
|
if !s.IsPostQuantum() {
|
|
t.Errorf("%q must be IsPostQuantum=true", s.String())
|
|
}
|
|
}
|
|
for _, s := range []KeyExchangeID{KeyExchangeInvalid, KeyExchangeX25519Unsafe} {
|
|
if s.IsPostQuantum() {
|
|
t.Errorf("%q must be IsPostQuantum=false", s.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKeyExchangeID_IsForbiddenInPQMode(t *testing.T) {
|
|
if !KeyExchangeX25519Unsafe.IsForbiddenInPQMode() {
|
|
t.Errorf("KeyExchangeX25519Unsafe must be forbidden in PQ mode")
|
|
}
|
|
for _, s := range []KeyExchangeID{
|
|
KeyExchangeInvalid,
|
|
KeyExchangeMLKEM768,
|
|
KeyExchangeMLKEM1024,
|
|
} {
|
|
if s.IsForbiddenInPQMode() {
|
|
t.Errorf("%q must NOT be forbidden in PQ mode", s.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRecoverySchemeID_StableIntegers pins the recovery scheme bytes.
|
|
// SLH-DSA occupies 0x05..0x07 (FIPS 205 Cat 1/3/5), ML-DSA-87 shares
|
|
// 0x43 with the FIPS 204 enum, RecoverySchemeNone is 0xFF.
|
|
func TestRecoverySchemeID_StableIntegers(t *testing.T) {
|
|
cases := []struct {
|
|
s RecoverySchemeID
|
|
want uint8
|
|
}{
|
|
{RecoverySchemeInvalid, 0x00},
|
|
{RecoverySchemeSLHDSA128, 0x05},
|
|
{RecoverySchemeSLHDSA192, 0x06},
|
|
{RecoverySchemeSLHDSA256, 0x07},
|
|
{RecoverySchemeMLDSA87, 0x43},
|
|
{RecoverySchemeNone, 0xFF},
|
|
}
|
|
for _, c := range cases {
|
|
if got := uint8(c.s); got != c.want {
|
|
t.Errorf("RecoverySchemeID %q = 0x%02x, want 0x%02x", c.s.String(), got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecoverySchemeID_String(t *testing.T) {
|
|
cases := []struct {
|
|
s RecoverySchemeID
|
|
want string
|
|
}{
|
|
{RecoverySchemeInvalid, "invalid"},
|
|
{RecoverySchemeSLHDSA128, "slh-dsa-128"},
|
|
{RecoverySchemeSLHDSA192, "slh-dsa-192"},
|
|
{RecoverySchemeSLHDSA256, "slh-dsa-256"},
|
|
{RecoverySchemeMLDSA87, "ml-dsa-87"},
|
|
{RecoverySchemeNone, "none"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := c.s.String(); got != c.want {
|
|
t.Errorf("RecoverySchemeID(0x%02x).String() = %q, want %q", uint8(c.s), got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRecoverySchemeID_IsPostQuantum — every defined non-Invalid
|
|
// recovery scheme is PQ-acceptable. None is a policy-class sentinel
|
|
// (acceptable only when high-value is Cat 5) and counts as PQ for the
|
|
// predicate; the cross-axis validation gate refuses it when the
|
|
// high-value scheme doesn't permit it.
|
|
func TestRecoverySchemeID_IsPostQuantum(t *testing.T) {
|
|
for _, s := range []RecoverySchemeID{
|
|
RecoverySchemeSLHDSA128,
|
|
RecoverySchemeSLHDSA192,
|
|
RecoverySchemeSLHDSA256,
|
|
RecoverySchemeMLDSA87,
|
|
RecoverySchemeNone,
|
|
} {
|
|
if !s.IsPostQuantum() {
|
|
t.Errorf("%q must be IsPostQuantum=true", s.String())
|
|
}
|
|
}
|
|
if RecoverySchemeInvalid.IsPostQuantum() {
|
|
t.Errorf("RecoverySchemeInvalid must be IsPostQuantum=false")
|
|
}
|
|
}
|
|
|
|
func TestRecoverySchemeID_IsForbiddenInPQMode(t *testing.T) {
|
|
// No 0x90+ classical recovery scheme is currently defined; the
|
|
// predicate is trivially false for every entry. Lock the behaviour
|
|
// so future additions surface here.
|
|
for _, s := range []RecoverySchemeID{
|
|
RecoverySchemeInvalid,
|
|
RecoverySchemeSLHDSA128,
|
|
RecoverySchemeSLHDSA192,
|
|
RecoverySchemeSLHDSA256,
|
|
RecoverySchemeMLDSA87,
|
|
RecoverySchemeNone,
|
|
} {
|
|
if s.IsForbiddenInPQMode() {
|
|
t.Errorf("%q must NOT be forbidden in PQ mode", s.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRecoverySchemeID_IsStateless — only SLH-DSA schemes are stateless
|
|
// hash-based; ML-DSA-87 and None are not.
|
|
func TestRecoverySchemeID_IsStateless(t *testing.T) {
|
|
for _, s := range []RecoverySchemeID{
|
|
RecoverySchemeSLHDSA128,
|
|
RecoverySchemeSLHDSA192,
|
|
RecoverySchemeSLHDSA256,
|
|
} {
|
|
if !s.IsStateless() {
|
|
t.Errorf("%q must be IsStateless=true", s.String())
|
|
}
|
|
}
|
|
for _, s := range []RecoverySchemeID{
|
|
RecoverySchemeInvalid,
|
|
RecoverySchemeMLDSA87,
|
|
RecoverySchemeNone,
|
|
} {
|
|
if s.IsStateless() {
|
|
t.Errorf("%q must be IsStateless=false", s.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ChainSecurityProfile.Validate — E2E PQ axis enforcement
|
|
// =============================================================================
|
|
|
|
// TestChainSecurityProfile_Validate_StrictPQ_RejectsECDSAWallets proves
|
|
// the strict-PQ profile refuses a wallet scheme set to the 0x90 classical
|
|
// marker. Two-stage check: setting WalletSchemeECDSAUnsafe directly is
|
|
// refused by validatePolicy's IsForbiddenInPQMode gate; setting
|
|
// ForbidECDSAWallets=false (and keeping the lattice wallet scheme) is
|
|
// refused by the operator-policy check.
|
|
func TestChainSecurityProfile_Validate_StrictPQ_RejectsECDSAWallets(t *testing.T) {
|
|
// Case 1: classical scheme byte on the wire.
|
|
p := StrictPQ()
|
|
p.WalletSchemeID = WalletSchemeECDSAUnsafe
|
|
if err := p.Validate(); !errors.Is(err, ErrProfileFieldInvalid) {
|
|
t.Errorf("Validate() with WalletSchemeECDSAUnsafe returned %v; want ErrProfileFieldInvalid", err)
|
|
}
|
|
|
|
// Case 2: Forbid bit cleared while the scheme stays lattice. The
|
|
// operator-policy gate must catch the cleared bit on a strict-PQ
|
|
// profile even though the scheme itself is lattice.
|
|
q := StrictPQ()
|
|
q.ForbidECDSAWallets = false
|
|
if err := q.Validate(); !errors.Is(err, ErrProfileFieldInvalid) {
|
|
t.Errorf("Validate() with ForbidECDSAWallets=false returned %v; want ErrProfileFieldInvalid", err)
|
|
}
|
|
}
|
|
|
|
// TestChainSecurityProfile_Validate_StrictPQ_RejectsX25519KEM proves the
|
|
// strict-PQ profile refuses both classical KEM scheme bytes and a cleared
|
|
// ForbidClassicalKEM bit.
|
|
func TestChainSecurityProfile_Validate_StrictPQ_RejectsX25519KEM(t *testing.T) {
|
|
p := StrictPQ()
|
|
p.KeyExchangeID = KeyExchangeX25519Unsafe
|
|
if err := p.Validate(); !errors.Is(err, ErrProfileFieldInvalid) {
|
|
t.Errorf("Validate() with KeyExchangeX25519Unsafe returned %v; want ErrProfileFieldInvalid", err)
|
|
}
|
|
|
|
q := StrictPQ()
|
|
q.HighValueKEM = KeyExchangeX25519Unsafe
|
|
if err := q.Validate(); !errors.Is(err, ErrProfileFieldInvalid) {
|
|
t.Errorf("Validate() with HighValueKEM=KeyExchangeX25519Unsafe returned %v; want ErrProfileFieldInvalid", err)
|
|
}
|
|
|
|
r := StrictPQ()
|
|
r.ForbidClassicalKEM = false
|
|
if err := r.Validate(); !errors.Is(err, ErrProfileFieldInvalid) {
|
|
t.Errorf("Validate() with ForbidClassicalKEM=false returned %v; want ErrProfileFieldInvalid", err)
|
|
}
|
|
}
|
|
|
|
// TestChainSecurityProfile_Validate_StrictPQ_RejectsECDSATxScheme covers
|
|
// the tx axis explicitly so an audit reader sees the gate is per-axis.
|
|
func TestChainSecurityProfile_Validate_StrictPQ_RejectsECDSATxScheme(t *testing.T) {
|
|
p := StrictPQ()
|
|
p.TxSchemeID = TxSchemeECDSAUnsafe
|
|
if err := p.Validate(); !errors.Is(err, ErrProfileFieldInvalid) {
|
|
t.Errorf("Validate() with TxSchemeECDSAUnsafe returned %v; want ErrProfileFieldInvalid", err)
|
|
}
|
|
}
|
|
|
|
// TestChainSecurityProfile_Validate_StrictPQ_RejectsBLSContractAuth proves
|
|
// the strict-PQ profile refuses classical BLS aggregates at the contract
|
|
// authorisation layer.
|
|
func TestChainSecurityProfile_Validate_StrictPQ_RejectsBLSContractAuth(t *testing.T) {
|
|
p := StrictPQ()
|
|
p.ContractAuthID = ContractAuthBLSUnsafe
|
|
if err := p.Validate(); !errors.Is(err, ErrProfileFieldInvalid) {
|
|
t.Errorf("Validate() with ContractAuthBLSUnsafe returned %v; want ErrProfileFieldInvalid", err)
|
|
}
|
|
|
|
q := StrictPQ()
|
|
q.ForbidBLSContractAuth = false
|
|
if err := q.Validate(); !errors.Is(err, ErrProfileFieldInvalid) {
|
|
t.Errorf("Validate() with ForbidBLSContractAuth=false returned %v; want ErrProfileFieldInvalid", err)
|
|
}
|
|
}
|
|
|
|
// TestChainSecurityProfile_Validate_StrictPQ_RequiresTypedTxAuth proves
|
|
// strict-PQ demands the typed-auth byte on the wire.
|
|
func TestChainSecurityProfile_Validate_StrictPQ_RequiresTypedTxAuth(t *testing.T) {
|
|
p := StrictPQ()
|
|
p.RequireTypedTxAuth = false
|
|
if err := p.Validate(); !errors.Is(err, ErrProfileFieldInvalid) {
|
|
t.Errorf("Validate() with RequireTypedTxAuth=false returned %v; want ErrProfileFieldInvalid", err)
|
|
}
|
|
}
|
|
|
|
// TestChainSecurityProfile_Validate_RecoverySchemeNone_RequiresMLDSA87 proves
|
|
// the cross-axis rule: RecoverySchemeNone is only acceptable when the
|
|
// chain's high-value scheme is Pulsar-M-87 or raw ML-DSA-87 (both Cat 5).
|
|
//
|
|
// In practice the existing HighValueSchemeID invariant restricts the
|
|
// field to Pulsar-M-65 / Pulsar-M-87 (raw ML-DSA is identity, not
|
|
// high-value); the ML-DSA-87 branch of the rule is therefore defensive
|
|
// — it covers a future profile-class that opts out of the Pulsar-M
|
|
// constraint. This test exercises only the reachable Pulsar-M-87 path
|
|
// and the Pulsar-M-65 negative case.
|
|
func TestChainSecurityProfile_Validate_RecoverySchemeNone_RequiresMLDSA87(t *testing.T) {
|
|
// Strict-PQ is at HighValue=Pulsar-M-87 → RecoverySchemeNone is OK.
|
|
p := StrictPQ()
|
|
p.RecoverySchemeID = RecoverySchemeNone
|
|
if err := p.Validate(); err != nil {
|
|
t.Errorf("Validate() with RecoverySchemeNone + HighValue=Pulsar-M-87 returned %v; want nil", err)
|
|
}
|
|
|
|
// Drop HighValue to Pulsar-M-65 → must be rejected. Pulsar-M-65 is
|
|
// NIST PQ Cat 3 (acceptable for high-value finality) but the
|
|
// recovery-fallback rule demands Cat 5, so the cross-axis gate
|
|
// trips.
|
|
q := StrictPQ()
|
|
q.HighValueSchemeID = SigSchemePulsar65
|
|
q.RecoverySchemeID = RecoverySchemeNone
|
|
if err := q.Validate(); !errors.Is(err, ErrProfileFieldInvalid) {
|
|
t.Errorf("Validate() with RecoverySchemeNone + HighValue=Pulsar-M-65 returned %v; want ErrProfileFieldInvalid", err)
|
|
}
|
|
|
|
// The fork profile demonstrates RecoverySchemeNone in production —
|
|
// HighValueSchemeID=Pulsar-M-87 carries the strong fallback that
|
|
// would otherwise require a stateless backstop.
|
|
f := ForkClassicalCompatUnsafeProfile
|
|
if f.RecoverySchemeID != RecoverySchemeNone {
|
|
t.Errorf("ForkClassicalCompatUnsafeProfile.RecoverySchemeID = %s; want none", f.RecoverySchemeID.String())
|
|
}
|
|
if err := f.Validate(); err != nil {
|
|
t.Errorf("ForkClassicalCompatUnsafeProfile.Validate() = %v; want nil", err)
|
|
}
|
|
}
|
|
|
|
// TestChainSecurityProfile_Validate_RejectsInvalidEnumZeros proves the
|
|
// structural gate rejects a zero-init value on every new enum.
|
|
func TestChainSecurityProfile_Validate_RejectsInvalidEnumZeros(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mutate func(p *ChainSecurityProfile)
|
|
}{
|
|
{"WalletSchemeID", func(p *ChainSecurityProfile) { p.WalletSchemeID = WalletSchemeInvalid }},
|
|
{"TxSchemeID", func(p *ChainSecurityProfile) { p.TxSchemeID = TxSchemeInvalid }},
|
|
{"ContractAuthID", func(p *ChainSecurityProfile) { p.ContractAuthID = ContractAuthInvalid }},
|
|
{"KeyExchangeID", func(p *ChainSecurityProfile) { p.KeyExchangeID = KeyExchangeInvalid }},
|
|
{"HighValueKEM", func(p *ChainSecurityProfile) { p.HighValueKEM = KeyExchangeInvalid }},
|
|
{"RecoverySchemeID", func(p *ChainSecurityProfile) { p.RecoverySchemeID = RecoverySchemeInvalid }},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
p := StrictPQ()
|
|
c.mutate(p)
|
|
if err := p.Validate(); !errors.Is(err, ErrProfileFieldUnset) {
|
|
t.Errorf("Validate() with %s=Invalid returned %v; want ErrProfileFieldUnset",
|
|
c.name, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChainSecurityProfile_Validate_RejectsUnknownEnumByte proves
|
|
// out-of-range bytes (no known enum entry) are refused. A real wire
|
|
// could carry an attacker-supplied byte the local toolkit doesn't know.
|
|
func TestChainSecurityProfile_Validate_RejectsUnknownEnumByte(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mutate func(p *ChainSecurityProfile)
|
|
}{
|
|
{"WalletSchemeID", func(p *ChainSecurityProfile) { p.WalletSchemeID = WalletSchemeID(0x77) }},
|
|
{"TxSchemeID", func(p *ChainSecurityProfile) { p.TxSchemeID = TxSchemeID(0x77) }},
|
|
{"ContractAuthID", func(p *ChainSecurityProfile) { p.ContractAuthID = ContractAuthID(0x77) }},
|
|
{"KeyExchangeID", func(p *ChainSecurityProfile) { p.KeyExchangeID = KeyExchangeID(0x77) }},
|
|
{"HighValueKEM", func(p *ChainSecurityProfile) { p.HighValueKEM = KeyExchangeID(0x77) }},
|
|
{"RecoverySchemeID", func(p *ChainSecurityProfile) { p.RecoverySchemeID = RecoverySchemeID(0x77) }},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
p := StrictPQ()
|
|
c.mutate(p)
|
|
if err := p.Validate(); !errors.Is(err, ErrProfileFieldUnknown) {
|
|
t.Errorf("Validate() with %s=0x77 returned %v; want ErrProfileFieldUnknown",
|
|
c.name, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChainSecurityProfile_Permissive_AllowsExperiments proves the
|
|
// permissive profile accepts the same lattice defaults as strict-PQ but
|
|
// keeps the new Forbid* bits cleared and RequireTypedTxAuth false.
|
|
func TestChainSecurityProfile_Permissive_AllowsExperiments(t *testing.T) {
|
|
p := Permissive()
|
|
if err := p.Validate(); err != nil {
|
|
t.Fatalf("Permissive().Validate() = %v; want nil", err)
|
|
}
|
|
if p.ForbidECDSAWallets {
|
|
t.Errorf("Permissive() must NOT set ForbidECDSAWallets")
|
|
}
|
|
if p.ForbidECDSAContractAuth {
|
|
t.Errorf("Permissive() must NOT set ForbidECDSAContractAuth")
|
|
}
|
|
if p.ForbidBLSContractAuth {
|
|
t.Errorf("Permissive() must NOT set ForbidBLSContractAuth")
|
|
}
|
|
if p.ForbidClassicalKEM {
|
|
t.Errorf("Permissive() must NOT set ForbidClassicalKEM")
|
|
}
|
|
if p.RequireTypedTxAuth {
|
|
t.Errorf("Permissive() must NOT require typed tx auth")
|
|
}
|
|
if p.WalletSchemeID != WalletSchemeMLDSA65 {
|
|
t.Errorf("Permissive().WalletSchemeID = %s; want ml-dsa-65", p.WalletSchemeID.String())
|
|
}
|
|
}
|
|
|
|
// TestChainSecurityProfile_ForkClassicalCompatUnsafe_AcceptsClassical proves
|
|
// the fork profile passes Validate even with every new axis pinned to
|
|
// the 0x90 classical marker. Only StrictPQ and FIPS enforce the
|
|
// strict-PQ refusals; the fork is allowed to opt out.
|
|
func TestChainSecurityProfile_ForkClassicalCompatUnsafe_AcceptsClassical(t *testing.T) {
|
|
if err := ForkClassicalCompatUnsafeProfile.Validate(); err != nil {
|
|
t.Fatalf("ForkClassicalCompatUnsafeProfile.Validate() = %v; want nil", err)
|
|
}
|
|
if ForkClassicalCompatUnsafeProfile.WalletSchemeID != WalletSchemeECDSAUnsafe {
|
|
t.Errorf("fork.WalletSchemeID = %s; want ECDSA-unsafe", ForkClassicalCompatUnsafeProfile.WalletSchemeID.String())
|
|
}
|
|
if ForkClassicalCompatUnsafeProfile.KeyExchangeID != KeyExchangeX25519Unsafe {
|
|
t.Errorf("fork.KeyExchangeID = %s; want X25519-unsafe", ForkClassicalCompatUnsafeProfile.KeyExchangeID.String())
|
|
}
|
|
if ForkClassicalCompatUnsafeProfile.RecoverySchemeID != RecoverySchemeNone {
|
|
t.Errorf("fork.RecoverySchemeID = %s; want none", ForkClassicalCompatUnsafeProfile.RecoverySchemeID.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ComputeHash — every new field must be bound into the TupleHash transcript
|
|
// =============================================================================
|
|
|
|
// TestChainSecurityProfile_ComputeHash_BindsNewFields walks every new
|
|
// E2E PQ field on the profile and proves mutating it changes the
|
|
// ComputeHash output. The test is the last line of defence against a
|
|
// refactor accidentally dropping a field from the canonical encoding.
|
|
//
|
|
// Mutations are chosen to keep Validate happy (so ComputeHash's internal
|
|
// validateStructural pass succeeds) while still being a distinct value
|
|
// from the canonical profile.
|
|
func TestChainSecurityProfile_ComputeHash_BindsNewFields(t *testing.T) {
|
|
baseHash, err := StrictPQ().ComputeHash()
|
|
if err != nil {
|
|
t.Fatalf("base: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
mutate func(p *ChainSecurityProfile)
|
|
}{
|
|
{"WalletSchemeID", func(p *ChainSecurityProfile) { p.WalletSchemeID = WalletSchemeMLDSA87 }},
|
|
{"TxSchemeID", func(p *ChainSecurityProfile) { p.TxSchemeID = TxSchemeMLDSA87 }},
|
|
{"ContractAuthID", func(p *ChainSecurityProfile) { p.ContractAuthID = ContractAuthMLDSA87 }},
|
|
{"KeyExchangeID", func(p *ChainSecurityProfile) { p.KeyExchangeID = KeyExchangeMLKEM1024 }},
|
|
{"HighValueKEM", func(p *ChainSecurityProfile) { p.HighValueKEM = KeyExchangeMLKEM768 }},
|
|
{"RecoverySchemeID", func(p *ChainSecurityProfile) { p.RecoverySchemeID = RecoverySchemeSLHDSA256 }},
|
|
// Forbid bits and RequireTypedTxAuth — these mutations make
|
|
// Validate fail under strict-PQ but ComputeHash only calls
|
|
// validateStructural, which does not enforce the strict-PQ
|
|
// operator-policy. Each flip MUST still change the transcript.
|
|
{"ForbidECDSAWallets", func(p *ChainSecurityProfile) { p.ForbidECDSAWallets = false }},
|
|
{"ForbidECDSAContractAuth", func(p *ChainSecurityProfile) { p.ForbidECDSAContractAuth = false }},
|
|
{"ForbidBLSContractAuth", func(p *ChainSecurityProfile) { p.ForbidBLSContractAuth = false }},
|
|
{"ForbidClassicalKEM", func(p *ChainSecurityProfile) { p.ForbidClassicalKEM = false }},
|
|
{"RequireTypedTxAuth", func(p *ChainSecurityProfile) { p.RequireTypedTxAuth = false }},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
mutated := StrictPQ()
|
|
c.mutate(mutated)
|
|
got, err := mutated.ComputeHash()
|
|
if err != nil {
|
|
t.Fatalf("ComputeHash(mutated %s): %v", c.name, err)
|
|
}
|
|
if got == baseHash {
|
|
t.Errorf("mutating %s did not change ComputeHash output", c.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Golden vector — the re-pinned canonical StrictPQ profile hash.
|
|
//
|
|
// The hash binds every field of ChainSecurityProfile under the
|
|
// TupleHash256 / cSHAKE256 customisation "LUX-CHAIN-SECURITY-PROFILE-V1".
|
|
// Any change to the canonical profile, the field set, or the encoding
|
|
// produces a new digest — and re-pinning this constant is the explicit
|
|
// gate that says "I intentionally rolled the canonical profile."
|
|
//
|
|
// Update path: when the canonical profile or encoding changes, re-run
|
|
//
|
|
// cd ~/work/lux/consensus && env GOWORK=off go test -count=1 \
|
|
// -run TestChainSecurityProfile_GoldenVector_LuxStrictPQ -v ./config/
|
|
//
|
|
// Copy the digest from the failure message into the constant below in
|
|
// the SAME COMMIT that landed the schema change.
|
|
// =============================================================================
|
|
|
|
// strictPQGoldenHashHex is the pinned hex of StrictPQ.ComputeHash().
|
|
// Re-pinned after ProfileName rename ("STRICT_PQ" → "STRICT"); the
|
|
// rename drops the "PQ" suffix because the canonical spectrum
|
|
// (classical, hybrid, strict) cuts across more than the PQ axis, and
|
|
// the strictest posture is just called "strict".
|
|
const strictPQGoldenHashHex = "cf3f2cd7b54cfc2cb88b741f38514c58aef7f7184379c7ea454e16c3e54f7d5b04aaafd3817db676d96099e1c2e1fd1c"
|
|
|
|
// TestChainSecurityProfile_GoldenVector_StrictPQ pins the canonical
|
|
// StrictPQ profile hash. A failing test means either the canonical
|
|
// profile changed, the encoding changed, or both — every such change
|
|
// MUST be intentional and re-pinned here in the same commit.
|
|
func TestChainSecurityProfile_GoldenVector_StrictPQ(t *testing.T) {
|
|
got, err := StrictPQ().ComputeHash()
|
|
if err != nil {
|
|
t.Fatalf("ComputeHash: %v", err)
|
|
}
|
|
gotHex := hex.EncodeToString(got[:])
|
|
if gotHex != strictPQGoldenHashHex {
|
|
t.Errorf("StrictPQ.ComputeHash() golden vector drift.\n got: %s\nwant: %s\nIf the schema change is intentional, re-pin strictPQGoldenHashHex to the 'got' value above.",
|
|
gotHex, strictPQGoldenHashHex)
|
|
}
|
|
}
|