mirror of
https://github.com/luxfi/maker.git
synced 2026-10-30 16:20:04 +00:00
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).
258 lines
11 KiB
Go
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
|
|
}
|