mirror of
https://github.com/luxfi/sdk.git
synced 2026-11-03 10:23:31 +00:00
* wallet: PQAccount + ML-DSA-65 HD derivation + SLH-DSA-192 recovery
ML-DSA-65 native wallet accounts with 48-byte SHAKE256-384 AccountID
derived via cSHAKE-256 domain separation. KMAC/cSHAKE child seeds use
"LUX/WALLET/{IDENTITY,TX,SESSION,RECOVERY}/V1" customization strings.
HD path: m/44'/9000'/<chain>'/0'/<role>'/<account>', mirroring the F35
hardening landed in genesis/pkg/genesis/keys.go.
Recovery accounts use SLH-DSA-SHAKE-192s (FIPS 205, NIST Cat 3) for
stateless cold-key recovery on a separate hardened branch
m/44'/9000'/<chain>'/2'/<account>' so a leaked wallet branch cannot
derive recovery keys.
Keystore at rest: Argon2id (RFC 9106) → AES-256-GCM with AAD bound to
(scheme, role, chain, path). Private keys never persisted in plaintext.
WalletSchemeID/RecoverySchemeID declared locally (consensus/config has
not committed its WalletSchemeID byte yet); byte values match the raw
ML-DSA block (0x40..) so wire compatibility is preserved when the
consensus type lands.
AccountID KAT cross-checked against @noble/hashes/sha3-addons cshake256
(JS) — Go and TS implementations produce identical 48-byte AccountIDs
for the canonical test vector.
37 unit tests in wallet/account/, 7 in wallet/ (PQWallet facade).
Patch-bump.
* wallet/account: drop unused branchClassical constant
staticcheck U1000 catches it. The comment on branchPQ already
documents that branches 1' (secp256k1) and 2' (recovery) live in
sibling derivations, so the unused constant is dead documentation.
Patch-bump.
* sdk/wallet/account: use luxfi/crypto/pq/mldsa/mldsa65 wrapper
Switches PQAccount ML-DSA-65 key derivation, signing, and verification
from the upstream cloudflare/circl entry points to the luxfi/crypto
wrapper exposed at github.com/luxfi/crypto/pq/mldsa/mldsa65 (v1.18.4
adds the canonical NewKeyFromSeed/[]byte entry point we want).
- NewKeyFromSeed accepts []byte (any length) and internally cSHAKEs
to the 32-byte ξ FIPS 204 §5.1 consumes. The wallet already pre-
domain-separates by role through cSHAKE before handing the seed
off; the wrapper's per-parameter-set "LUX/FIPS204/MLDSA65/seed"
customization compounds with the wallet role customization without
overlap.
- Sign returns ([]byte, error) directly — replaces the SignTo(...)
out-parameter form.
- go.mod: bump luxfi/crypto v1.18.3 → v1.18.4.
Tests rebound to the luxfi/crypto package; sizes (PublicKeySize,
PrivateKeySize, SignatureSize) are re-exported aliases so existing
KAT and roundtrip tests continue to verify byte-identical values.
All 37 wallet/account tests pass under GOWORK=off.
* sdk/wallet/account: gofmt — unstuck CI lint job
Pure gofmt -w pass on four files flagged by golangci-lint in the
previous PR run. Doc-comment list indentation only — no code changes,
all tests green.
- wallet/account/cshake.go
- wallet/account/keystore.go
- wallet/account/scheme.go
- wallet/account/sign_tx_test.go
* sdk/wallet/keychain: hybrid AND-verifies (closes F112)
Adds VerifyHybridSecp256k1MLDSA and VerifyHybridSecp256k1SLHDSA — the
AND-verifying hybrid signature verifiers that close red-team F112.
Previously the hybrid signature format produced by PQSigner.Sign
(length-prefixed classical || PQ concatenation) had no verifier in
this package, leaving every consumer to interpret it. The implicit
"either half verifies" OR semantics is as strong as the WEAKER of the
two halves (ECDSA in the production hybrids) — a forged hybrid
carrying a valid ECDSA half plus random "PQ" bytes would pass any OR
verifier.
The new verifiers require BOTH halves to verify under their respective
public keys. Failure on either half returns a typed sentinel
(ErrHybridSigClassicalFailed / ErrHybridSigPQFailed) without short-
circuiting on the classical refusal — both halves are checked before
the AND result is computed so timing-side channels do not leak which
half failed.
SplitHybridSignature is exported so callers can dispatch verification
through their own keying setup without re-implementing the wire-format
parsing.
Adds 9 regression tests:
- happy-path AND accepts (MLDSA + SLHDSA)
- tampered classical → ErrHybridSigClassicalFailed
- tampered PQ → ErrHybridSigPQFailed
- ECDSA-valid || random PQ bytes → ErrHybridSigPQFailed (the
canonical F112 attack)
- wrong message → either-half-fail
- truncated blob → ErrHybridSigTruncated
- wire-format round-trip
Closes red-team F112.
321 lines
10 KiB
Go
321 lines
10 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package keychain
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/luxfi/crypto/mldsa"
|
|
"github.com/luxfi/crypto/secp256k1"
|
|
"github.com/luxfi/crypto/slhdsa"
|
|
)
|
|
|
|
// TestVerifyHybridMLDSA_AndSemantics_AcceptsBoth proves the hybrid
|
|
// verifier accepts a signature where BOTH halves verify. Baseline
|
|
// happy-path check for the AND verification path.
|
|
func TestVerifyHybridMLDSA_AndSemantics_AcceptsBoth(t *testing.T) {
|
|
classical, err := secp256k1.NewPrivateKey()
|
|
if err != nil {
|
|
t.Fatalf("secp256k1 keygen: %v", err)
|
|
}
|
|
pq, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA44)
|
|
if err != nil {
|
|
t.Fatalf("mldsa keygen: %v", err)
|
|
}
|
|
|
|
kc := NewPQKeychain(KeyTypeHybridSecp256k1MLDSA44)
|
|
addr := kc.AddHybrid(classical, pq)
|
|
signer, _ := kc.GetPQSigner(addr)
|
|
|
|
msg := []byte("AND-verify happy path message")
|
|
sig, err := signer.Sign(msg)
|
|
if err != nil {
|
|
t.Fatalf("Sign: %v", err)
|
|
}
|
|
|
|
classicalPub := classical.PublicKey()
|
|
pqPub := pq.PublicKey
|
|
if err := VerifyHybridSecp256k1MLDSA(classicalPub, pqPub, msg, sig); err != nil {
|
|
t.Fatalf("VerifyHybridSecp256k1MLDSA rejected a valid hybrid signature: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyHybridMLDSA_AndSemantics_RejectsTamperedClassical proves
|
|
// the F112 closure: a hybrid signature with a real PQ half but a
|
|
// tampered classical half MUST fail. The previous OR-semantics would
|
|
// have accepted this as long as the PQ half verified — that loophole
|
|
// is now closed.
|
|
func TestVerifyHybridMLDSA_AndSemantics_RejectsTamperedClassical(t *testing.T) {
|
|
classical, err := secp256k1.NewPrivateKey()
|
|
if err != nil {
|
|
t.Fatalf("secp256k1 keygen: %v", err)
|
|
}
|
|
pq, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA44)
|
|
if err != nil {
|
|
t.Fatalf("mldsa keygen: %v", err)
|
|
}
|
|
|
|
kc := NewPQKeychain(KeyTypeHybridSecp256k1MLDSA44)
|
|
addr := kc.AddHybrid(classical, pq)
|
|
signer, _ := kc.GetPQSigner(addr)
|
|
|
|
msg := []byte("AND-verify rejects tampered classical")
|
|
sig, err := signer.Sign(msg)
|
|
if err != nil {
|
|
t.Fatalf("Sign: %v", err)
|
|
}
|
|
|
|
// Flip a byte in the classical half (offset 5: 2-byte len prefix + 3
|
|
// bytes into the secp256k1 signature). The PQ half remains valid.
|
|
tampered := append([]byte(nil), sig...)
|
|
tampered[5] ^= 0xFF
|
|
|
|
classicalPub := classical.PublicKey()
|
|
pqPub := pq.PublicKey
|
|
err = VerifyHybridSecp256k1MLDSA(classicalPub, pqPub, msg, tampered)
|
|
if err == nil {
|
|
t.Fatal("AND-verifier accepted a hybrid with tampered classical half: F112 regressed")
|
|
}
|
|
if !errors.Is(err, ErrHybridSigClassicalFailed) {
|
|
t.Errorf("expected ErrHybridSigClassicalFailed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyHybridMLDSA_AndSemantics_RejectsTamperedPQ proves the
|
|
// other axis of F112: a hybrid with a valid classical half but a
|
|
// tampered PQ half MUST fail. Previously the OR-semantics would have
|
|
// accepted because classical alone passed; AND now refuses.
|
|
func TestVerifyHybridMLDSA_AndSemantics_RejectsTamperedPQ(t *testing.T) {
|
|
classical, err := secp256k1.NewPrivateKey()
|
|
if err != nil {
|
|
t.Fatalf("secp256k1 keygen: %v", err)
|
|
}
|
|
pq, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA44)
|
|
if err != nil {
|
|
t.Fatalf("mldsa keygen: %v", err)
|
|
}
|
|
|
|
kc := NewPQKeychain(KeyTypeHybridSecp256k1MLDSA44)
|
|
addr := kc.AddHybrid(classical, pq)
|
|
signer, _ := kc.GetPQSigner(addr)
|
|
|
|
msg := []byte("AND-verify rejects tampered PQ")
|
|
sig, err := signer.Sign(msg)
|
|
if err != nil {
|
|
t.Fatalf("Sign: %v", err)
|
|
}
|
|
|
|
// Parse to find the PQ-half offset then flip a byte at the midpoint.
|
|
classicalSig, pqSig, err := SplitHybridSignature(sig)
|
|
if err != nil {
|
|
t.Fatalf("SplitHybridSignature: %v", err)
|
|
}
|
|
pqOffset := 2 + len(classicalSig) + 2 // [len][cl][len][pq]
|
|
tampered := append([]byte(nil), sig...)
|
|
tampered[pqOffset+len(pqSig)/2] ^= 0xFF
|
|
|
|
classicalPub := classical.PublicKey()
|
|
pqPub := pq.PublicKey
|
|
err = VerifyHybridSecp256k1MLDSA(classicalPub, pqPub, msg, tampered)
|
|
if err == nil {
|
|
t.Fatal("AND-verifier accepted a hybrid with tampered PQ half: F112 regressed")
|
|
}
|
|
if !errors.Is(err, ErrHybridSigPQFailed) {
|
|
t.Errorf("expected ErrHybridSigPQFailed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyHybridMLDSA_AndSemantics_RejectsRandomPQ is the canonical
|
|
// F112 anti-regression: a real ECDSA signature concatenated with
|
|
// random bytes labelled as "PQ" must be rejected. Previously the OR
|
|
// semantics let this through.
|
|
func TestVerifyHybridMLDSA_AndSemantics_RejectsRandomPQ(t *testing.T) {
|
|
classical, err := secp256k1.NewPrivateKey()
|
|
if err != nil {
|
|
t.Fatalf("secp256k1 keygen: %v", err)
|
|
}
|
|
pq, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA44)
|
|
if err != nil {
|
|
t.Fatalf("mldsa keygen: %v", err)
|
|
}
|
|
|
|
kc := NewPQKeychain(KeyTypeHybridSecp256k1MLDSA44)
|
|
addr := kc.AddHybrid(classical, pq)
|
|
signer, _ := kc.GetPQSigner(addr)
|
|
|
|
msg := []byte("AND-verify rejects random PQ half")
|
|
sig, err := signer.Sign(msg)
|
|
if err != nil {
|
|
t.Fatalf("Sign: %v", err)
|
|
}
|
|
|
|
// Replace the entire PQ half with random bytes of the same length.
|
|
classicalSig, pqSig, err := SplitHybridSignature(sig)
|
|
if err != nil {
|
|
t.Fatalf("SplitHybridSignature: %v", err)
|
|
}
|
|
pqOffset := 2 + len(classicalSig) + 2
|
|
tampered := append([]byte(nil), sig...)
|
|
if _, err := rand.Read(tampered[pqOffset : pqOffset+len(pqSig)]); err != nil {
|
|
t.Fatalf("rand.Read: %v", err)
|
|
}
|
|
|
|
classicalPub := classical.PublicKey()
|
|
pqPub := pq.PublicKey
|
|
err = VerifyHybridSecp256k1MLDSA(classicalPub, pqPub, msg, tampered)
|
|
if err == nil {
|
|
t.Fatal("AND-verifier accepted ECDSA || random: F112 regressed (the headline attack)")
|
|
}
|
|
if !errors.Is(err, ErrHybridSigPQFailed) {
|
|
t.Errorf("expected ErrHybridSigPQFailed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyHybridMLDSA_AndSemantics_RejectsWrongMessage proves the
|
|
// verifier refuses a valid hybrid signature against a different
|
|
// message — basic signature integrity.
|
|
func TestVerifyHybridMLDSA_AndSemantics_RejectsWrongMessage(t *testing.T) {
|
|
classical, _ := secp256k1.NewPrivateKey()
|
|
pq, _ := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA44)
|
|
|
|
kc := NewPQKeychain(KeyTypeHybridSecp256k1MLDSA44)
|
|
addr := kc.AddHybrid(classical, pq)
|
|
signer, _ := kc.GetPQSigner(addr)
|
|
|
|
msg := []byte("original message")
|
|
sig, _ := signer.Sign(msg)
|
|
|
|
wrongMsg := []byte("different message")
|
|
classicalPub := classical.PublicKey()
|
|
pqPub := pq.PublicKey
|
|
err := VerifyHybridSecp256k1MLDSA(classicalPub, pqPub, wrongMsg, sig)
|
|
if err == nil {
|
|
t.Fatal("AND-verifier accepted a signature against wrong message")
|
|
}
|
|
// Either half can fail first; both are acceptable failure modes.
|
|
if !errors.Is(err, ErrHybridSigClassicalFailed) && !errors.Is(err, ErrHybridSigPQFailed) {
|
|
t.Errorf("expected ErrHybridSig{Classical,PQ}Failed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyHybridMLDSA_AndSemantics_RejectsTruncated proves the
|
|
// verifier refuses a truncated signature blob.
|
|
func TestVerifyHybridMLDSA_AndSemantics_RejectsTruncated(t *testing.T) {
|
|
classical, _ := secp256k1.NewPrivateKey()
|
|
pq, _ := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA44)
|
|
classicalPub := classical.PublicKey()
|
|
pqPub := pq.PublicKey
|
|
|
|
cases := map[string][]byte{
|
|
"empty": {},
|
|
"one byte": {0x00},
|
|
"len only": {0x00, 0x40},
|
|
}
|
|
for name, sig := range cases {
|
|
err := VerifyHybridSecp256k1MLDSA(classicalPub, pqPub, []byte("msg"), sig)
|
|
if err == nil {
|
|
t.Errorf("%s: AND-verifier accepted a truncated signature", name)
|
|
continue
|
|
}
|
|
if !errors.Is(err, ErrHybridSigTruncated) {
|
|
t.Errorf("%s: expected ErrHybridSigTruncated, got: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestVerifyHybridSLHDSA_AndSemantics_AcceptsBoth covers the
|
|
// secp256k1+SLH-DSA hybrid happy path. F112 closure is symmetric
|
|
// across the two PQ choices.
|
|
func TestVerifyHybridSLHDSA_AndSemantics_AcceptsBoth(t *testing.T) {
|
|
classical, _ := secp256k1.NewPrivateKey()
|
|
pq, err := slhdsa.GenerateKey(rand.Reader, slhdsa.SHA2_128s)
|
|
if err != nil {
|
|
t.Fatalf("slhdsa keygen: %v", err)
|
|
}
|
|
|
|
kc := NewPQKeychain(KeyTypeHybridSecp256k1SLHDSA128)
|
|
addr := kc.AddHybrid(classical, pq)
|
|
signer, _ := kc.GetPQSigner(addr)
|
|
|
|
msg := []byte("SLH-DSA AND-verify happy path")
|
|
sig, err := signer.Sign(msg)
|
|
if err != nil {
|
|
t.Fatalf("Sign: %v", err)
|
|
}
|
|
|
|
classicalPub := classical.PublicKey()
|
|
pqPub := pq.PublicKey
|
|
if err := VerifyHybridSecp256k1SLHDSA(classicalPub, pqPub, msg, sig); err != nil {
|
|
t.Fatalf("VerifyHybridSecp256k1SLHDSA rejected a valid hybrid: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyHybridSLHDSA_AndSemantics_RejectsRandomPQ — F112 closure
|
|
// on the SLH-DSA axis.
|
|
func TestVerifyHybridSLHDSA_AndSemantics_RejectsRandomPQ(t *testing.T) {
|
|
classical, _ := secp256k1.NewPrivateKey()
|
|
pq, _ := slhdsa.GenerateKey(rand.Reader, slhdsa.SHA2_128s)
|
|
|
|
kc := NewPQKeychain(KeyTypeHybridSecp256k1SLHDSA128)
|
|
addr := kc.AddHybrid(classical, pq)
|
|
signer, _ := kc.GetPQSigner(addr)
|
|
|
|
msg := []byte("SLH-DSA AND-verify rejects random PQ")
|
|
sig, _ := signer.Sign(msg)
|
|
|
|
classicalSig, pqSig, err := SplitHybridSignature(sig)
|
|
if err != nil {
|
|
t.Fatalf("SplitHybridSignature: %v", err)
|
|
}
|
|
pqOffset := 2 + len(classicalSig) + 2
|
|
tampered := append([]byte(nil), sig...)
|
|
if _, err := rand.Read(tampered[pqOffset : pqOffset+len(pqSig)]); err != nil {
|
|
t.Fatalf("rand.Read: %v", err)
|
|
}
|
|
|
|
classicalPub := classical.PublicKey()
|
|
pqPub := pq.PublicKey
|
|
err = VerifyHybridSecp256k1SLHDSA(classicalPub, pqPub, msg, tampered)
|
|
if err == nil {
|
|
t.Fatal("SLH-DSA AND-verifier accepted ECDSA || random: F112 regressed")
|
|
}
|
|
if !errors.Is(err, ErrHybridSigPQFailed) {
|
|
t.Errorf("expected ErrHybridSigPQFailed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSplitHybridSignature_Roundtrip proves the parsing primitive
|
|
// matches the producer's layout — a regression check on the wire
|
|
// format that the verifier depends on.
|
|
func TestSplitHybridSignature_Roundtrip(t *testing.T) {
|
|
classical, _ := secp256k1.NewPrivateKey()
|
|
pq, _ := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA44)
|
|
|
|
kc := NewPQKeychain(KeyTypeHybridSecp256k1MLDSA44)
|
|
addr := kc.AddHybrid(classical, pq)
|
|
signer, _ := kc.GetPQSigner(addr)
|
|
|
|
msg := []byte("roundtrip")
|
|
sig, _ := signer.Sign(msg)
|
|
|
|
cl, pqHalf, err := SplitHybridSignature(sig)
|
|
if err != nil {
|
|
t.Fatalf("Split: %v", err)
|
|
}
|
|
if len(cl) == 0 || len(pqHalf) == 0 {
|
|
t.Fatalf("Split produced empty halves: cl=%d pq=%d", len(cl), len(pqHalf))
|
|
}
|
|
// Both halves must equal the directly-signed primitives.
|
|
hashed := sha256.Sum256(msg)
|
|
if !classical.PublicKey().VerifyHash(hashed[:], cl) {
|
|
t.Errorf("classical half didn't verify directly")
|
|
}
|
|
if !pq.PublicKey.VerifySignature(msg, pqHalf) {
|
|
t.Errorf("PQ half didn't verify directly")
|
|
}
|
|
}
|