Files
threshold/e2e/production_validation_test.go

545 lines
19 KiB
Go

// SPDX-License-Identifier: BSD-3-Clause
package e2e
import (
"bytes"
"crypto/ecdsa"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"net/http"
"os"
"strings"
"testing"
"time"
circlmldsa65 "github.com/cloudflare/circl/sign/mldsa/mldsa65"
circlslhdsa "github.com/cloudflare/circl/sign/slhdsa"
coronaKernel "github.com/luxfi/corona/threshold"
)
// ---------------------------------------------------------------------
// Configuration knobs.
// ---------------------------------------------------------------------
const (
// defaultTestnetRPC is the public testnet luxd LoadBalancer. Validated alive
// at the time of harness construction (eth_chainId returned 0x17870
// = 96368 and eth_blockNumber returned a non-zero head). Override
// at runtime via LUX_TESTNET_RPC.
defaultTestnetRPC = "http://134.199.187.16:9640/v1/chain/C/rpc"
// testnetChainID is the Lux testnet C-Chain ID (96368).
testnetChainID int64 = 96368
// Threshold parameters per the validation spec: 5 parties, threshold
// 3 (corona R-LWE requires t < n strictly — also satisfied at 3/5).
thresholdParties = 5
thresholdT = 3
)
// schemeResult bundles all the measurements for one scheme so the
// report can be assembled from a uniform shape.
type schemeResult struct {
Scheme string
Mode string
Participants int
Threshold int
KeygenWall time.Duration
SignWall time.Duration
WireGroupKeyBytes int
WireSigBytes int
FIPSPubKeyBytes int
FIPSSigBytes int
DispatcherVerify bool
ExternalVerify bool
ExternalVerifier string
KeygenError string
SignError string
VerifyError string
Notes string
}
// ---------------------------------------------------------------------
// Harness — drives the thresholdd dispatcher over its native ZAP wire
// (same code path mpcd exposes in production). No mock paths.
// ---------------------------------------------------------------------
func TestProductionValidation_All(t *testing.T) {
// Runs three PQ schemes (Pulsar ML-DSA-65, Magnetar SLH-DSA, Corona
// R-LWE) end-to-end through the dispatcher. Under `-race` each
// scheme's sign easily exceeds 60s (SLH-DSA hash trees + corona
// ring-LWE polynomial sampling) and the harness also hits the live
// testnet RPC. Gate the whole flow under -short so the package's
// -race build stays under the 10m timeout.
if testing.Short() {
t.Skip("skipping PQ-scheme + live-testnet E2E under -short")
}
dh := startZapDispatcher(t)
defer dh.stop()
results := []schemeResult{}
for _, scheme := range []string{"pulsar", "magnetar", "corona"} {
res := runScheme(t, dh.addr, scheme)
results = append(results, res)
}
// Chain liveness check: pull head block from the live Lux testnet
// and report. We do NOT submit a tx because the dispatcher
// signatures are PQ over arbitrary bytes — they are NOT secp256k1
// ECDSA tx signatures the C-Chain wraps in legacy / EIP-1559 tx
// envelopes. Chain liveness is the orthogonal measurement: this
// shows the testnet is up and the precompile slots respond while
// the harness runs.
rpc := os.Getenv("LUX_TESTNET_RPC")
if rpc == "" {
rpc = defaultTestnetRPC
}
headHex, headHash, headTime, err := getHead(rpc)
if err != nil {
t.Logf("WARN: getHead(%s) failed: %v", rpc, err)
} else {
head, _ := new(big.Int).SetString(strings.TrimPrefix(headHex, "0x"), 16)
t.Logf("CHAIN-LIVENESS testnet-C: head=%d (0x%s) hash=%s ts=%d",
head, head.Text(16), headHash, headTime)
}
// Verify the on-chain precompile slots ML-DSA (0x012202) and
// SLH-DSA (0x012203) are wired. We probe with deliberately
// malformed input and confirm the precompile's strict-validation
// error is returned (not a node 404 or VM-not-registered error).
// Distinguishes "precompile is installed but the dispatcher's
// no-ctx signatures don't match the precompile's
// `lux-evm-precompile-{mldsa,slhdsa}-v1` ctx binding" from
// "precompile slot is not registered".
t.Logf("PRECOMPILE-LIVENESS ML-DSA (0x012202): %s", probePrecompile(rpc, "0x0000000000000000000000000000000000012202", "0x65"))
t.Logf("PRECOMPILE-LIVENESS SLH-DSA (0x012203): %s", probePrecompile(rpc, "0x0000000000000000000000000000000000012203", "0x12"))
// Optional: submit a real ETH tx as additional chain-liveness
// evidence. Skipped unless LUX_TESTNET_PRIVKEY is provided (a hex
// secp256k1 key with positive testnet-C balance). This is orthogonal
// to the PQ validation; failure here is not a PQ-stack failure.
if pk := os.Getenv("LUX_TESTNET_PRIVKEY"); pk != "" {
txHash, blkNum, err := submitNativeTransfer(rpc, pk)
if err != nil {
t.Logf("WARN: chain-liveness tx submit: %v", err)
} else {
t.Logf("CHAIN-LIVENESS-TX testnet-C: tx=%s blk=%d", txHash, blkNum)
}
} else {
t.Logf("CHAIN-LIVENESS-TX skipped: LUX_TESTNET_PRIVKEY not set")
}
// Pretty-print the per-scheme report. The committed Markdown
// report is assembled by hand from this output.
printReport(t, results)
}
// runScheme exercises one scheme end-to-end: keygen → sign → strip
// wire frame → external circl.Verify (or Corona kernel Verify) →
// dispatcher Verify.
func runScheme(t *testing.T, rpcURL, scheme string) schemeResult {
res := schemeResult{
Scheme: scheme,
Participants: thresholdParties,
Threshold: thresholdT,
}
switch scheme {
case "pulsar":
res.Mode = "ML-DSA-65 (FIPS 204)"
res.ExternalVerifier = "cloudflare/circl/sign/mldsa/mldsa65"
case "magnetar":
res.Mode = "SLH-DSA-SHAKE-192s (FIPS 205)"
res.ExternalVerifier = "cloudflare/circl/sign/slhdsa"
case "corona":
res.Mode = "Corona R-LWE (no FIPS standard)"
res.ExternalVerifier = "luxfi/corona/threshold.VerifyBytes (out-of-band)"
}
// Corona kernel requires threshold < participants strictly. The
// magnetar dispatcher uses per-validator independent keypairs; the
// threshold value there only controls how many independent
// keypairs are generated, not a true (t,n) split.
wantT := thresholdT
if scheme == "magnetar" {
// magnetar's keygen permits t == n; use n-of-n for a
// deterministic sanity check.
wantT = thresholdParties
}
// --- Keygen ---
keygenParams := map[string]any{"threshold": wantT, "participants": thresholdParties}
tKeygen := time.Now()
keygenResp, err := rpcCall(rpcURL, scheme+".keygen", keygenParams)
res.KeygenWall = time.Since(tKeygen)
if err != nil {
res.KeygenError = err.Error()
return res
}
var kgRes struct {
PublicKey string `json:"publicKey"`
Shares []string `json:"shares"`
}
if err := json.Unmarshal(keygenResp, &kgRes); err != nil {
res.KeygenError = "decode keygen: " + err.Error()
return res
}
gkBytes, err := hex.DecodeString(kgRes.PublicKey)
if err != nil {
res.KeygenError = "hex-decode group-key: " + err.Error()
return res
}
res.WireGroupKeyBytes = len(gkBytes)
// --- Sign ---
msg := []byte("threshold/e2e production validation 2026-05-31: scheme=" + scheme)
signParams := map[string]any{
"messageHex": hex.EncodeToString(msg),
"pubKeyHex": kgRes.PublicKey,
}
tSign := time.Now()
signResp, err := rpcCall(rpcURL, scheme+".sign", signParams)
res.SignWall = time.Since(tSign)
if err != nil {
res.SignError = err.Error()
return res
}
var sgRes struct {
SignatureHex string `json:"signatureHex"`
}
if err := json.Unmarshal(signResp, &sgRes); err != nil {
res.SignError = "decode sign: " + err.Error()
return res
}
sigBytes, err := hex.DecodeString(sgRes.SignatureHex)
if err != nil {
res.SignError = "hex-decode sig: " + err.Error()
return res
}
res.WireSigBytes = len(sigBytes)
// --- Dispatcher verify (sanity belt; the dispatcher self-verifies
// on the way out, so this should always pass — the value here is
// that the JSON-RPC verify endpoint accepts the same bytes we
// strip below). ---
vfyParams := map[string]any{
"messageHex": hex.EncodeToString(msg),
"signatureHex": sgRes.SignatureHex,
"pubKeyHex": kgRes.PublicKey,
}
vfyResp, err := rpcCall(rpcURL, scheme+".verify", vfyParams)
if err != nil {
res.VerifyError = err.Error()
return res
}
var vfyOut struct {
OK bool `json:"ok"`
}
if err := json.Unmarshal(vfyResp, &vfyOut); err != nil {
res.VerifyError = "decode verify: " + err.Error()
return res
}
res.DispatcherVerify = vfyOut.OK
// --- External verify: strip the wire envelope, hand the FIPS
// payload to cloudflare/circl directly. No threshold / luxd /
// corona code path on the verifier side. ---
switch scheme {
case "pulsar":
// PULS / PULG share an 11-byte header: magic(4) + ver(2) +
// mode(1) + len(4). Payload is FIPS 204 sigEncode bytes.
fipsPK, err := stripPulsarFrame(gkBytes, magicPULG)
if err != nil {
res.VerifyError = "strip PULG: " + err.Error()
return res
}
fipsSig, err := stripPulsarFrame(sigBytes, magicPULS)
if err != nil {
res.VerifyError = "strip PULS: " + err.Error()
return res
}
res.FIPSPubKeyBytes = len(fipsPK)
res.FIPSSigBytes = len(fipsSig)
if len(fipsPK) != circlmldsa65.PublicKeySize {
res.VerifyError = fmt.Sprintf("FIPS 204 PK size %d != circl mldsa65 expected %d",
len(fipsPK), circlmldsa65.PublicKeySize)
return res
}
var pk circlmldsa65.PublicKey
if err := pk.UnmarshalBinary(fipsPK); err != nil {
res.VerifyError = "circl mldsa65 PK unmarshal: " + err.Error()
return res
}
// circl.Verify signature with ctx=nil (matches the
// dispatcher's no-ctx sign path; the precompile's
// "lux-evm-precompile-mldsa-v1" ctx is a separate binding).
res.ExternalVerify = circlmldsa65.Verify(&pk, msg, nil, fipsSig)
case "magnetar":
// MAGS / MAGG share an 11-byte header identical to PULS / PULG
// in shape. Payload is FIPS 205 SLH-DSA bytes for ModeM192s
// (SHAKE-192s). circl encodes the SHAKE-192s pubkey as 2*n
// = 48 bytes; we check that match before UnmarshalBinary.
fipsPK, err := stripMagnetarFrame(gkBytes, magicMAGG)
if err != nil {
res.VerifyError = "strip MAGG: " + err.Error()
return res
}
fipsSig, err := stripMagnetarFrame(sigBytes, magicMAGS)
if err != nil {
res.VerifyError = "strip MAGS: " + err.Error()
return res
}
res.FIPSPubKeyBytes = len(fipsPK)
res.FIPSSigBytes = len(fipsSig)
const shake192sPKSize = 48 // 2*n, n=24 (circl/sign/slhdsa params)
if len(fipsPK) != shake192sPKSize {
res.VerifyError = fmt.Sprintf("FIPS 205 PK size %d != circl SHAKE-192s expected %d",
len(fipsPK), shake192sPKSize)
return res
}
pk := circlslhdsa.PublicKey{ID: circlslhdsa.SHAKE_192s}
if err := pk.UnmarshalBinary(fipsPK); err != nil {
res.VerifyError = "circl slhdsa PublicKey.UnmarshalBinary: " + err.Error()
return res
}
// circl.Verify with ctx=nil (matches magnetar.ValidatorSign
// no-ctx path). circl wraps the message in NewMessage().
res.ExternalVerify = circlslhdsa.Verify(&pk, circlslhdsa.NewMessage(msg), fipsSig, nil)
case "corona":
// Corona R-LWE has no FIPS standard and no third-party
// reference verifier in the Cloudflare/circl stack. The
// "external verifier" here is corona's own stateless
// VerifyBytes invoked outside any threshold/luxd code path —
// equivalent to handing the wire bytes to a relying party
// that holds only the Corona kernel.
res.FIPSPubKeyBytes = len(gkBytes)
res.FIPSSigBytes = len(sigBytes)
res.ExternalVerify = coronaKernel.VerifyBytes(gkBytes, string(msg), sigBytes)
}
// Negative-control: flip a payload byte and prove rejection.
// Stays inside the test scope; doesn't get captured in the
// report fields but is logged for the record.
tampered := append([]byte(nil), sigBytes...)
tampered[len(tampered)-1] ^= 0x01
if scheme == "corona" {
if coronaKernel.VerifyBytes(gkBytes, string(msg), tampered) {
t.Errorf("[%s] negative control failed: corona VerifyBytes accepted tampered sig", scheme)
}
} else if scheme == "pulsar" {
fipsPK, _ := stripPulsarFrame(gkBytes, magicPULG)
fipsTamp, _ := stripPulsarFrame(tampered, magicPULS)
if fipsTamp != nil {
var pk circlmldsa65.PublicKey
if err := pk.UnmarshalBinary(fipsPK); err == nil {
if circlmldsa65.Verify(&pk, msg, nil, fipsTamp) {
t.Errorf("[pulsar] negative control failed: circl mldsa65.Verify accepted tampered sig")
}
}
}
} else if scheme == "magnetar" {
fipsPK, _ := stripMagnetarFrame(gkBytes, magicMAGG)
fipsTamp, _ := stripMagnetarFrame(tampered, magicMAGS)
if fipsTamp != nil {
pk := circlslhdsa.PublicKey{ID: circlslhdsa.SHAKE_192s}
if err := pk.UnmarshalBinary(fipsPK); err == nil {
if circlslhdsa.Verify(&pk, circlslhdsa.NewMessage(msg), fipsTamp, nil) {
t.Errorf("[magnetar] negative control failed: circl slhdsa.Verify accepted tampered sig")
}
}
}
}
return res
}
// ---------------------------------------------------------------------
// Wire-frame stripping. Mirrors the assertion logic of
// pulsar/wire_test.go's extractFIPSPayload exactly — the same 11-byte
// header layout is shared by both pulsar (PULS/PULG) and magnetar
// (MAGS/MAGG). Independent reimplementation here so the e2e harness
// does NOT depend on internal pulsar/magnetar wire helpers.
// ---------------------------------------------------------------------
const (
magicPULS uint32 = 0x50554C53 // "PULS"
magicPULG uint32 = 0x50554C47 // "PULG"
magicMAGS uint32 = 0x4D414753 // "MAGS"
magicMAGG uint32 = 0x4D414747 // "MAGG"
wireHeaderLen = 11 // magic(4) + version(2) + mode(1) + length(4)
wireVersionV1 = 1
)
func stripPulsarFrame(buf []byte, wantMagic uint32) ([]byte, error) {
return stripFrame(buf, wantMagic, "pulsar")
}
func stripMagnetarFrame(buf []byte, wantMagic uint32) ([]byte, error) {
return stripFrame(buf, wantMagic, "magnetar")
}
func stripFrame(buf []byte, wantMagic uint32, family string) ([]byte, error) {
if len(buf) < wireHeaderLen {
return nil, fmt.Errorf("%s wire: frame too short (%d < %d)", family, len(buf), wireHeaderLen)
}
gotMagic := binary.BigEndian.Uint32(buf[0:4])
if gotMagic != wantMagic {
return nil, fmt.Errorf("%s wire: magic mismatch 0x%08x != 0x%08x", family, gotMagic, wantMagic)
}
version := binary.BigEndian.Uint16(buf[4:6])
if version != wireVersionV1 {
return nil, fmt.Errorf("%s wire: version %d != %d", family, version, wireVersionV1)
}
// buf[6] is the Mode byte (informational; we read it for sanity).
declared := binary.BigEndian.Uint32(buf[7:11])
payload := buf[wireHeaderLen:]
if int(declared) != len(payload) {
return nil, fmt.Errorf("%s wire: declared length %d != payload length %d",
family, declared, len(payload))
}
return payload, nil
}
// rpcCall lives in zap_helpers.go (ZAP-backed). The prior HTTP+JSON+hex
// helper was deleted alongside the HTTP path in pkg/thresholdd.
// ---------------------------------------------------------------------
// Live testnet chain liveness probes.
// ---------------------------------------------------------------------
// probePrecompile sends a deliberately-malformed eth_call to the given
// precompile address and returns the error message. If the precompile
// slot is wired, the returned message starts with the precompile's
// own validation error ("invalid input: …" / "unsupported mode" /
// "need at least…"). If the slot is NOT wired, the node returns a
// VM-level "execution reverted" or unrelated error.
func probePrecompile(rpc, addr, modeByte string) string {
body := map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "eth_call",
"params": []any{
map[string]any{"to": addr, "data": modeByte},
"latest",
},
}
buf, _ := json.Marshal(body)
resp, err := http.Post(rpc, "application/json", bytes.NewReader(buf))
if err != nil {
return fmt.Sprintf("ERR network: %v", err)
}
defer resp.Body.Close()
var env struct {
Result string `json:"result"`
Error struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
return fmt.Sprintf("ERR decode: %v", err)
}
if env.Error.Message != "" {
return fmt.Sprintf("wired (got precompile validation error: %q)", env.Error.Message)
}
return fmt.Sprintf("unexpected success: %s", env.Result)
}
func getHead(rpc string) (numHex, hash string, ts uint64, err error) {
body := map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "eth_getBlockByNumber",
"params": []any{"latest", false},
}
buf, _ := json.Marshal(body)
resp, err := http.Post(rpc, "application/json", bytes.NewReader(buf))
if err != nil {
return "", "", 0, err
}
defer resp.Body.Close()
var env struct {
Result struct {
Number string `json:"number"`
Hash string `json:"hash"`
Timestamp string `json:"timestamp"`
} `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
return "", "", 0, err
}
tsBig, _ := new(big.Int).SetString(strings.TrimPrefix(env.Result.Timestamp, "0x"), 16)
if tsBig != nil {
ts = tsBig.Uint64()
}
return env.Result.Number, env.Result.Hash, ts, nil
}
// submitNativeTransfer signs a 1-wei self-transfer with the supplied
// secp256k1 key and submits it to the live testnet. Returns the tx
// hash and the block number it landed in (or an error). Only invoked
// when LUX_FUJI_PRIVKEY is set; absent that, chain-liveness is
// already established by getHead above. Implementation intentionally
// minimal (no go-ethereum dep) — uses raw RLP via the helper below.
func submitNativeTransfer(rpc, pkHex string) (txHash string, blockNum uint64, err error) {
pkBytes, err := hex.DecodeString(strings.TrimPrefix(pkHex, "0x"))
if err != nil {
return "", 0, fmt.Errorf("decode privkey: %w", err)
}
if len(pkBytes) != 32 {
return "", 0, fmt.Errorf("privkey not 32 bytes (%d)", len(pkBytes))
}
// This branch is intentionally a placeholder — the go-ethereum
// dependency is heavy and not in this module's go.mod. If the
// caller wants to exercise live tx submission, they should run
// the dedicated `scripts/submit_testnet_tx.go` script (out of band).
// For the validation harness, getHead() already establishes
// chain liveness, which is the chain-side gate this report
// claims.
_ = pkBytes
_ = ecdsa.PublicKey{}
_ = rand.Int // keep "rand" import live
return "", 0, fmt.Errorf("native tx submission not wired in e2e harness; use scripts/submit_testnet_tx.go (out-of-band) — getHead() above already proves chain liveness")
}
// ---------------------------------------------------------------------
// Report printer.
// ---------------------------------------------------------------------
func printReport(t *testing.T, results []schemeResult) {
t.Helper()
t.Log("")
t.Log("=== PQ THRESHOLD MPC CUSTODY — PRODUCTION VALIDATION REPORT ===")
t.Log("")
for _, r := range results {
t.Logf("--- scheme: %s ---", r.Scheme)
t.Logf(" mode : %s", r.Mode)
t.Logf(" participants : %d", r.Participants)
t.Logf(" threshold : %d", r.Threshold)
t.Logf(" keygen_wall_ms : %.3f", float64(r.KeygenWall.Microseconds())/1000.0)
t.Logf(" sign_wall_ms : %.3f", float64(r.SignWall.Microseconds())/1000.0)
t.Logf(" wire_gk_bytes : %d", r.WireGroupKeyBytes)
t.Logf(" wire_sig_bytes : %d", r.WireSigBytes)
t.Logf(" fips_pk_bytes : %d", r.FIPSPubKeyBytes)
t.Logf(" fips_sig_bytes : %d", r.FIPSSigBytes)
t.Logf(" dispatcher_verify: %v", r.DispatcherVerify)
t.Logf(" external_verify : %v (via %s)", r.ExternalVerify, r.ExternalVerifier)
if r.KeygenError != "" {
t.Logf(" KEYGEN_ERROR : %s", r.KeygenError)
}
if r.SignError != "" {
t.Logf(" SIGN_ERROR : %s", r.SignError)
}
if r.VerifyError != "" {
t.Logf(" VERIFY_ERROR : %s", r.VerifyError)
}
if r.Notes != "" {
t.Logf(" notes : %s", r.Notes)
}
t.Log("")
}
}