Files
zeekay fe69a14d1e feat(maker): ML-DSA-65 post-quantum signing via decomplected authScheme
Adds FIPS 204 ML-DSA-65 alongside secp256k1, selectable via MAKER_SCHEME=mldsa. Decomplected: dexauth.go factors the signature primitive into an authScheme interface (id/sign/pub/account) with two complete impls (secp, mldsa); the Signer composes one and assembles the envelope ONE uniform way — no per-scheme branching, one account-fold, one wire format. NewMLDSASignerFromSeed derives a deterministic PQ identity (keccak-stream CSPRNG). Round-trip test covers BOTH schemes against the venue verify algorithm (lx.RecoverAuthAddress + Account16). secp unchanged. Verified: go test ./... green; secp seeds a live custody-backed LUX/LUSD market on spark (real mainnet LUSD, 2-sided). Live mldsa pending venue rebuild to current pkg/dchain (spark dexd v1.0.0 envelope skew).
2026-06-26 18:03:22 -07:00

258 lines
11 KiB
Go

package maker
import (
cryptorand "crypto/rand"
"crypto/ecdsa"
"crypto/sha256"
"encoding/binary"
"fmt"
"strings"
"github.com/luxfi/crypto"
"github.com/luxfi/crypto/mldsa"
)
// dexauth.go is the maker's AUTHORIZATION half of the venue D-Chain wire. The live
// VM (github.com/luxfi/dex/pkg/dchain) REQUIRES every money-moving op
// (deposit/place/cancel/submit) to carry a signed envelope appended to the frozen
// body: the RPC payload is [frozen body] ‖ [auth envelope]. dex.go builds the body;
// this file builds the envelope and binds it to the maker's account, so the venue's
// verifyTxSignature (pkg/dchain/auth.go) recovers the signer, folds it to the
// 16-byte settlement account, and confirms it equals the account the body asserts.
//
// DECOMPLECTED. Two orthogonal concerns, never braided:
//
// - authScheme : the SIGNATURE primitive. Turns the 32-byte auth digest into
// (sig, pub) and folds its key to the wire account. Two complete,
// independent implementations — secp256k1 and ML-DSA-65 — neither
// of which knows the wire format.
// - Signer : the ENVELOPE assembly. Composes ONE authScheme and builds the
// digest + envelope + wire-user uniformly, the same single way for
// every scheme. It never reaches into a key.
//
// So adding a scheme is adding one authScheme value; the wire path is untouched.
//
// WIRE/AUTH PARITY — byte-identical to the canonical signer (dex/cmd/dexbench) and
// the server gate (dex/pkg/dchain auth.go + tx.go). A client cannot import the
// cgo-tagged pkg/dchain, so the three small auth pieces are RE-DEFINED here and
// MUST stay byte-identical or the venue refuses the signature:
//
// - account fold : keccak256(accountDomain ‖ scheme[1] ‖ addr[20])[:16]
// - auth digest : keccak256(txAuthDomain ‖ scheme[1] ‖ type[1] ‖ nonce[8] ‖ body)
// - auth envelope : scheme[1] ‖ nonce[8] ‖ sigLen[2] ‖ pubLen[2] ‖ sig ‖ pub
//
// crypto.Keccak256(a,b,...) hashes the concatenation byte-identically to the
// server's hash.ComputeKeccak256Array, so the digests (hence the recovered
// account) match across client and consensus. auth_test.go proves the round-trip.
// Domain-separation tags, byte-identical to dex/pkg/dchain/auth.go.
const (
txAuthDomain = "lux.dchain.tx.auth.v1"
accountDomain = "lux.dchain.account.v1"
)
// Auth scheme bytes (pkg/lx/auth.go AuthScheme). secp256k1 recovers the key from
// the signature (no pubkey on the wire); ML-DSA-65 carries the 1952-byte pubkey
// inline (FIPS 204, post-quantum). A Signer is one scheme; the envelope encoding
// is uniform across both — pubLen just goes to 0 for the recovered scheme.
const (
schemeSecp = byte(0)
schemeMLDSA = byte(1)
)
// Auth tx-type bytes. The auth digest binds the operation TYPE so a signed place
// can never be replayed as a submit. The VALUES are pkg/dchain/tx.go's TxType
// iota+1 (TxEnsureMarket=1 … TxOpenMarket=7); only the money-moving four are ever
// signed (ensure/open are admin, unauthenticated).
const (
txTypeEnsure byte = 1
txTypePlace byte = 2
txTypeCancel byte = 3
txTypeSubmit byte = 4
txTypeDeposit byte = 5
txTypeWithdraw byte = 6
txTypeOpenMarket byte = 7
)
// authHeaderSize is the fixed prefix of an encoded envelope: scheme[1] + nonce[8]
// + sigLen[2] + pubLen[2]. Byte-identical to dchain auth.go.
const authHeaderSize = 1 + 8 + 2 + 2
// secpSigSize is the 65-byte r‖s‖v signature crypto.Sign returns for secp256k1.
const secpSigSize = 65
// accountFold is the ONE derivation from a verified signer address to the 16-byte
// settlement account, as the string the frozen body's user[16] field carries:
// keccak256(accountDomain ‖ scheme ‖ addr)[:16]. Byte-identical to dchain
// Account16; the scheme byte makes a classical and a PQ key map to DISTINCT
// accounts even for the same person.
func accountFold(scheme byte, addr []byte) string {
return string(crypto.Keccak256([]byte(accountDomain), []byte{scheme}, addr)[:userSize])
}
// authScheme is the orthogonal signature primitive the Signer composes. An
// implementation owns exactly one key, signs a digest, and reports its scheme
// byte, inline pubkey (empty if recovered), and wire account. It NEVER touches the
// envelope/digest layout — that is the Signer's single concern.
type authScheme interface {
id() byte // the scheme byte
sign(digest []byte) []byte // signature over the 32-byte auth digest
pub() []byte // inline pubkey, or nil for a recovered scheme
account() string // string(Account16) — the wire user this key asserts
}
// --- secp256k1 (classical) -------------------------------------------------
type secpScheme struct {
priv *ecdsa.PrivateKey
acct string
}
func newSecpScheme(priv *ecdsa.PrivateKey) *secpScheme {
addr := crypto.PubkeyToAddress(priv.PublicKey).Bytes() // 20-byte keccak address
return &secpScheme{priv: priv, acct: accountFold(schemeSecp, addr)}
}
func (s *secpScheme) id() byte { return schemeSecp }
func (s *secpScheme) pub() []byte { return nil } // recovered from the signature
func (s *secpScheme) account() string { return s.acct }
func (s *secpScheme) sign(digest []byte) []byte {
sig, err := crypto.Sign(digest, s.priv) // 65-byte r‖s‖v
if err != nil {
// Unreachable: digest is a 32-byte keccak256 and priv is validated at
// construction (ToECDSA/HexToECDSA reject an off-curve/out-of-range key).
panic(fmt.Sprintf("maker: secp256k1 sign: %v", err))
}
return sig
}
// --- ML-DSA-65 (FIPS 204, post-quantum) ------------------------------------
type mldsaScheme struct {
priv *mldsa.PrivateKey
pubKey []byte // 1952 bytes, carried inline on every envelope
acct string
}
func newMLDSAScheme(priv *mldsa.PrivateKey) *mldsaScheme {
pub := priv.Public().(*mldsa.PublicKey).Bytes()
h := sha256.Sum256(pub) // lx.AddressFromMLDSAPubKey = sha256(pub)[:20]
return &mldsaScheme{priv: priv, pubKey: pub, acct: accountFold(schemeMLDSA, h[:20])}
}
func (m *mldsaScheme) id() byte { return schemeMLDSA }
func (m *mldsaScheme) pub() []byte { return m.pubKey }
func (m *mldsaScheme) account() string { return m.acct }
func (m *mldsaScheme) sign(digest []byte) []byte {
// Hedged FIPS 204 §5.2 signing with ctx=nil, which pairs with the venue's
// VerifySignature(digest, sig) (also ctx=nil). 3293-byte signature.
sig, err := m.priv.SignCtx(cryptorand.Reader, digest, nil)
if err != nil {
panic(fmt.Sprintf("maker: ML-DSA-65 sign: %v", err))
}
return sig
}
// --- Signer: the one uniform envelope assembly -----------------------------
// Signer composes one authScheme and produces the per-op auth envelope dex.go
// appends to a money-moving frame. One Signer = one venue account; nonces are
// owned by the DEXClient (one monotonic stream per client), not the Signer.
type Signer struct {
scheme authScheme
}
// NewSignerFromSeed derives a DETERMINISTIC secp256k1 signer from a seed label: the
// key is crypto.ToECDSA(keccak256("maker.secp.v1" ‖ seed)), re-hashing on the rare
// out-of-range scalar exactly as dexbench.newAccount does, so the same seed yields
// the same account byte-for-byte across hosts and restarts. Devnet path (the maker
// user label is the seed); on mainnet sign with the real key via NewSignerFromHexKey.
func NewSignerFromSeed(seed []byte) (*Signer, error) {
d := crypto.Keccak256([]byte("maker.secp.v1"), seed)
for {
priv, err := crypto.ToECDSA(d)
if err == nil {
return &Signer{scheme: newSecpScheme(priv)}, nil
}
d = crypto.Keccak256(d) // re-hash the out-of-range scalar (matches dexbench)
}
}
// NewSignerFromHexKey builds a secp256k1 signer from a real 32-byte private key in
// hex (with or without 0x). MAINNET path: the caller passes the treasury key from
// env/KMS at the boundary — NEVER hardcoded, NEVER logged.
func NewSignerFromHexKey(hexKey string) (*Signer, error) {
clean := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(hexKey), "0x"), "0X")
priv, err := crypto.HexToECDSA(clean)
if err != nil {
return nil, fmt.Errorf("maker: parse signer private key: %w", err)
}
return &Signer{scheme: newSecpScheme(priv)}, nil
}
// NewMLDSASignerFromSeed derives a DETERMINISTIC ML-DSA-65 signer from a seed label
// via a keccak-stream CSPRNG, so the post-quantum account is reproducible from a
// label without persisting the ~4 KB key — the PQ analogue of NewSignerFromSeed. A
// strict-PQ trader has a DISTINCT account from the same seed's classical key (the
// scheme byte is folded in), exactly as the venue derives it.
func NewMLDSASignerFromSeed(seed []byte) (*Signer, error) {
priv, err := mldsa.GenerateKey(newDetReader(seed), mldsa.MLDSA65)
if err != nil {
return nil, fmt.Errorf("maker: ML-DSA-65 keygen: %w", err)
}
return &Signer{scheme: newMLDSAScheme(priv)}, nil
}
// WireUser returns the 16-byte settlement account this signer authorizes, as the
// string the frozen body's user[16] field must carry. dex.go copies it onto the
// DEXClient so the body asserts the SAME account the signature recovers to.
func (s *Signer) WireUser() string { return s.scheme.account() }
// envelope returns the auth envelope authorizing (typ, nonce, body) under the
// signer's scheme. ONE assembly for every scheme:
//
// scheme[1] ‖ nonce[8] BE ‖ sigLen[2] BE ‖ pubLen[2] BE ‖ sig ‖ pub
//
// over the digest keccak256(txAuthDomain ‖ scheme ‖ type ‖ nonce8BE ‖ body). The
// caller appends this to the frozen body to form the RPC payload.
func (s *Signer) envelope(typ byte, nonce uint64, body []byte) []byte {
var n [8]byte
binary.BigEndian.PutUint64(n[:], nonce)
digest := crypto.Keccak256([]byte(txAuthDomain), []byte{s.scheme.id()}, []byte{typ}, n[:], body)
sig := s.scheme.sign(digest)
pub := s.scheme.pub()
env := make([]byte, 0, authHeaderSize+len(sig)+len(pub))
env = append(env, s.scheme.id())
env = append(env, n[:]...)
var l [2]byte
binary.BigEndian.PutUint16(l[:], uint16(len(sig)))
env = append(env, l[:]...)
binary.BigEndian.PutUint16(l[:], uint16(len(pub)))
env = append(env, l[:]...)
env = append(env, sig...)
return append(env, pub...)
}
// detReader is a deterministic byte stream keccak256(seed ‖ counter) — an infinite
// CSPRNG so mldsa.GenerateKey reads a reproducible key from a seed label.
type detReader struct {
seed []byte
ctr uint64
buf []byte
}
func newDetReader(seed []byte) *detReader { return &detReader{seed: seed} }
func (r *detReader) Read(p []byte) (int, error) {
for len(r.buf) < len(p) {
var c [8]byte
binary.BigEndian.PutUint64(c[:], r.ctr)
r.ctr++
r.buf = append(r.buf, crypto.Keccak256(r.seed, c[:])...)
}
n := copy(p, r.buf)
r.buf = r.buf[n:]
return n, nil
}