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

125 lines
5.1 KiB
Go

// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package account
import (
"encoding/binary"
"fmt"
"golang.org/x/crypto/sha3"
)
// CSHAKE customization strings, per NIST SP 800-185 §3.3 (cSHAKE). The
// customization string ("S") is what makes two cSHAKE invocations with the
// same input message produce independent outputs. We use it three ways:
//
// 1. Per-role child-seed expansion. The BIP-32 child seed at
// m/44'/9000'/<chain>'/0'/<role>'/<account>' is expanded through
// cSHAKE-256 with the role's customization string into the 32-byte ξ
// that FIPS 204 §5.1 KeyGen consumes. Without the customization, a
// single leaked child seed would be re-usable across roles.
//
// 2. AccountID derivation. AccountIDs hash the (chain, scheme, pubkey)
// triple under cSHAKE-256 with the "ACCOUNT_ID" customization so the
// same pubkey under different chains/schemes produces independent
// AccountIDs.
//
// 3. Recovery-seed expansion. SLH-DSA recovery accounts use the same
// role-customized cSHAKE-256 to expand the BIP-32 child seed into the
// three n-byte seeds (skSeed, skPrf, pkSeed) that FIPS 205 KeyGen
// consumes.
//
// The strings are versioned ("/V1") so a future scheme rotation (e.g.,
// migration to ML-DSA-87 by default or to a new derivation tree shape) can
// land without invalidating already-issued V1 accounts.
const (
CSHAKECustomizationIdentity = "LUX/WALLET/IDENTITY/V1"
CSHAKECustomizationTx = "LUX/WALLET/TX/V1"
CSHAKECustomizationSession = "LUX/WALLET/SESSION/V1"
CSHAKECustomizationRecovery = "LUX/WALLET/RECOVERY/V1"
CSHAKECustomizationAccountID = "LUX/WALLET/ACCOUNT_ID/V1"
)
// accountIDLabel is the function-name (N) prefix passed to cSHAKE-256 for
// AccountID derivation. NIST SP 800-185 §3.3 reserves N for the calling
// function's name; AccountID is a Lux-specific function.
const accountIDLabel = "LUX_ACCOUNT_V1"
// AccountIDSize is the fixed 48-byte length of an AccountID. 48 bytes ==
// 384 bits == matches the SHAKE256-384 output the consensus layer expects
// for identity hashes (ChainSecurityProfile.MinHashOutputBits >= 384 on
// strict-PQ). Bigger AccountIDs would not give more collision resistance
// (we're already past the SHA-384 birthday bound for any realistic chain
// size) and smaller ones would force a hash-suite mismatch with strict-PQ.
const AccountIDSize = 48
// AccountID is the 48-byte deterministic identifier for a wallet account.
// Computed as cSHAKE-256(N="LUX_ACCOUNT_V1", S="LUX/WALLET/ACCOUNT_ID/V1",
// msg = u32be(chain_id) || u8(scheme) || pubkey, outlen = 48 bytes).
type AccountID [AccountIDSize]byte
// DeriveAccountID returns the canonical 48-byte AccountID for a (chain,
// scheme, pubkey) triple. Pure function; same inputs → same output.
//
// Errors only on pubkey == nil. Empty pubkey is permitted (yields a
// well-defined "uninitialised account" AccountID); callers that want to
// reject empty pubkeys should do so before calling.
func DeriveAccountID(chainID uint32, scheme WalletSchemeID, pubkey []byte) (AccountID, error) {
if pubkey == nil {
return AccountID{}, fmt.Errorf("account: pubkey is nil")
}
h := sha3.NewCShake256([]byte(accountIDLabel), []byte(CSHAKECustomizationAccountID))
var chainBytes [4]byte
binary.BigEndian.PutUint32(chainBytes[:], chainID)
if _, err := h.Write(chainBytes[:]); err != nil {
return AccountID{}, fmt.Errorf("cshake write chain: %w", err)
}
if _, err := h.Write([]byte{byte(scheme)}); err != nil {
return AccountID{}, fmt.Errorf("cshake write scheme: %w", err)
}
if _, err := h.Write(pubkey); err != nil {
return AccountID{}, fmt.Errorf("cshake write pubkey: %w", err)
}
var out AccountID
if _, err := h.Read(out[:]); err != nil {
return AccountID{}, fmt.Errorf("cshake read account-id: %w", err)
}
return out, nil
}
// expandChildSeed expands a BIP-32 child seed into a fixed-length keygen
// input under a role-bound cSHAKE-256 customization. The function-name (N)
// is "LUX_PQ_KEYGEN_V1" so the same role customization is unambiguous
// across (a) AccountID derivation and (b) keygen seed derivation: callers
// reading the cSHAKE transcript can tell which Lux function produced an
// output by looking at N, even if the customization (S) is the same.
//
// outLen must be > 0. The output is suitable for direct use as a FIPS-204
// ξ (outLen = 32) or as the random-stream input to a deterministic
// FIPS-205 keygen (outLen = 3 * n).
func expandChildSeed(childSeed []byte, customization string, outLen int) ([]byte, error) {
if len(childSeed) == 0 {
return nil, fmt.Errorf("account: child seed is empty")
}
if outLen <= 0 {
return nil, fmt.Errorf("account: outLen=%d must be positive", outLen)
}
if customization == "" {
return nil, fmt.Errorf("account: customization is empty")
}
h := sha3.NewCShake256([]byte("LUX_PQ_KEYGEN_V1"), []byte(customization))
if _, err := h.Write(childSeed); err != nil {
return nil, fmt.Errorf("cshake write child seed: %w", err)
}
out := make([]byte, outLen)
if _, err := h.Read(out); err != nil {
return nil, fmt.Errorf("cshake read: %w", err)
}
return out, nil
}