Files
dex/pkg/dchain/auth_testutil_test.go
zeekayandHanzo Dev df71b108f5 dchain: a signature named no chain, so it authorized every chain
txAuthDigest bound the type, the nonce and the whole frozen body. Nothing in
that preimage names a chain, and nothing in a tx body does either. Account16
folds a key to an account with no network in the fold, so one key is the same
account on every D-Chain there is, and the native asset is all-zero on all of
them.

So a signed frame was valid wherever it could be parsed. An export signed on a
devnet is a valid export of the same account's mainnet balance to the same C
address, and anyone who saw the frame can submit it there. The signer authorized
one withdrawal and paid for as many chains as exist. Nothing had to be forged
and no key had to leak — the frame is replayed verbatim.

Put the network and the chain in the preimage. A frame now verifies on exactly
the chain its signer meant and is bytes on every other. This covers every signed
type, not only the export: a signed order was equally portable, and there is one
digest, so there is one place to fix it.

verifyTxSignature takes the scope from the VM, which fixes it at Initialize and
never changes it, so the check stays a pure function of the frame plus a
constant — still runnable at the RPC boundary before the mempool and identically
at consensus.

Account16 is deliberately NOT scoped. Its network-independence is what makes one
key one identity across the stack, and with the signature bound it is no longer
load-bearing for replay. It also keys every genesis allocation, and three chains
are about to be founded on those values.

Test VMs and the test signer now share one scope, because a signer and the chain
it signs for have to agree on it — which is the property.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 21:41:48 -07:00

208 lines
8.1 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dchain
import (
stdcrypto "crypto"
"crypto/ecdsa"
"crypto/rand"
"encoding/hex"
"sync"
"testing"
"github.com/luxfi/crypto"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/dex/pkg/dex"
"github.com/luxfi/dex/pkg/zapwire"
"github.com/luxfi/geth/common"
)
// auth_testutil_test.go is the shared test signing harness. The production gate
// (auth.go / block.execute) refuses any money-moving tx that is not signed by the
// asserted account, so every consensus-path test builds SIGNED txs through one
// identity model here: a deterministic per-(test,name) keypair, the 16-byte
// account it derives (Account16 — the SAME fold production uses), and a monotonic
// per-account nonce. Tests keep passing human names ("maker-a"); the helpers fold
// the name to its cryptographic account on the wire and sign.
// testAcct is a deterministic test identity. secp256k1 by default; ML-DSA-65 when
// built via pqAcctFor (the post-quantum lane). user is string(account) — exactly
// the zapwire user[16] field the wire carries and the ledger keys balances by.
type testAcct struct {
scheme dex.AuthScheme
priv *ecdsa.PrivateKey // secp256k1 (nil for PQ)
mlpriv *mldsa.PrivateKey // ML-DSA-65 (nil for classical)
pubKey []byte // ML-DSA pubkey bytes (nil for secp)
account userKey // 16-byte settlement account
user string // string(account[:]) — the wire user
nonce uint64 // next nonce to use
}
// authFor produces an authorization envelope binding (type, nonce, body) under
// the account's key. It does NOT advance the nonce (callers choose the nonce, so
// the security tests can forge replays/gaps explicitly).
func (a *testAcct) authFor(typ TxType, nonce uint64, body []byte) *TxAuth {
digest := txAuthDigest(testNetworkID, testChainID, typ, a.scheme, nonce, body)
switch a.scheme {
case dex.AuthMLDSA65:
sig, err := a.mlpriv.Sign(rand.Reader, digest[:], stdcrypto.Hash(0))
if err != nil {
panic("dchain test: ML-DSA sign: " + err.Error())
}
return &TxAuth{Scheme: a.scheme, Nonce: nonce, Sig: sig, PubKey: a.pubKey}
default:
sig, err := crypto.Sign(digest[:], a.priv)
if err != nil {
panic("dchain test: secp256k1 sign: " + err.Error())
}
return &TxAuth{Scheme: a.scheme, Nonce: nonce, Sig: sig}
}
}
// signed builds a signed Tx at the account's CURRENT nonce and advances it. This
// is the normal path every migrated builder uses.
func (a *testAcct) signed(t *testing.T, typ TxType, body []byte) *Tx {
t.Helper()
auth := a.authFor(typ, a.nonce, body)
a.nonce++
tx, err := NewSignedTx(typ, body, auth)
if err != nil {
t.Fatalf("NewSignedTx(%s): %v", typ, err)
}
return tx
}
// depositAuthorityName is the reserved identity of the test's trusted deposit
// authority (the "bridge"). A TxDeposit MINTS ledger balance, so post-F9 it is
// authorized ONLY by this account, never by the crediting user. Reserved (leading
// NUL) so it never collides with a human account name.
const depositAuthorityName = "\x00dchain-deposit-authority"
// authAcct returns this test's deposit-authority identity. Per-test (its nonce
// resets with the fresh VM), and it is the SAME account authConfig configures on
// the VM, so an authAcct-signed deposit passes the authority gate.
func authAcct(t *testing.T) *testAcct { return acctFor(t, depositAuthorityName) }
// authConfig is the VM init Config JSON that names this test's deposit authority,
// so every test VM that processes a funding deposit accepts authAcct-signed
// deposits (and rejects any self-signed one — the F9 fix). Production sets the real
// bridge account the same way.
func authConfig(t *testing.T) []byte {
a := authAcct(t)
return []byte(`{"depositAuthority":"` + hex.EncodeToString(a.account[:]) + `"}`)
}
// fundAcct builds a BACKED funding deposit crediting a SPECIFIC account (secp or
// ML-DSA), signed by the trusted deposit AUTHORITY (the F9 model — money enters
// only when the bridge, which holds the C-side value, authorizes it, NEVER by the
// crediting account self-signing). The authority signature does NOT advance the
// beneficiary's nonce (a different signer), so a funded account's first own tx
// starts at nonce 0.
func fundAcct(t *testing.T, a *testAcct, asset [32]byte, amount uint64, ref [32]byte) *Tx {
t.Helper()
return authAcct(t).signed(t, TxDeposit, zapwire.EncodeDeposit(a.user, asset, amount, ref))
}
// fundTx credits the named (secp) user via the authority — the funding primitive
// the conservation/byzantine suites use.
func fundTx(t *testing.T, beneficiary string, asset [32]byte, amount uint64, ref [32]byte) *Tx {
t.Helper()
return fundAcct(t, acctFor(t, beneficiary), asset, amount, ref)
}
// identityRegistry maps (test, name) -> a stable testAcct so the same human name
// yields the same key/account/nonce within a test, independent across tests.
type identityRegistry struct {
mu sync.Mutex
accts map[string]*testAcct
}
var idents = &identityRegistry{accts: map[string]*testAcct{}}
func regKey(t *testing.T, name string) string { return t.Name() + "\x00" + name }
// secpScalar derives a valid secp256k1 private scalar deterministically from a
// seed string (re-hash on the vanishingly rare out-of-range value).
func secpScalar(seed string) *ecdsa.PrivateKey {
d := crypto.Keccak256([]byte("lux.dchain.test.secp.v1"), []byte(seed))
for {
priv, err := crypto.ToECDSA(d)
if err == nil {
return priv
}
d = crypto.Keccak256(d)
}
}
// acctFor returns the deterministic secp256k1 identity for (test, name).
func acctFor(t *testing.T, name string) *testAcct {
t.Helper()
idents.mu.Lock()
defer idents.mu.Unlock()
k := regKey(t, name)
if a, ok := idents.accts[k]; ok {
return a
}
priv := secpScalar(name)
// Derive the geth-common Address the gate recovers (crypto.PubkeyToAddress
// returns crypto/common.Address; the gate keys on geth/common.Address — the
// underlying 20 keccak bytes are identical, so bridge by bytes).
addr := common.BytesToAddress(crypto.PubkeyToAddress(priv.PublicKey).Bytes())
acc := Account16(dex.AuthSecp256k1, addr)
a := &testAcct{scheme: dex.AuthSecp256k1, priv: priv, account: acc, user: string(acc[:])}
idents.accts[k] = a
return a
}
// pqAcctFor returns the ML-DSA-65 identity for (test, name) — the post-quantum
// lane used by the dual-scheme security tests. Keyed separately ("pq:") so a
// classical and a PQ identity for the same name never alias.
func pqAcctFor(t *testing.T, name string) *testAcct {
t.Helper()
idents.mu.Lock()
defer idents.mu.Unlock()
k := regKey(t, "pq:"+name)
if a, ok := idents.accts[k]; ok {
return a
}
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("mldsa.GenerateKey: %v", err)
}
pub := priv.PublicKey.Bytes()
addr := dex.AddressFromMLDSAPubKey(pub)
acc := Account16(dex.AuthMLDSA65, addr)
a := &testAcct{scheme: dex.AuthMLDSA65, mlpriv: priv, pubKey: pub, account: acc, user: string(acc[:])}
idents.accts[k] = a
return a
}
// wireUser resolves a human name to the 16-byte account string the wire/ledger
// uses, so balance assertions key the SAME account the signed txs credited.
func wireUser(t *testing.T, name string) string {
t.Helper()
return acctFor(t, name).user
}
// idOf resolves a human name to the 16-byte settlement account (userKey) the
// ledger and the orderuser: index key by — the value the gate binds a signature
// to. Identity assertions compare against this, NOT the raw name bytes.
func idOf(t *testing.T, name string) userKey {
t.Helper()
return acctFor(t, name).account
}
// signedPayload returns the RPC payload a client sends to a money-moving handler:
// the frozen zapwire body followed by the auth envelope, signed by `name` at its
// next nonce. `body` MUST already carry name's account (wireUser) as its user[16].
func signedPayload(t *testing.T, name string, typ TxType, body []byte) []byte {
t.Helper()
a := acctFor(t, name)
auth := a.authFor(typ, a.nonce, body)
a.nonce++
out := make([]byte, 0, len(body)+authHeaderSize+len(auth.Sig)+len(auth.PubKey))
out = append(out, body...)
return append(out, auth.encode()...)
}