Files
zeekay aa697bfc10 feat(mpcd): expose ZAP server on :9970 for KMS handshake
Adds threshold-signing transport so KMS can use mpcd as MPC backend.
Reuses ML-KEM-768 hybrid handshake from luxfi/kms/pkg/zap (#55) by
vendoring the same construction into mpc/pkg/zap — wire constants
(opcodes 0x00F0/0x00F1, HKDF info "kms/zap/v1/session", direction
tags) stay in lockstep with KMS.

New surface:
- pkg/zap: vendored handshake + session (forward-only convergent)
- pkg/api/zap_kms_server.go: KMS-facing ZAP node (opcodes
  0x0001/0x0010/0x0011/0x0012/0x0020) wired onto MPCBackend
- cmd/mpcd: --kms-zap-listen flag (default :9970), enabled out of the
  box; default-disable by passing empty string

Existing pkg/api/zap_server.go (dashboard event bus, opcodes 0x0060+)
is unchanged — different consumer, different port.

Closes the secrets-only mode gap on mainnet (#70).
2026-05-01 01:35:13 -07:00

113 lines
4.1 KiB
Go

// Session-layer framing helpers built on top of the handshake module.
//
// A completed handshake yields a 32-byte AES-256-GCM key. Every secret-
// carrying payload is sealed with that key before it crosses the wire and
// opened on the receiver. The ZAP frame envelope (opcode, message header)
// stays in clear — only the payload bytes the application supplies are
// confidential.
//
// Nonces are 12 bytes (GCM standard): 4-byte direction tag || 8-byte
// big-endian monotonic counter. The direction tag prevents a sealed frame
// the server emits from being re-injected on the client→server channel
// and vice versa. The counter is enforced never to wrap inside a single
// session; the underlying TCP stream from luxfi/zap delivers frames
// strictly in-order so peer counters stay in lockstep without any
// sequence number on the wire.
//
// Wire layout for a sealed payload:
//
// +0 ciphertext || GCM tag
//
// A replayed frame opens with a nonce mismatch (the receiver counter has
// advanced) and the AEAD rejects it. A tampered frame fails the GCM tag
// the same way. The handshake transcript hash binds the session key to
// both hello frames, so a captured ClientHello cannot resume into a
// previously observed session.
package zap
import (
"crypto/cipher"
"encoding/binary"
"errors"
"fmt"
"sync/atomic"
)
// Direction tags mix into the nonce so a frame the server seals is
// undecryptable on the server's own opener and vice versa.
const (
DirClientToServer uint32 = 0x43325300 // "C2S\x00"
DirServerToClient uint32 = 0x53324300 // "S2C\x00"
)
// Session is the per-connection AEAD state. SessionKey is the 32-byte
// HKDF output from the handshake; Hybrid records whether ML-KEM-768 ran
// for operator-visible logging.
type Session struct {
aead cipher.AEAD
hybrid bool
sendCtr atomic.Uint64
recvCtr atomic.Uint64
}
// NewSession wraps a 32-byte handshake-derived key in a Session ready to
// seal and open frames.
func NewSession(sessionKey []byte, hybrid bool) (*Session, error) {
a, err := SessionAEAD(sessionKey)
if err != nil {
return nil, err
}
return &Session{aead: a, hybrid: hybrid}, nil
}
// Hybrid reports whether the session was negotiated with ML-KEM-768
// active. Operators log this once per session at INFO.
func (s *Session) Hybrid() bool { return s.hybrid }
// Seal encrypts plaintext payload bytes for the given direction. The
// returned slice is the GCM ciphertext+tag (no length prefix).
//
// The send counter advances on every call; callers must use the same
// Session instance for every frame they send. Concurrent Seal is safe.
func (s *Session) Seal(direction uint32, plaintext []byte) ([]byte, error) {
ctr := s.sendCtr.Add(1)
if ctr == 0 {
return nil, errors.New("zap/session: send counter overflow")
}
nonce := makeNonce(direction, ctr)
return s.aead.Seal(nil, nonce, plaintext, nil), nil
}
// Open verifies and decrypts a sealed frame. The receive counter
// advances on every call; an out-of-order or replayed frame fails GCM
// and the counter is rolled back so the channel stays usable.
func (s *Session) Open(direction uint32, sealed []byte) ([]byte, error) {
if len(sealed) < s.aead.Overhead() {
return nil, errors.New("zap/session: sealed frame too short")
}
ctr := s.recvCtr.Add(1)
if ctr == 0 {
return nil, errors.New("zap/session: recv counter overflow")
}
nonce := makeNonce(direction, ctr)
pt, err := s.aead.Open(nil, nonce, sealed, nil)
if err != nil {
// Roll the counter back so a benign retry can succeed; a true
// attacker still cannot forge under the AEAD key.
s.recvCtr.Add(^uint64(0))
return nil, fmt.Errorf("zap/session: aead open: %w", err)
}
return pt, nil
}
// makeNonce builds a 12-byte GCM nonce: 4 bytes of direction tag || 8
// bytes of big-endian counter. Big-endian on the counter makes the nonce
// monotonically increase as a byte string, which is the documented GCM
// safe pattern for monotonic counters.
func makeNonce(direction uint32, counter uint64) []byte {
out := make([]byte, 12)
binary.BigEndian.PutUint32(out[0:4], direction)
binary.BigEndian.PutUint64(out[4:12], counter)
return out
}