mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
lux OSS names no PAD-2 / liveness vendor. Decomplect the verifier into a
pluggable extension point so a white-label deployment (e.g. liquidity)
registers its own provider without forking:
- pkg/webauthn: add Verifier interface + Binding (vendor-neutral per-call
input) + Ed25519EnvelopeVerifier (OSS default, delegates to the audited
VerifyLiveness) + RegisterVerifier/LookupVerifier registry. Tests cover
the default verifier, the registry, and a custom non-Ed25519 scheme.
- pkg/api: resolve the active Verifier at boot from MPC_LIVENESS_PROVIDER_ID
(registered custom wins; else Ed25519 from MPC_LIVENESS_PUBKEY_ED25519;
else nil → enroll fails closed 503). handleBiometricEnroll calls
s.livenessVerifier.Verify(...) — no vendor code in lux.
- Rename MPC__* env vars → MPC_LIVENESS_*; drop every
identifier/comment across code + LLM.md. Zero /onyxd in lux/mpc.
Also finish the half-done decomplect from a326cc6 'rip zapauth gate' that
left pkg/api/zap_kms_server.go calling a removed checkAuth and missing the
encoding/json import — restore the import, drop the orphaned guard.
(Pre-existing, unrelated: cmd/mpcd still references thresholdd.NewZapServer
from the same in-flight WIP — not touched here.)
433 lines
15 KiB
Go
433 lines
15 KiB
Go
// Package api — KMS-facing ZAP server.
|
|
//
|
|
// This file implements a luxfi/zap node that exposes MPC threshold operations
|
|
// to the KMS control plane. KMS dials this node, runs the ML-KEM-768 hybrid
|
|
// handshake (kms/pkg/zap), then issues opcode-prefixed JSON requests for
|
|
// keygen / sign / reshare / status / wallet lookup. The wire contract is the
|
|
// one defined by kms/pkg/mpc/zap_client.go — opcodes 0x0001-0x0031.
|
|
//
|
|
// The existing pkg/api zap server (zap_server.go, opcodes 0x0060+) is for
|
|
// MPC's own dashboard event bus and uses a different wire format. The two
|
|
// servers serve different consumers and run on different ports.
|
|
//
|
|
// Wire format per request: opcode(2 LE) || JSON payload.
|
|
// Response: opcode(2 LE) || JSON body. Errors set top-level "error" string.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"github.com/luxfi/mpc/pkg/logger"
|
|
"github.com/luxfi/zap"
|
|
|
|
kmszap "github.com/luxfi/mpc/pkg/zap"
|
|
)
|
|
|
|
// KMS-facing opcode constants. These are the contract with
|
|
// luxfi/kms/pkg/mpc/zap_client.go and MUST stay in lockstep with that file.
|
|
const (
|
|
OpKMSStatus uint16 = 0x0001
|
|
OpKMSKeygen uint16 = 0x0010
|
|
OpKMSSign uint16 = 0x0011
|
|
OpKMSReshare uint16 = 0x0012
|
|
OpKMSWallet uint16 = 0x0020
|
|
)
|
|
|
|
// KMSZapServer wires MPCBackend onto a luxfi/zap.Node and frames responses in
|
|
// KMS's opcode-prefixed JSON dialect.
|
|
type KMSZapServer struct {
|
|
backend MPCBackend
|
|
|
|
// sessions holds per-peer hybrid handshake state. Once a peer runs the
|
|
// ML-KEM-768 handshake (opcode 0x00F0), every subsequent request is
|
|
// AEAD-sealed under the derived session key. A peer with no entry can
|
|
// still talk in the clear — forward-only fallback so older KMS builds
|
|
// that pre-date the handshake (#55) keep working until they upgrade.
|
|
sessions map[string]*kmszap.Session
|
|
sessionsMu sync.RWMutex
|
|
|
|
// localCaps controls what the server advertises in ServerHello. ML-KEM
|
|
// is on by default; we never opt out unilaterally.
|
|
localCaps uint16
|
|
|
|
node *zap.Node
|
|
}
|
|
|
|
// StartKMSZAP creates a luxfi/zap.Node bound to listenAddr and registers KMS
|
|
// opcode handlers. Returns the server (so callers can stop it on shutdown).
|
|
//
|
|
// nodeID is the ZAP advertised identity — KMS uses it as the auditable
|
|
// principal for sign/keygen requests. Every MPC StatefulSet pod gets a unique
|
|
// ID (mpc-kms-zap-<pod>) so KMS audit rows can pin a request to a node.
|
|
//
|
|
// Trust is enforced at the network boundary (NetworkPolicy + ZAP wire).
|
|
func StartKMSZAP(backend MPCBackend, nodeID, listenAddr string) (*KMSZapServer, error) {
|
|
port, err := parsePort(listenAddr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zap: parse listen %q: %w", listenAddr, err)
|
|
}
|
|
|
|
node := zap.NewNode(zap.NodeConfig{
|
|
NodeID: nodeID,
|
|
ServiceType: "_mpc-kms._tcp",
|
|
Port: port,
|
|
NoDiscovery: true, // direct dial only — no mDNS leakage from prod pods
|
|
})
|
|
|
|
s := &KMSZapServer{
|
|
backend: backend,
|
|
sessions: make(map[string]*kmszap.Session),
|
|
localCaps: kmszap.CapMLKEM768,
|
|
node: node,
|
|
}
|
|
|
|
// Application-layer hybrid handshake. Distinct opcode so a client can
|
|
// negotiate before sending any threshold-op payload.
|
|
node.Handle(kmszap.OpClientHello, s.handleHandshake)
|
|
|
|
// KMS opcodes — the public surface.
|
|
node.Handle(OpKMSStatus, s.handle(s.handleStatus))
|
|
node.Handle(OpKMSKeygen, s.handle(s.handleKeygen))
|
|
node.Handle(OpKMSSign, s.handle(s.handleSign))
|
|
node.Handle(OpKMSReshare, s.handle(s.handleReshare))
|
|
node.Handle(OpKMSWallet, s.handle(s.handleWallet))
|
|
|
|
// Universal type=0 mux: clients that don't set the per-message type
|
|
// field (zap.Builder default) reach handlers via the opcode embedded in
|
|
// the body bytes. Mirrors kms/pkg/zapserver Register().
|
|
node.Handle(0, s.muxByOpcode)
|
|
|
|
if err := node.Start(); err != nil {
|
|
return nil, fmt.Errorf("zap: start node: %w", err)
|
|
}
|
|
logger.Info("KMS ZAP server started",
|
|
"addr", listenAddr, "nodeID", nodeID,
|
|
"caps", "X25519+ML-KEM-768")
|
|
return s, nil
|
|
}
|
|
|
|
// Stop tears down the underlying ZAP node. Idempotent.
|
|
func (s *KMSZapServer) Stop() {
|
|
if s.node != nil {
|
|
s.node.Stop()
|
|
}
|
|
}
|
|
|
|
// handlerFn is the shape of an op handler before opcode framing.
|
|
type handlerFn func(ctx context.Context, from string, payload []byte) ([]byte, error)
|
|
|
|
// handle wraps an opcode handler with payload extraction, optional session
|
|
// AEAD, and opcode-prefixed JSON response framing. The opcode comes from
|
|
// the first 2 LE bytes of the body — the same prefix the client wrote.
|
|
func (s *KMSZapServer) handle(op handlerFn) zap.Handler {
|
|
return func(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
|
|
raw := extractPayload(msg)
|
|
if len(raw) < 2 {
|
|
return s.respond(from, 0, errBody("empty payload")), nil
|
|
}
|
|
opcode := binary.LittleEndian.Uint16(raw[:2])
|
|
payload := raw[2:]
|
|
// Session-sealed payloads are AEAD-opened before dispatch.
|
|
if sess := s.session(from); sess != nil {
|
|
pt, err := sess.Open(kmszap.DirClientToServer, payload)
|
|
if err != nil {
|
|
logger.Warn("kms-zap session open failed", "from", from, "op", opcode, "err", err)
|
|
return s.respond(from, opcode, errBody("session decrypt failed")), nil
|
|
}
|
|
payload = pt
|
|
}
|
|
body, err := op(ctx, from, payload)
|
|
if err != nil {
|
|
logger.Warn("kms-zap handler error", "from", from, "op", opcode, "err", err)
|
|
return s.respond(from, opcode, errBody(err.Error())), nil
|
|
}
|
|
return s.respond(from, opcode, body), nil
|
|
}
|
|
}
|
|
|
|
// muxByOpcode dispatches type-0 messages by reading the opcode from the body.
|
|
// Clients that don't set the ZAP message type field land here.
|
|
func (s *KMSZapServer) muxByOpcode(ctx context.Context, from string, msg *zap.Message) (*zap.Message, error) {
|
|
raw := extractPayload(msg)
|
|
if len(raw) < 2 {
|
|
return s.respond(from, 0, errBody("empty payload")), nil
|
|
}
|
|
op := binary.LittleEndian.Uint16(raw[:2])
|
|
if op == kmszap.OpClientHello {
|
|
return s.respondHandshake(from, raw[2:]), nil
|
|
}
|
|
dispatch := map[uint16]handlerFn{
|
|
OpKMSStatus: s.handleStatus,
|
|
OpKMSKeygen: s.handleKeygen,
|
|
OpKMSSign: s.handleSign,
|
|
OpKMSReshare: s.handleReshare,
|
|
OpKMSWallet: s.handleWallet,
|
|
}
|
|
h, ok := dispatch[op]
|
|
if !ok {
|
|
return s.respond(from, op, errBody(fmt.Sprintf("unknown opcode 0x%04x", op))), nil
|
|
}
|
|
payload := raw[2:]
|
|
if sess := s.session(from); sess != nil {
|
|
pt, err := sess.Open(kmszap.DirClientToServer, payload)
|
|
if err != nil {
|
|
logger.Warn("kms-zap mux session open failed", "from", from, "op", op, "err", err)
|
|
return s.respond(from, op, errBody("session decrypt failed")), nil
|
|
}
|
|
payload = pt
|
|
}
|
|
body, err := h(ctx, from, payload)
|
|
if err != nil {
|
|
logger.Warn("kms-zap mux handler error", "from", from, "op", op, "err", err)
|
|
return s.respond(from, op, errBody(err.Error())), nil
|
|
}
|
|
return s.respond(from, op, body), nil
|
|
}
|
|
|
|
// extractPayload pulls the application bytes out of a ZAP message,
|
|
// handling both the structured-Object form and the raw-bytes-after-header
|
|
// form used by zap.Builder.WriteBytes.
|
|
func extractPayload(msg *zap.Message) []byte {
|
|
if raw := msg.Root().Bytes(0); raw != nil {
|
|
return raw
|
|
}
|
|
b := msg.Bytes()
|
|
if len(b) > zap.HeaderSize {
|
|
return b[zap.HeaderSize:]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// respond frames opcode(2 LE) || body and seals under the peer's session
|
|
// key when one exists. Output matches what kms/pkg/mpc/zap_client.go expects.
|
|
func (s *KMSZapServer) respond(peerID string, opcode uint16, body []byte) *zap.Message {
|
|
if sess := s.session(peerID); sess != nil {
|
|
sealed, err := sess.Seal(kmszap.DirServerToClient, body)
|
|
if err != nil {
|
|
logger.Error("kms-zap seal failed", err, "from", peerID)
|
|
body = errBody("session seal failed")
|
|
} else {
|
|
body = sealed
|
|
}
|
|
}
|
|
out := make([]byte, 2+len(body))
|
|
binary.LittleEndian.PutUint16(out[0:2], opcode)
|
|
copy(out[2:], body)
|
|
b := zap.NewBuilder(len(out) + 64)
|
|
b.WriteBytes(out)
|
|
m, err := zap.Parse(b.Finish())
|
|
if err != nil {
|
|
// Builder output is always parseable — invariant violation.
|
|
logger.Error("kms-zap respond build failed", err)
|
|
return nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
func errBody(msg string) []byte {
|
|
b, _ := json.Marshal(map[string]string{"error": msg})
|
|
return b
|
|
}
|
|
|
|
// session returns the active hybrid session for a peer, or nil when the
|
|
// peer hasn't run OpClientHello.
|
|
func (s *KMSZapServer) session(peerID string) *kmszap.Session {
|
|
s.sessionsMu.RLock()
|
|
defer s.sessionsMu.RUnlock()
|
|
return s.sessions[peerID]
|
|
}
|
|
|
|
// setSession stores a freshly negotiated session, replacing any prior entry.
|
|
func (s *KMSZapServer) setSession(peerID string, sess *kmszap.Session) {
|
|
s.sessionsMu.Lock()
|
|
s.sessions[peerID] = sess
|
|
s.sessionsMu.Unlock()
|
|
}
|
|
|
|
// handleHandshake is the per-opcode handler for OpClientHello.
|
|
func (s *KMSZapServer) handleHandshake(_ context.Context, from string, msg *zap.Message) (*zap.Message, error) {
|
|
// Trust is at the network boundary (NetworkPolicy + ZAP wire), not an
|
|
// application-layer auth gate — see 7600a13 "rip zapauth gate".
|
|
raw := extractPayload(msg)
|
|
if len(raw) < 2 {
|
|
return s.respond(from, kmszap.OpServerHello, errBody("empty handshake payload")), nil
|
|
}
|
|
// Strip 2-byte opcode prefix; client mux puts opcode in body.
|
|
return s.respondHandshake(from, raw[2:]), nil
|
|
}
|
|
|
|
// respondHandshake runs the server side of the hybrid PQ handshake, installs
|
|
// the session, and returns ServerHello as the response payload (opcode 0x00F1).
|
|
//
|
|
// ServerHello rides un-AEAD'd because the key only exists after this exchange
|
|
// completes. Subsequent opcodes get sealed automatically.
|
|
func (s *KMSZapServer) respondHandshake(from string, helloBytes []byte) *zap.Message {
|
|
replyWire, result, err := kmszap.ServerRespond(s.localCaps, helloBytes)
|
|
if err != nil {
|
|
logger.Warn("kms-zap handshake failed", "from", from, "err", err)
|
|
return s.respond(from, kmszap.OpServerHello, errBody(err.Error()))
|
|
}
|
|
sess, err := kmszap.NewSession(result.SessionKey, result.Hybrid)
|
|
if err != nil {
|
|
logger.Error("kms-zap session init failed", err, "from", from)
|
|
return s.respond(from, kmszap.OpServerHello, errBody(err.Error()))
|
|
}
|
|
s.setSession(from, sess)
|
|
if !result.Hybrid {
|
|
logger.Warn("kms-zap handshake classical-only — peer cleared ML-KEM-768",
|
|
"from", from, "peerCaps", result.PeerCaps)
|
|
} else {
|
|
logger.Info("kms-zap handshake hybrid", "from", from, "alg", "X25519+ML-KEM-768")
|
|
}
|
|
// Frame the server hello: opcode(0x00F1) || helloBytes (no AEAD).
|
|
out := make([]byte, 2+len(replyWire))
|
|
binary.LittleEndian.PutUint16(out[0:2], kmszap.OpServerHello)
|
|
copy(out[2:], replyWire)
|
|
b := zap.NewBuilder(len(out) + 64)
|
|
b.WriteBytes(out)
|
|
m, err := zap.Parse(b.Finish())
|
|
if err != nil {
|
|
logger.Error("kms-zap handshake reply build failed", err)
|
|
return nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
// ---- KMS opcode handlers ----
|
|
|
|
// kmsZapStatusResponse mirrors kms/pkg/mpc/types.go ClusterStatus on the wire.
|
|
type kmsZapStatusResponse struct {
|
|
NodeID string `json:"node_id"`
|
|
Mode string `json:"mode"`
|
|
ExpectedPeers int `json:"expected_peers"`
|
|
ConnectedPeers int `json:"connected_peers"`
|
|
Ready bool `json:"ready"`
|
|
Threshold int `json:"threshold"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
func (s *KMSZapServer) handleStatus(_ context.Context, _ string, _ []byte) ([]byte, error) {
|
|
st := s.backend.GetClusterStatus()
|
|
resp := kmsZapStatusResponse{
|
|
NodeID: st.NodeID,
|
|
Mode: st.Mode,
|
|
ExpectedPeers: st.ExpectedPeers,
|
|
ConnectedPeers: st.ConnectedPeers,
|
|
Ready: st.Ready,
|
|
Threshold: st.Threshold,
|
|
Version: st.Version,
|
|
}
|
|
return json.Marshal(resp)
|
|
}
|
|
|
|
// kmsZapKeygenRequest matches kms/pkg/mpc/zap_client.go Keygen() payload shape.
|
|
type kmsZapKeygenRequest struct {
|
|
VaultID string `json:"vault_id"`
|
|
Request struct {
|
|
WalletID string `json:"wallet_id"`
|
|
KeyType string `json:"key_type,omitempty"`
|
|
} `json:"request"`
|
|
}
|
|
|
|
func (s *KMSZapServer) handleKeygen(_ context.Context, from string, payload []byte) ([]byte, error) {
|
|
var req kmsZapKeygenRequest
|
|
if err := json.Unmarshal(payload, &req); err != nil {
|
|
return nil, fmt.Errorf("decode keygen: %w", err)
|
|
}
|
|
if req.VaultID == "" {
|
|
return nil, fmt.Errorf("vault_id required")
|
|
}
|
|
logger.Info("kms-zap keygen", "from", from, "vault", req.VaultID, "wallet", req.Request.WalletID)
|
|
result, err := s.backend.TriggerKeygen(req.VaultID, req.Request.WalletID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(result)
|
|
}
|
|
|
|
// kmsZapSignRequest matches kms/pkg/mpc/zap_client.go Sign() payload shape.
|
|
type kmsZapSignRequest struct {
|
|
VaultID string `json:"vault_id"`
|
|
WalletID string `json:"wallet_id"`
|
|
Payload []byte `json:"payload"`
|
|
}
|
|
|
|
func (s *KMSZapServer) handleSign(_ context.Context, from string, payload []byte) ([]byte, error) {
|
|
var req kmsZapSignRequest
|
|
if err := json.Unmarshal(payload, &req); err != nil {
|
|
return nil, fmt.Errorf("decode sign: %w", err)
|
|
}
|
|
if req.VaultID == "" || req.WalletID == "" {
|
|
return nil, fmt.Errorf("vault_id and wallet_id required")
|
|
}
|
|
if len(req.Payload) == 0 {
|
|
return nil, fmt.Errorf("payload required")
|
|
}
|
|
logger.Info("kms-zap sign", "from", from, "vault", req.VaultID, "wallet", req.WalletID, "payloadHex", hex.EncodeToString(req.Payload[:min(8, len(req.Payload))]))
|
|
result, err := s.backend.TriggerSign(req.VaultID, req.WalletID, req.Payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(result)
|
|
}
|
|
|
|
// kmsZapReshareRequest matches kms/pkg/mpc/zap_client.go Reshare() payload shape.
|
|
type kmsZapReshareRequest struct {
|
|
WalletID string `json:"wallet_id"`
|
|
Request struct {
|
|
VaultID string `json:"vault_id"`
|
|
NewThreshold int `json:"new_threshold"`
|
|
NewParticipants []string `json:"new_participants"`
|
|
} `json:"request"`
|
|
}
|
|
|
|
func (s *KMSZapServer) handleReshare(_ context.Context, from string, payload []byte) ([]byte, error) {
|
|
var req kmsZapReshareRequest
|
|
if err := json.Unmarshal(payload, &req); err != nil {
|
|
return nil, fmt.Errorf("decode reshare: %w", err)
|
|
}
|
|
if req.WalletID == "" || req.Request.VaultID == "" {
|
|
return nil, fmt.Errorf("wallet_id and request.vault_id required")
|
|
}
|
|
logger.Info("kms-zap reshare", "from", from, "wallet", req.WalletID, "newT", req.Request.NewThreshold)
|
|
if err := s.backend.TriggerReshare(req.Request.VaultID, req.WalletID, req.Request.NewThreshold, req.Request.NewParticipants); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(map[string]bool{"ok": true})
|
|
}
|
|
|
|
// handleWallet is a metadata read — mirrors GET /v1/wallets/{id}. We don't
|
|
// have a generic wallet-by-id lookup on MPCBackend; return cluster status as
|
|
// a placeholder so KMS Status() works and reshape later when MPCBackend grows
|
|
// a Wallet() method. Until then a Wallet() call returns a 503-shaped error
|
|
// the KMS client passes through.
|
|
func (s *KMSZapServer) handleWallet(_ context.Context, _ string, _ []byte) ([]byte, error) {
|
|
return nil, fmt.Errorf("wallet lookup not yet exposed on MPCBackend; use /v1/wallets HTTP")
|
|
}
|
|
|
|
// parsePort extracts the numeric port from a host:port listen string.
|
|
func parsePort(listenAddr string) (int, error) {
|
|
for i := len(listenAddr) - 1; i >= 0; i-- {
|
|
if listenAddr[i] == ':' {
|
|
var p int
|
|
if _, err := fmt.Sscanf(listenAddr[i+1:], "%d", &p); err != nil {
|
|
return 0, err
|
|
}
|
|
return p, nil
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("no port in %q", listenAddr)
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|