Files
zeekay 8273105926 The signing curve is derived from the network, not stated by the caller
Solana addresses were correct and receive-only: ConsensusKeyInfoStore.Save was
the only RegisterKey caller and hardcoded "secp256k1", and TriggerSign read the
curve from that field, so no product entry point could reach Ed25519 signing.

The hardcode is not fixed, it is UNREPRESENTABLE. transport.KeyInfo.KeyType is
deleted — the record carries one public key per curve and a PublicKeyFor(curve)
— and RegisterKey lost its keyType parameter, so there is no longer a place to
state a curve wrongly. A scalar KeyType was the wrong shape anyway: one keygen
ceremony produces BOTH a secp256k1 and an Ed25519 key, so "what curve is this
wallet" has no single answer.

The request carries the network instead, and ONE table maps it to a curve, with
NO default arm — KeyTypeForNetwork returns ErrUnknownNetwork rather than
guessing. That is the point: the old default returned secp256k1 for anything
unrecognized, which is how a Solana request would have been signed with the
wrong key.

/sign's 32-byte clamp becomes the SCHEME's rule rather than the endpoint's:
secp256k1 signs a digest and requires exactly 32 bytes, Ed25519 signs the
message at any length. /sign no longer accepts key_type at all — two ways to say
the curve is the misuse being removed — and Network is bound into the
idempotency fingerprint, so the same bytes for SOL and ETH conflict instead of
sharing one cached signature from a different key.

⚠ The trap this nearly shipped with: every pre-existing KeyInfo record has EMPTY
public keys, because the old Save wrote "", "". A naive "does this wallet have
this curve" gate would have refused every existing wallet on every curve. The
check is gated on RecordsCurves() — a record naming no curve denies none, and
the missing share stays the thing that fails closed.

Verified end to end, not by mirroring: a Solana request through
ConsensusMPCBackend.TriggerSign with a real 2-of-3 FROST ring produces a
signature crypto/ed25519.Verify accepts, over a 63-byte message so any
reintroduced clamp or pre-hash fails it; ETH/EVM/BTC/LUX still route to
secp256k1 and must RECOVER to the wallet's key. Four mutations tried, all
caught.

Behavioral risk worth naming: an unrecognized chain string now fails its
transaction closed rather than signing with a default. That is correct, and the
real fix is validating chain at POST /v1/transactions.
2026-08-06 15:59:30 -07:00

42 KiB

LLM.md - MPC Signer Architecture & Development Guide

This document provides comprehensive guidance for AI assistants working with the MPC (Multi-Party Computation) codebase.

✅ eddsa_pub_key / sol_address are LIVE — Ed25519, and only from KeygenEd25519

Solana/TON keys are real as of luxfi/threshold v1.12.7, which added the FROST(Ed25519, SHA-512) ciphersuite of RFC 9591. This section previously said the opposite and told you to leave eddsa_pub_key empty; that blocker is cleared.

The hazard it replaced, which has NOT gone away as a class. The sessions named "EdDSA" used to call frost.KeygenTaproot — BIP-340 over secp256k1. Its public key is also 32 bytes, so it passed mpcd's len(result.EDDSAPubKey) == 32 gate and base58-encoded into a real-looking Solana address the ring could never sign for. Deposits to such an address are unrecoverable.

The rules that keep it fixed:

  • Any value reaching EDDSAPubKey MUST come from frost.KeygenEd25519 and nothing else. KeygenTaproot (secp256k1) and frost.Keygen(curve.Ed25519{}, …) (BLAKE3 challenge, big-endian S) both produce plausible-looking 32-byte keys that no chain accepts.
  • Sign with frost.SignEd25519 only. Generic frost.Sign over the same Ed25519 config returns 64 bytes shaped exactly like a signature, with a BLAKE3 challenge and big-endian S. SignEd25519's Result() is a value (frost.Ed25519Signature), not a pointer.
  • Never pre-hash. PureEdDSA hashes the message inside the challenge. The old signing session SHA-256'd anything that was not already 32 bytes (a BIP-340 requirement); that is removed, and re-adding it signs a digest of a digest.
  • Provenance is the defence, not validation. pkg/address.Solana checks the bytes decode as a prime-order edwards25519 point, but that is only a backstop: measured, it refuses ~93.4% of secp256k1 x-only keys, because about 1 in 16 of them is genuinely a valid Ed25519 key too. No function of the bytes alone can do better. It also rejects the identity point, for which any signature verifies — the DKG does not.
  • When anything is uncertain, emit nothing. An absent sol_address is recoverable; a wrong one is not. The Ed25519 keygen leg is deliberately non-fatal for this reason.

Layout. Keygen pkg/mpc/frost_keygen_session.go → share at ed25519:<walletID> (Ed25519ShareKey, CBOR via ed25519_config_marshal.go), topics keygen:*:ed25519:*. Signing pkg/mpc/signing_session_frost.go reads the same key. Orchestration is handleKeyGenEvent in pkg/eventconsumer/event_consumer.go, which runs the secp256k1 leg then the Ed25519 leg and publishes one result. That order is load-bearing: only the CGGMP21 session writes keyinfo, and CreateEdDSASignSession reads it to count signers, so the Ed25519 leg must never run first or alone. Shares written under the old frost: prefix were secp256k1 and are intentionally unreachable.

Pinned by pkg/mpc/ed25519_spendable_test.go, which runs the real ceremony, round-trips every share through the production codec, derives the address, decodes it back, and verifies a threshold signature with crypto/ed25519 against the key the address decodes to — plus pkg/address, cmd/mpcd, and pkg/eventconsumer guards.

Still open: TON addresses are NOT emitted. The Ed25519 key is the correct TON key; only the address encoding is missing (workchain ‖ SHA-256 of a wallet contract's StateInit cell, base64url + CRC16 — and v4r2 vs W5 give different addresses for the same key). Guessing it recreates exactly the failure above. Also open: resharing an Ed25519 wallet (eddsa_resharing_session.go + pkg/protocol/frost/adapter.go are still Taproot/secp256k1 and read the bare walletID key, so they never touch the Ed25519 share); and sr25519 (DOT/KSM) still has no keygen leg.

Curve selection: a property of the REQUEST, from the network, in one place

A wallet is not on a curve. One keygen mints a secp256k1 key (EVM/BTC/Lux/XRPL) and an Ed25519 key (Solana/TON), so "what curve is this wallet" has no answer — and any scalar answer is a lie the signer then acts on. transport.KeyInfo therefore has no KeyType field; it has one public-key field per curve, and RegisterKey has no keyType parameter for anyone to hardcode. It merges, because one keygen writes the record twice: the CGGMP21 ceremony knows the threshold and no keys, TriggerKeygen knows the keys and arrives later.

The curve comes from the network, resolved in exactly one function — types.KeyTypeForNetwork over the table in pkg/types/networks.go. There is no default arm: an unrecorded network yields types.ErrUnknownNetwork and the request is refused before it touches any signing machinery. types.ParseNetwork is the one lookup from a free-form chain string ("evm", "solana", "base") to a canonical code, likewise closed — an unknown chain fails closed rather than being pattern-matched into a family.

MPCBackend.TriggerSign(orgID, walletID, network, payload) is the single funnel. Entry points that carry a network state it (/v1/mpc/sign, /v1/mpc/sign/settlement, the ZAP sign op, the transaction pipeline via tx.Chain); entry points bound to one runtime by construction name it as a constant, because no other payload can reach them — Safe EIP-712 and ERC-4337 hashes and the bridge keccak digest are NetworkEVM; the KMS surfaces sign Lux validator key sets and are NetworkLUX.

/sign's payload rule is now the scheme's, not the endpoint's: secp256k1 requires exactly 32 bytes (it signs a digest), Ed25519 accepts the message at any non-empty length (PureEdDSA hashes it inside the challenge). The old unconditional 32-byte clamp made a Solana transaction impossible to express over HTTP. /sign takes network and no longer takes key_type — two ways to say the curve is the misuse being removed. lux/wallet's custody adapter must send network; it currently sends key_type plus a numeric chain_id, and its requests are refused until it does.

Pinned end-to-end by cmd/mpcd/sign_curve_routing_test.go, which drives ConsensusMPCBackend.TriggerSign with a real threshold ring behind it and checks the result with crypto/ed25519.Verify (Solana) and public-key recovery (EVM). The in-process ceremony driver is shared at internal/ceremony.

🔴 v1.17.12 — CRITICAL threshold-degree fix (was silently 1-of-n) + BRIDGE-ORACLE finding (2026-07-15)

The bug (fixed by commit 1e1d318, in tag v1.17.12): --threshold=N only drove the HasSigningQuorum health gate + status display. The CGGMP21 keygen DEGREE was read separately from viper key mpc_threshold, which nothing ever set (mpcd never calls InitViperConfig, so AutomaticEnv was off), so viper.GetInt("mpc_threshold") was always 0 → polynomial degree 0 → 1-of-n: any single share produced a valid signature, with zero threshold protection against single-node compromise. Every wallet keyed by every ring before this fix is degree-0.

The fix: keygenDegreeForThreshold(threshold) = threshold-1 (floored at 0), wired via viper.Set("mpc_threshold", …) at consensus start. --threshold=3 → degree 2 → 3-of-5; --threshold=2 → degree 1 → 2-of-3. Named + unit-tested (cmd/mpcd/threshold_test.go) so the security-critical off-by-one is explicit. Takes effect for NEW keygens ONLY — existing wallets must be RE-KEYED to gain threshold security (re-keying changes the wallet's address, so live wallets need a coordinated migration, never re-key in place).

Enforcement is two-layer + per-key: the KEY's degree is stored as keyinfo.KeyInfo.Threshold (registry.go:248) at keygen (keygen_session.go:418). Every sign: node.go CreateSignSession rejects len(signers) < keyInfo.Threshold+1 (ErrNotEnoughParticipants); the cmp library independently rejects it at cmp.Sign → sign.StartSign → config.CanSign → ValidThreshold(t, n) (false when t > n-1). Property pinned by luxfi/threshold test protocols/cmp/degree2_proof_test.go (degree-2: 2 signers refused, 3 verify).

Ring status (2026-07-15)

Ring ns / ctx image degree status
zoo-mpc zoo-mpc / do-sfo3-zoo-k8s zooai/mpc:1.17.12 degree 2 (3-of-5) ✅ FIXED + PROVEN. Fresh wallet zoo-threshold-proof-v1 (pub 02f21a7a…, EVM 0x394d6480…) real sign verifies; KMS-driven wallet 713f5c2d… (pub 0360ac6b…) also degree-2, KMS sign verifies.
hanzo-mpc hanzo-mpc / do-sfo3-hanzo-k8s →hanzoai/mpc:1.17.12 degree 1 (2-of-3) deploy in progress (separate agent)
bridge-mpc bridge-mpc / do-sfo3-lux-k8s luxfi/mpc:v1.17.9 degree 0 (1-of-5) 🔴 VULNERABLE — see below
lux-mpc lux-mpc / do-sfo3-lux-k8s luxfi/mpc:v1.10.0 degree 0 (1-of-n) 🔴 STAGED (7-version jump = risky; 5 PVC snapshots mpc-node-{0..4}-prefix1712 ReadyToUse)
lux-bridge (own 3-node ring) lux-bridge / do-sfo3-lux-k8s luxfi/mpc:v1.5.3 — dormant, 0 keygens, holds NO keys → not a vuln

🔴 HIGH-STAKES BRIDGE-ORACLE FINDING

The LUX↔ZOO corridor signer keys are degree-0 (1-of-n): a SINGLE compromised MPC node can forge a valid bridge-oracle signature and drain the corridor.

  • bridge-mpc/bridge-oracle — keygen log threshold=0 on all 5 nodes (2026-07-13); pub b534f9b5…; derives to EVM 0x46457f86bb0e8b3013eaae4699d529a98471c511 (= the task's 0x46457f86). CONFIRMED 1-of-5.
  • lux-mpc wallet 17727859650255619160017 — the wallet the live bridge-server actually signs with (bridge-server env: MPC_API_URL=https://mpc.lux.network, MPC_WALLET_ID=17727859650255619160017, MPC_KEY_PREFIX=bridge/mpc). lux-mpc runs v1.10.0 which provably has the degree-0 bug (keygen reads viper mpc_threshold, nothing sets it) → degree 0 = 1-of-n. Likely the task's second oracle address 0x086f4aa1.

Remediation (do NOT re-key the live oracle in place — it authorizes real value + changing its address orphans the corridor mapping):

  1. Roll the fixed image (v1.17.12) onto bridge-mpc + lux-mpc in a maintenance window. lux-mpc is a 7-minor-version jump (v1.10.0→v1.17.12) — canary the zapdb/wire compat on a restored snapshot copy first; snapshots are ReadyToUse for rollback.
  2. Re-key a NEW oracle wallet at genuine degree 2 (3-of-5) on the fixed ring → NEW address.
  3. Migrate bridge-oracle authority old→new address via a coordinated bridge-contract owner rotation on BOTH LUX and ZOO sides; pause/drain high-value transfers during the cutover.
  4. Until migrated: the corridor is protected ONLY by perimeter (NetworkPolicy / node isolation), NOT by threshold. Treat any single bridge-mpc/lux-mpc node compromise as full oracle-key compromise.

v1.14.0 — pkg/zapauth (LP-103)

Bearer-token gate on the KMS-facing ZAP server. JWKS-validated JWT presented in OpAuthHello (0x00EF) BEFORE the X25519+ML-KEM-768 handshake. Configured via ZAP_JWKS_URL, ZAP_EXPECTED_ISS, ZAP_EXPECTED_AUDIENCES, ZAP_AUTH_REQUIRED. See docs/zap-auth.md. Backwards compatible: defaults to auth=off; flips to auth=required once every KMS deploys with bearer minting (luxfi/kms v1.9.0 pkg/iamclient).

🏭 Production Deployment State (2026-03-02)

Namespaces

  • lux-mpc — PRIMARY production consensus-mode MPC (3 nodes, dashboard API, postgres, valkey)
  • lux-bridge — Bridge-specific MPC (3 consensus-mode nodes, dashboard API using bridge postgres)
  • hanzo — Legacy NATS/Consul MPC nodes (5 nodes, older deployment)

Key Decisions

  • Storage: ZapDB (github.com/luxfi/database/zapdb) — our fork, NOT badger directly
  • Encryption: encdb.New(password, rawDB) wraps ZapDB with ChaCha20-Poly1305; backups contain pre-encrypted values, restore into raw zapdb
  • Dashboard API: Port 8081, enabled by MPC_API_DB env var; uses ORM's _entities JSONB table
  • Multi-tenancy: One postgres instance, one _entities table, kind + orgId in JSONB data
  • Binary distribution: S3 bucket lux-mpc-backups/binaries/ (public read); startup script promotes /data/mpcd.new
  • S3 address: Use ClusterIP 10.124.44.247:9000 from lux-bridge pods (cross-namespace DNS fails); internal: s3.hanzo.svc.cluster.local:9000 works only from hanzo namespace

Infrastructure Principles (keep it light)

  • 1 postgres per cluster (MPC gets mpc_api db, bridge gets bridge db)
  • 1 valkey/redis per cluster for KV cache
  • 1 S3 bucket with org-prefixed paths for all backups
  • ZapDB passwords via HSM PasswordProvider (--hsm-provider=aws|gcp|azure|env|file, default env for backward compat). Both legacy and consensus modes use resolveZapDBPassword() in cmd/mpcd/main.go.
  • Customer-controlled encryption: each org's ZapDB encrypted with org's KMS key — FUTURE WORK

ORM Filter Behavior

  • Filter("keyHash=", value) → SQL: WHERE data->>'keyHash' = $1 (camelCase from Go field name)
  • Filter("key_hash=", value) → SQL: WHERE data->>'key_hash' = $1 (no conversion)
  • ALWAYS use camelCase matching the struct's json:"" tag when filtering

📚 Overview

MPC is a threshold signing service that provides:

  • ECDSA (secp256k1) for Bitcoin/Ethereum/EVM chains
  • EdDSA (Ed25519) for Solana/Polkadot/Sui
  • Threshold signatures (t-of-n) with CGGMP21 protocol
  • Key resharing for rotation without changing addresses

Architecture Position

MPC is designed as a pluggable signer backend for KMS:

┌─────────────────────────────────────────────────────────────────┐
│                      KMS (Control Plane)                  │
│  ┌──────────┬─────────────┬──────────────┬─────────────────┐    │
│  │ Policy   │ Approvals   │  Audit Log   │  Key Registry   │    │
│  └────┬─────┴──────┬──────┴───────┬──────┴───────┬─────────┘    │
│       │            │              │              │              │
│  ┌────▼────────────▼──────────────▼──────────────▼─────────┐    │
│  │              Unified Signing API                         │    │
│  └────┬────────────┬──────────────┬──────────────┬─────────┘    │
│       │            │              │              │              │
│  ┌────▼────┐  ┌────▼────┐   ┌─────▼─────┐  ┌─────▼─────┐        │
│  │  HSM    │  │  MPC    │   │  Software │  │  Remote   │        │
│  │ Signer  │  │ Signer  │   │  Signer   │  │  Signer   │        │
│  └─────────┘  └─────────┘   └───────────┘  └───────────┘        │
└─────────────────────────────────────────────────────────────────┘

Product Architecture

  1. KMS Platform (Control Plane)

    • Key registry + metadata
    • Policy + workflow (quorum, time locks, spend limits, allowlists)
    • Audit log
    • Unified API
    • Secrets manager
  2. MPC Signer (This Project - Data Plane)

    • DKG / key share management
    • Threshold signing sessions
    • Reshare/rotate shares
    • Optional hardware-rooted modes
  3. Hanzo HSM (Alternative Signer)

    • HSM-backed keys for classic KMS workloads
    • HSM-sealed share storage for MPC nodes
  4. Hanzo Treasury (Optional UI)

    • Transaction building + chain adapters
    • Simulation / policy previews
    • Approvals UI (backed by KMS workflow engine)

🚀 Quick Start

Build and Install

# Build binaries
make build

# Or install directly (for consensus-embedded mode)
go install ./cmd/mpcd

# Or for legacy NATS/Consul mode
go install ./cmd/mpc
# Start MPC node in consensus mode (no external dependencies)
mpcd start --mode consensus \
  --node-id node0 \
  --listen :9999 \
  --api :9800 \
  --data /data/mpc/node0 \
  --threshold 2 \
  --peer [email protected]:9999 \
  --peer [email protected]:9999

# Or via lux CLI
lux mpc init --threshold 2 --nodes 3
lux mpc start

Legacy Mode (NATS + Consul)

# Generate peers configuration
mpc generate-peers -n 3

# Register peers to Consul
mpc register-peers

# Generate event initiator
mpc generate-initiator

# Generate node identity
mpc generate-identity --node node0

# Start MPC node in legacy mode
mpcd start --mode legacy -n node0

📁 Project Structure

/Users/z/work/lux/mpc/
├── cmd/                    # Command-line applications
│   ├── mpcd/              # Main MPC daemon (consensus-embedded)
│   └── mpc/       # CLI tools for configuration
├── pkg/                    # Core packages
│   ├── client/            # Go client library
│   ├── mpc/               # MPC implementation (TSS)
│   ├── kvstore/           # BadgerDB storage
│   ├── transport/         # Consensus-embedded transport (ZAP + PoA)
│   ├── messaging/         # NATS messaging (DEPRECATED - use transport)
│   ├── infra/             # Consul integration (DEPRECATED - use transport)
│   ├── identity/          # Ed25519 identity management
│   └── eventconsumer/     # Event processing
├── e2e/                    # End-to-end tests
├── examples/               # Usage examples
└── scripts/                # Utility scripts

🏗️ Core Components

1. MPC Engine

Based on threshold cryptography:

  • CGGMP21 protocol for ECDSA (secp256k1) - IMPLEMENTED & TESTED
  • FROST protocol for EdDSA (Ed25519) - IMPLEMENTED & TESTED (keygen generates both ECDSA and EdDSA keys)
  • Configurable threshold (t-of-n)
  • Default: t = ⌊n/2⌋ + 1 (majority)

2. Storage Layer: BadgerDB

  • AES-256 encrypted key shares
  • Session data persistence
  • Automatic backups

3. Transport Layer (NEW - Jan 2026)

The MPC daemon now supports consensus-embedded transport that eliminates external dependencies:

┌─────────────────────────────────────────────────────────────────┐
│                 MPC Node (Consensus-Embedded)                   │
│  ┌──────────┬─────────────┬──────────────┬─────────────────┐    │
│  │ PubSub   │ MessageQ    │  Registry    │  KeyInfoStore   │    │
│  └────┬─────┴──────┬──────┴───────┬──────┴───────┬─────────┘    │
│       │            │              │              │              │
│  ┌────▼────────────▼──────────────▼──────────────▼─────────┐    │
│  │              ZAP Transport (Wire Protocol)               │    │
│  └────┬────────────────────────────────────────────────────┘    │
│       │                                                         │
│  ┌────▼─────────────────────────────────────────────────────┐   │
│  │           Membership (Ed25519 PoA Validators)            │   │
│  └──────────────────────────────────────────────────────────┘   │
│       │                                                         │
│  ┌────▼─────────────────────────────────────────────────────┐   │
│  │              StateStore (BadgerDB + Replication)          │   │
│  └──────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

What was replaced:

  • NATS → ZAP Transport with Broadcast()/Query()
  • Consul → Consensus Membership with Ed25519 keys as PoA validators
  • PostgreSQL → StateStore + BadgerDB for replicated state
  • Redis → Consensus state queries via Transport.Query()

What remains:

  • BadgerDB → Local encrypted key share storage (unchanged)
  • Ed25519 identities → Now serve as PoA validator keys

Usage (pkg/transport):

factory, err := transport.NewFactory(transport.FactoryConfig{
    NodeID:         "node0",
    ListenAddr:     ":9999",
    Peers:          map[string]string{"node0": ":9999", "node1": ":9999", "node2": ":9999"},
    PrivateKey:     privateKey,
    PublicKey:      publicKey,
    BadgerPath:     "/data/mpc/node0",
    BadgerPassword: "secure-password",
})

ctx := context.Background()
factory.Start(ctx)

// Use these instead of NATS/Consul:
pubSub := factory.PubSub()         // replaces messaging.PubSub
registry := factory.Registry()     // replaces mpc.PeerRegistry
kvstore := factory.KVStore()       // local BadgerDB
keyinfo := factory.KeyInfoStore()  // replaces Consul-based keyinfo.Store

3b. Messaging: NATS JetStream (DEPRECATED)

  • Pub/sub for broadcasts
  • Direct messaging for P2P
  • Message persistence
  • ⚠️ Use pkg/transport instead for new deployments

4. Service Discovery: Consul (DEPRECATED)

  • Node registration
  • Health checking
  • Configuration management
  • ⚠️ Use pkg/transport Membership instead for new deployments

5. Identity: Ed25519 keypairs

  • Node authentication
  • Message signing/verification
  • Encrypted with Age

🔧 Configuration

Consensus-Embedded Mode (NEW - Jan 2026)

# config.yaml
environment: development
transport:
  listen_addr: ":9999"
  peers:
    node0: "10.0.0.1:9999"
    node1: "10.0.0.2:9999"
    node2: "10.0.0.3:9999"
badger:
  path: "/data/mpc"
  password: "secure-password"
  backup_dir: "/data/mpc/backups"
identity:
  key_file: "node0_identity.json"
event_initiator_pubkey: "hex-encoded-pubkey"

Legacy Mode (NATS + Consul)

# config.yaml
environment: development
consul:
  address: localhost:8500
nats:
  url: nats://localhost:4222
badger_password: "secure-password"
event_initiator_pubkey: "hex-encoded-pubkey"

Environment Variables

  • MPC_CONFIG - Path to config.yaml
  • LUX_MPC_BACKUP - Backup file magic (on-disk format identifier; NOT an env var)
  • MODE - "consensus" (new) or "legacy" (NATS/Consul) (Viper config key mode, override via env)

/v1/mpc/* Spec Surface (MPC API)

The /v1/mpc/* subrouter implements the frozen spec at the MPC OpenAPI spec. One and only one canonical path per operation — no aliases, no backwards compatibility layers.

Mount point: pkg/api/server.go inside the authenticated /v1 group under r.Route("/mpc", ...).

Routes:

  • GET|POST /v1/mpc/wallets, GET /v1/mpc/wallets/{id}, PATCH /v1/mpc/wallets/{id}/default, GET /v1/mpc/wallets/balances, POST /v1/mpc/wallets/sweep, GET|POST /v1/mpc/wallet, GET /v1/mpc/balances/{address}, GET /v1/mpc/crypto/wallet/{asset} — wallet surface (handlers_mpc.go).
  • POST /v1/mpc/sign, POST /v1/mpc/settlement/sign — signing (handlers_mpc.go). Rate-limited 20 RPM/IP.
  • POST /v1/mpc/webauthn/challenge|verify, POST /v1/mpc/biometric/enroll, GET /v1/mpc/biometric/status — authenticator flows.
  • GET|POST /v1/mpc/wallets/{id}/sessions, GET|DELETE /v1/mpc/wallets/{id}/sessions/{sessionId} — sessions (handlers_sessions.go). Sessions are time-bounded signing grants with operationLimit + valueLimit enforced by consumeSessionForSign.
  • GET /v1/mpc/operations, GET /v1/mpc/operations/{operationId}, POST /v1/mpc/operations/{operationId}/approve|reject — unified view over db.Transaction with kind ∈ {sign,send,mint,burn,transfer,contract_call} (handlers_operations.go).
  • GET|POST /v1/mpc/policies, GET|PATCH|DELETE /v1/mpc/policies/{policyId} — spending/approval policies (handlers_policy.go).
  • GET /v1/mpc/audit — audit trail (handlers_audit.go).

Session enforcement

db.Session (ORM-registered as "mpc-session") is a specialized short-lived Policy. Every /sign and /settlement/sign call that carries a sessionId must transactionally consume one operation + accumulate value via Server.consumeSessionForSign. Sessions expire on any of:

  1. wall-clock expiry (ExpiresAt)
  2. operation count exhaustion (OperationsUsed >= OperationLimit)
  3. cumulative value exhaustion (ValueAccum + value > ValueLimit)
  4. explicit DELETE revocation

Migration notes (Apr 2026)

  • /v1/policies → /v1/mpc/policies (top-level /v1/policies removed).
  • /v1/audit → /v1/mpc/audit (top-level /v1/audit removed).
  • /v1/transactions POST retained as an internal entry point; the read surface is /v1/mpc/operations. All Transaction records are projected into the Operation view via txType discriminator.
  • Policy handlers now accept both {id} (legacy internal) and {policyId} (spec) via urlParam fallback — internal callers unaffected.

🔐 Security Model

  • Threshold Security: No single node has the complete key
  • Message Authentication: All messages signed with Ed25519
  • Storage Encryption: BadgerDB encrypted with user password
  • Network Security: TLS + mutual authentication
  • Key Rotation: Supports resharing without changing addresses

📊 Performance

  • Key Generation: ~30s for 3 nodes
  • Signing: <1s for threshold signatures
  • Storage: ~100MB per node (with backups)
  • Network: Low bandwidth, resilient to failures

🔗 Integration with Hanzo Commerce

The MPC Signer integrates with Commerce for crypto payments:

// Commerce uses MPC via the processor interface
type MPCProcessor struct {
    kmsClient  *kms.Client   // KMS for policy/approval
    mpcClient  *mpc.Client   // MPC for signing
}

func (p *MPCProcessor) Charge(ctx context.Context, req PaymentRequest) (*PaymentResult, error) {
    // 1. KMS validates policy and approvals
    // 2. MPC signs the transaction
    // 3. Transaction broadcast to blockchain
}

🔧 Development Workflow

Testing

# Run unit tests
make test

# Run with coverage
make test-coverage

# Run E2E tests
make e2e-test

Common Tasks

  1. Generate 3-node test cluster:

    ./setup_identities.sh
    
  2. Recover from backup:

    hanzo-mpc-cli recover --backup-dir ./backups --recovery-path ./recovered-db
    
  3. Production deployment:

    • Use --encrypt flag for identity generation
    • Enable TLS on all services
    • Use --prompt-credentials to avoid hardcoded passwords

🐛 Common Issues

  1. Port conflicts: Default ports are 4222 (NATS), 8500 (Consul)
  2. Database locks: Ensure single process per node
  3. Network delays: Check NATS/Consul connectivity
  4. Backup failures: Verify disk space and permissions

CGGMP21 Protocol Issues (Debugged Jan 2026)

  1. Protocol message serialization: Protocol messages MUST use MarshalBinary/UnmarshalBinary to preserve all fields (SSID, RoundNumber, etc.). Raw JSON marshaling loses critical protocol state.

  2. Party ID ordering: Party IDs must be sorted consistently across all nodes. The GetReadyPeersIncludeSelf() function in registry.go sorts peer IDs to ensure deterministic ordering.

  3. NATS topic naming: Result topics must match JetStream stream configuration:

    • Keygen results: mpc.mpc_keygen_result.<walletID> (note the mpc.mpc_ prefix)
    • Signing results: mpc.mpc_signing_result.<walletID>
    • Stream expects pattern: mpc.mpc_*_result.*
  4. Self-message rejection: It's NORMAL for nodes to log "Handler cannot accept message" warnings when they receive their own broadcast messages back. This is expected behavior in pub/sub systems.

  5. Binary rebuild for e2e tests: E2E tests use hanzo-mpc from PATH. After code changes, run go install ./cmd/hanzo-mpc && go install ./cmd/hanzo-mpc-cli to update the installed binaries.

  6. Session result publishing pattern: Individual protocol sessions (CGGMP21, FROST) should NOT publish success events directly to the result queue. The handler (keygen_handler_cggmp21.go) is responsible for publishing the combined result with both ECDSA and EdDSA keys. Sessions should only:

    • Publish FAILURE events to the queue (for immediate error notification)
    • Send success pubkey via externalFinishChan so WaitForFinish() returns
    • Always send to externalFinishChan (even empty string for errors) to prevent blocking
  7. Dual keygen architecture: The handleKeyGenEventCGGMP21 function runs both ECDSA (CGGMP21) and EdDSA (FROST) keygen protocols in parallel via goroutines with WaitGroup. Both sessions must complete before the handler publishes the combined result containing both public keys.

FROST Signing Issues (Debugged Jan 2026)

  1. FROST config serialization (CRITICAL): frost.TaprootConfig contains crypto types (*curve.Secp256k1Scalar, *curve.Secp256k1Point) that do NOT have JSON marshalers. Using json.Marshal() corrupts the key shares. MUST use CBOR serialization via MarshalFROSTConfig() and UnmarshalFROSTConfig() in frost_config_marshal.go.

  2. FROST signing result type: The FROST Taproot signing protocol returns taproot.Signature (which is []byte of 64 bytes), NOT *frost.Signature. The signing_session_frost.go handles this correctly with: s.signature = result.(taproot.Signature).

  3. BIP-340/Taproot signature format: FROST signing produces BIP-340 compatible signatures (64 bytes: R_x || s). The taproot.Signature type is already in this format, so no additional conversion is needed in publishResult().

LSS Protocol Issues (Fixed Jan 2026)

  1. LSS config serialization (CRITICAL - FIXED): Similar to FROST, lssConfig.Config contains crypto types (curve.Scalar, curve.Point) that do NOT have JSON marshalers. Fixed by implementing MarshalLSSConfig() and UnmarshalLSSConfig() in lss_config_marshal.go using CBOR serialization.

  2. LSS capabilities vs CGGMP21: LSS supports dynamic resharing (change T-of-N without reconstructing keys), threshold changes, and adding/removing participants. CGGMP21 only supports refresh (same committee). Both produce valid ECDSA signatures.

Security Audit Findings (Jan 2026)

  1. Message authentication: Protocol messages between nodes are not signed. Ed25519 signing code exists but is disabled. Consider re-enabling for production deployments.

  2. Deduplication map cleanup: The processing map used for deduplication grows unbounded. Recommend adding TTL-based cleanup for long-running sessions.

  3. Protocol timeouts: No timeout enforcement on protocol handlers. Recommend adding context with timeout to prevent indefinite hangs from stalling parties.

Security Hardening (Apr 2026)

  1. Internal API auth (port 9800): All mutating endpoints (/keygen, /keys, /backup) now require Authorization: Bearer <token>. Token sourced from MPC_INTERNAL_API_KEY env var (KMS-synced). Falls back to deterministic derivation from Ed25519 node identity for dev. /health remains unauthenticated for K8s probes.

  2. TLS-only transport: DualModeListener replaced by TLSOnlyListener. Plaintext connections are rejected. TLS 1.3 with PQ key exchange (X25519MLKEM768) is mandatory for all peer-to-peer communication.

  3. Sign rate limiting: Bridge signing and intent signing endpoints have a dedicated 20 RPM per-IP rate limiter, separate from the global 100 RPM limit.

  4. Internal API hardening: Port 9800 now has body size limits (1 MB), read/write timeouts, and 10 RPM rate limiting on mutating endpoints.

  5. Network egress restriction: K8s NetworkPolicy for MPC nodes now restricts egress to peer nodes, postgres, valkey, DNS, and HTTPS (S3 backup) only. No unrestricted egress.

  6. Compose.yml secrets: JWT_SECRET now uses ${JWT_SECRET:?} (required) instead of a hardcoded dev default.

  7. Cluster API key forwarding: api-deployment now passes --cluster-api-key $(MPC_CLUSTER_API_KEY) when proxying to internal MPC API. The apiOnlyMPCBackend.doRequest sends it as Authorization: Bearer header.

  8. R2-1 Biometric enroll hardened: handleBiometricEnroll now verifies a WebAuthn create ceremony AND a provider-signed liveness attestation. pkg/webauthn/verify.go centralizes: challenge comparison against the random bytes we issued (not the DB row ID), origin allowlist, rpIDHash check, UP+UV flag enforcement. pkg/webauthn/liveness.go verifies Ed25519-signed LivenessAttestation envelopes against MPC_LIVENESS_PUBKEY_ED25519; body-supplied scores are rejected. If pubkey is unconfigured, enroll returns 503.

  9. R2-2 CAS under Postgres: Session consume + operation approve now use orm.GetForUpdate (SELECT ... FOR UPDATE) inside READ COMMITTED tx via orm.RunInTransactionWith(IsolationReadCommitted). Requires hanzoai/[email protected]. Postgres-backed regression tests in cas_postgres_test.go — skipped when no Postgres available, TEST_PG_DSN overrides DSN. pkg/db.New now accepts postgres://... DSNs alongside SQLite.

  10. R2-3 Tightened API-key gate: requireRoleOrAPIPermission([]string{"owner","admin","signer"}, "mpc:sign") replaces requireRole("owner","admin","signer","api") on /v1/mpc/sign and /v1/mpc/settlement/sign. Role api passes only if the key holds the named permission. A bare API key with permissions=[] is rejected.

  11. R3-2 No * wildcard in API-key permissions: hasPermission no longer honors *. Every API key must enumerate explicit permissions. Tests TestRequireRoleOrAPIPermission_WildcardRejectedOnSensitive + TestRequirePermission_WildcardRejected assert permissions=["*"] is REJECTED for every sensitive name (mpc:sign, mpc:settlement:sign, mpc:operations:approve, mpc:wallet:sweep, mpc:policy:write, plus trade:submit). Breaking change: re-issue keys with explicit permissions; bump to v1.7.0.

  12. R3-8 Liveness envelope bound to enrollment: LivenessAttestation now carries credentialHash = sha256(pubKey) and/or challengeId. handleBiometricEnroll passes expected values to VerifyLiveness so a stolen envelope cannot be replayed against a different WebAuthn public key. Mode toggle via MPC_LIVENESS_BINDING=strict|lax (default strict). Blocker for mainnet biometric enroll: the liveness provider must ship the extended envelope; while in LAX the server emits a mpc.biometric.binding_warn log on every permissive accept.

  13. F4 (2026-04-18) LAX tightening — credentialHash floor + both-must-match: Red round 4 flagged that the previous LAX mode accepted envelopes with ONLY challengeId (no credentialHash), enabling a cross-ceremony replay: an attacker observing a fresh challenge could trick the liveness provider into signing an envelope binding to the challenge alone and replay it against any enrollment for the same userId. Two tightenings in VerifyLiveness:

    • LAX + server expects credentialHash + envelope omits it → reject (credentialHash required in LAX mode). credentialHash is now a hard floor — challengeId alone cannot satisfy the binding requirement in LAX.
    • Envelope supplies BOTH credentialHash AND challengeId AND server expects both → require BOTH to match (no more "either-or" loophole).

    LAX warn-on-missing still works when the call site does NOT commit a credentialHash expectation. Tests in pkg/webauthn/liveness_test.go:

    • TestLiveness_R38_LaxRejectsMissingCredentialHash — challengeId-only envelope rejected
    • TestLiveness_R38_LaxAcceptsBothMatch — happy path (extended envelope after the liveness provider rollout)
    • TestLiveness_R38_LaxRejectsMismatchedCredentialHashBothPresent
    • TestLiveness_R38_LaxRejectsMismatchedChallengeIDBothPresent
    • TestLiveness_R38_LaxAcceptsCredentialHashOnly — current provider envelope shape
    • TestLiveness_R38_LaxRejectsMissingBindingWhenCredentialHashExpected — replaces the old lax-warns-missing path
    • TestLiveness_R38_LaxWarnsOnMissing_ChallengeIDOnly — warn path still available when credentialHash not expected

    handleBiometricEnroll always commits ExpectedCredentialHash (derived from the enrollment pubKey via sha256), so the LAX floor always engages for WebAuthn enrollment. v1.7.1 follows v1.7.0.

  14. Pluggable liveness Verifier (vendor-neutral): lux OSS names NO PAD-2 / liveness vendor. pkg/webauthn exposes the extension seam:

    • Verifier interface — Verify(envelope string, b Binding) (*LivenessAttestation, error).
    • Ed25519EnvelopeVerifier{PubKey, ProviderID} — the one OSS-shipped implementation, delegating to the audited VerifyLiveness. Zero-code path for any vendor whose attestation is an Ed25519-signed envelope.
    • RegisterVerifier(providerID, Verifier) / LookupVerifier(providerID) — registry. A white-label deployment (e.g. a regulated broker wiring a regulated PAD-2 vendor) registers its own Verifier before boot.

    pkg/api/server.go resolves the active verifier at boot from MPC_LIVENESS_PROVIDER_ID: a registered custom Verifier wins; else, if MPC_LIVENESS_PUBKEY_ED25519 is set, the OSS-default Ed25519 envelope verifier is used; with neither, livenessVerifier is nil and /v1/mpc/biometric/enroll fails closed (503). handleBiometricEnroll calls s.livenessVerifier.Verify(envelope, webauthn.Binding{...}) — no vendor code in lux. To plug a vendor in from a downstream binary:

    import "github.com/luxfi/mpc/pkg/webauthn"
    // Ed25519-envelope vendor — just supply the public key:
    webauthn.RegisterVerifier("acme-pad2", webauthn.Ed25519EnvelopeVerifier{
        PubKey: acmePubKey, ProviderID: "acme-pad2",
    })
    // or a wholly different scheme — implement webauthn.Verifier.
    

Consensus-Embedded Transport (Jan 2026)

  1. ZAP Message Types: MPC uses ZAP wire protocol message types 60-79:

    • MsgMPCBroadcast (60) - Pub/sub broadcasts
    • MsgMPCDirect (61) - Point-to-point messaging
    • MsgMPCReady (62) - Peer registry readiness
    • MsgMPCKeygen (64) - DKG protocol messages
    • MsgMPCSign (65) - Signing protocol messages
    • MsgMPCReshare (66) - Key resharing messages
    • MsgMPCResult (67) - Session result messages
  2. PoA Membership: Ed25519 public keys are used as Proof-of-Authority validators. VoterIDs are derived via SHA256("MPC/Ed25519" || pubkey).

  3. State Replication: Key metadata is replicated via consensus transport. Local BadgerDB stores encrypted key shares (not replicated for security).

🌐 Blockchain Support

Blockchain Support Curve Protocol
Bitcoin (Legacy/SegWit) ✅ Full secp256k1 CGGMP21/LSS
Bitcoin (Taproot) ✅ Full secp256k1 FROST
Ethereum/EVM ✅ Full secp256k1 CGGMP21/LSS
XRPL (XRP Ledger) ✅ Full secp256k1 CGGMP21/LSS
Lux Network ✅ Full secp256k1 CGGMP21/LSS
Polkadot/Kusama ✅ Full ristretto255 FROST (SR25519)
Solana ⚠️ Partial Ed25519 FROST (Taproot mode)
TON ⚠️ Partial Ed25519 FROST (Taproot mode)

Note: Solana/TON use Ed25519 natively but our FROST implementation produces Taproot/BIP-340 signatures. Native Ed25519 support requires implementing the Ed25519 FROST variant.

SR25519 (Ristretto255/Schnorrkel) Implementation

SR25519 threshold signing uses the generic FROST protocol over the ristretto255 prime-order group:

  • Keygen: frost.Keygen(Ristretto255{}, ...) produces *frost.Config with ristretto255 curve types
  • Signing: frost.Sign(config, signers, message) with signing context prepended to message
  • Signing context: Default "substrate", configurable per-session (Schnorrkel convention)
  • Storage: Key shares stored with sr25519: prefix in BadgerDB, serialized via CBOR
  • Signature format: R (32 bytes, ristretto point) || z (32 bytes, ristretto scalar) = 64 bytes
  • Ristretto255 curve types: Implemented locally in pkg/mpc/ristretto255.go using gtank/ristretto255 library, satisfying the threshold library's curve.Curve interface. Will be replaced when threshold library publishes native Ristretto255 support.

🎯 Best Practices

  1. Always backup BadgerDB before major operations
  2. Test locally with 3-node setup before production
  3. Monitor health via Consul UI (http://localhost:8500)
  4. Rotate keys periodically using reshare functionality
  5. Use Age encryption for production identities
  6. Keep logs for debugging MPC rounds

Context for All AI Assistants

This file (LLM.md) is symlinked as:

  • .AGENTS.md
  • CLAUDE.md
  • QWEN.md
  • GEMINI.md

All files reference the same knowledge base. Updates here propagate to all AI systems.

Rules for AI Assistants

  1. ALWAYS update LLM.md with significant discoveries
  2. NEVER commit symlinked files (.AGENTS.md, CLAUDE.md, etc.) - they're in .gitignore
  3. NEVER create random summary files - update THIS file