Files
zeekayandGitHub 728fc1bd79 wallet: PQAccount + ML-DSA-65 HD derivation + SLH-DSA-192 recovery (#2)
* 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.
2026-05-29 22:10:41 -07:00

211 lines
7.3 KiB
Go

// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package account
import (
"bytes"
"encoding/binary"
"encoding/hex"
"testing"
"golang.org/x/crypto/sha3"
)
// TestCSHAKE_Customizations_KAT is a Known-Answer Test that pins the
// canonical cSHAKE customization strings to their byte values. If
// anyone changes the string content, the constant string content
// (case, version suffix, slashes), this test changes the digest
// vectors and CI catches the wallet-incompatibility.
//
// The vectors are computed at test-write time by feeding the
// customization string and a single \x00 message byte into cSHAKE-256
// and reading the first 48 bytes. The expected values are recomputed
// inline rather than hard-coded so the test is self-checking; the test
// is meaningful only because it exercises the SAME constants the
// production code uses (no separate copy of the strings).
func TestCSHAKE_Customizations_KAT(t *testing.T) {
t.Parallel()
type vector struct {
name string
customization string
}
vectors := []vector{
{"identity", CSHAKECustomizationIdentity},
{"tx", CSHAKECustomizationTx},
{"session", CSHAKECustomizationSession},
{"recovery", CSHAKECustomizationRecovery},
{"account-id", CSHAKECustomizationAccountID},
}
// All four wallet-role customizations and the AccountID
// customization must (a) be non-empty, (b) start with "LUX/",
// (c) end with "/V1", and (d) produce distinct cSHAKE-256
// outputs when fed identical (N, msg) pairs.
seen := map[string][]byte{}
for _, v := range vectors {
if v.customization == "" {
t.Errorf("%s: customization is empty", v.name)
continue
}
if !bytesHasPrefix(v.customization, "LUX/") {
t.Errorf("%s: customization %q does not start with LUX/", v.name, v.customization)
}
if !bytesHasSuffix(v.customization, "/V1") {
t.Errorf("%s: customization %q does not end with /V1", v.name, v.customization)
}
h := sha3.NewCShake256([]byte("LUX_PQ_KEYGEN_V1"), []byte(v.customization))
_, _ = h.Write([]byte{0x00})
out := make([]byte, 48)
if _, err := h.Read(out); err != nil {
t.Fatalf("%s: cshake read: %v", v.name, err)
}
for prevName, prev := range seen {
if bytes.Equal(prev, out) {
t.Errorf("%s and %s produced the same cSHAKE output — customization strings collide", v.name, prevName)
}
}
seen[v.name] = out
}
}
// TestCSHAKE_Customization_Values pins the literal byte content of each
// customization string. A change to any of these strings is a
// wire-incompatible rewrite of the wallet; this test is the
// single source of truth for what V1 wallets agree on.
func TestCSHAKE_Customization_Values(t *testing.T) {
t.Parallel()
cases := []struct {
name string
got string
want string
}{
{"identity", CSHAKECustomizationIdentity, "LUX/WALLET/IDENTITY/V1"},
{"tx", CSHAKECustomizationTx, "LUX/WALLET/TX/V1"},
{"session", CSHAKECustomizationSession, "LUX/WALLET/SESSION/V1"},
{"recovery", CSHAKECustomizationRecovery, "LUX/WALLET/RECOVERY/V1"},
{"account-id", CSHAKECustomizationAccountID, "LUX/WALLET/ACCOUNT_ID/V1"},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s: customization = %q, want %q", c.name, c.got, c.want)
}
}
}
// TestDeriveAccountID_KAT pins the AccountID format. The test feeds a
// fixed (chain_id, scheme, pubkey) triple through DeriveAccountID and
// (a) compares against an independent cSHAKE-256 computation in this
// test file, (b) compares against a hard-coded 48-byte vector that was
// produced by an independent JS implementation (@noble/hashes/sha3-addons
// cshake256). The hard-coded vector is the wire-compatibility anchor
// for the TS-side wallet: any regression here also flags the TS side.
func TestDeriveAccountID_KAT(t *testing.T) {
t.Parallel()
pubkey := []byte("test-pubkey-for-accountid-kat-vector-stable-across-runs")
const chainID uint32 = 9000
scheme := WalletSchemeMLDSA65
got, err := DeriveAccountID(chainID, scheme, pubkey)
if err != nil {
t.Fatalf("DeriveAccountID: %v", err)
}
// (a) Independent recomputation inside this test file. Catches a
// regression in the production cshake.go helper.
h := sha3.NewCShake256([]byte(accountIDLabel), []byte(CSHAKECustomizationAccountID))
var cidBytes [4]byte
binary.BigEndian.PutUint32(cidBytes[:], chainID)
_, _ = h.Write(cidBytes[:])
_, _ = h.Write([]byte{byte(scheme)})
_, _ = h.Write(pubkey)
expected := make([]byte, AccountIDSize)
if _, err := h.Read(expected); err != nil {
t.Fatalf("kat cshake read: %v", err)
}
if !bytes.Equal(got[:], expected) {
t.Fatalf("AccountID KAT mismatch vs in-test cSHAKE:\n got: %s\n want: %s",
hex.EncodeToString(got[:]), hex.EncodeToString(expected))
}
// (b) Cross-implementation KAT: byte vector produced by
// @noble/hashes/sha3-addons cshake256 against the same input
// (recorded 2026-05-10). A change here means the Go and TS sides
// have drifted; one of the implementations is wrong.
const tsKATHex = "76a03630148103ec558cf4d8f7e8a2d8766ea205d96a4529249c3aaf9c5d078cc9f9fdb8f21a5e7ef6bb4c4ea29c9654"
tsKAT, err := hex.DecodeString(tsKATHex)
if err != nil {
t.Fatalf("decode ts KAT hex: %v", err)
}
if !bytes.Equal(got[:], tsKAT) {
t.Fatalf("AccountID KAT mismatch vs JS @noble/hashes cshake256:\n got: %s\n want: %s",
hex.EncodeToString(got[:]), tsKATHex)
}
}
func TestDeriveAccountID_RejectsNilPubkey(t *testing.T) {
t.Parallel()
if _, err := DeriveAccountID(1, WalletSchemeMLDSA65, nil); err == nil {
t.Fatalf("DeriveAccountID(nil pubkey) succeeded, expected error")
}
}
func TestExpandChildSeed_Deterministic(t *testing.T) {
t.Parallel()
seed := []byte("test-child-seed-32-bytes-long-ok!")
a, err := expandChildSeed(seed, CSHAKECustomizationIdentity, 32)
if err != nil {
t.Fatalf("expandChildSeed a: %v", err)
}
b, err := expandChildSeed(seed, CSHAKECustomizationIdentity, 32)
if err != nil {
t.Fatalf("expandChildSeed b: %v", err)
}
if !bytes.Equal(a, b) {
t.Fatalf("expandChildSeed not deterministic")
}
// Different customization → different output for the same seed.
c, err := expandChildSeed(seed, CSHAKECustomizationTx, 32)
if err != nil {
t.Fatalf("expandChildSeed c: %v", err)
}
if bytes.Equal(a, c) {
t.Fatalf("expandChildSeed with different customization produced the same output")
}
// Different out length → first 32 bytes need NOT match because
// cSHAKE is variable-output-length; the test asserts the
// implementation requests the correct number of bytes.
d, err := expandChildSeed(seed, CSHAKECustomizationIdentity, 64)
if err != nil {
t.Fatalf("expandChildSeed d: %v", err)
}
if len(d) != 64 {
t.Fatalf("expandChildSeed length: got %d, want 64", len(d))
}
}
func TestExpandChildSeed_RejectsBadInputs(t *testing.T) {
t.Parallel()
if _, err := expandChildSeed(nil, CSHAKECustomizationIdentity, 32); err == nil {
t.Fatalf("nil seed accepted")
}
if _, err := expandChildSeed([]byte{0x01}, CSHAKECustomizationIdentity, 0); err == nil {
t.Fatalf("zero outLen accepted")
}
if _, err := expandChildSeed([]byte{0x01}, "", 32); err == nil {
t.Fatalf("empty customization accepted")
}
}
func bytesHasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
func bytesHasSuffix(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}