Files
kms/pkg/zap/handshake.go
zeekayandHanzo Dev a20ace0dd0 a request names the channel it is for, and is honoured only there
Key agreement gives two endpoints a private channel. It does not tell
either of them who is on the far end, so a signature on a request proves
who wrote it and not who they wrote it to. Anyone able to agree keys with
both sides can therefore carry a signed request from one channel to
another and be served on it.

The handshake now derives a second value alongside the session key, under
its own label: a binding that names the channel. Only the two endpoints
that ran that handshake can compute it. The caller signs it into every
envelope; the peer checks it against its own side of the channel the
envelope arrived on. A request written for one channel is inert on any
other — it cannot be re-signed without the caller's key, and a channel
cannot be made to produce a name it did not derive.

It is not optional anywhere it could be skipped:

  - NewSession takes the handshake result, so a session always carries
    the binding derived with its key.
  - envelope.Build requires one, so an envelope addressed to nobody
    cannot be signed; Parse requires one, so it cannot be read.
  - envelope.Verify takes the arriving channel as a parameter, so there
    is no way to verify without saying where the request came from.
  - Both transports supply their own: the ZAP wire its session's, the
    HTTP surface the TLS exporter. A connection with no keys of its own
    has no binding and can carry no envelope.

zapclient loses SkipHandshake and RequireSession. With the binding
required, a client without a session has nothing to sign and cannot
speak, so the dial either agrees keys or fails, and a peer that answers
classical-only when ML-KEM-768 was offered is refused outright rather
than by configuration. VerifyEnvelope goes with them: a verify entry
that checked neither the ledger nor the channel had no callers and no
business existing.

Tests: an attacker that terminates and re-originates both halves of the
handshake, built from the shipping client and server, is refused — both
when it forwards the envelope verbatim and when it rewrites the name to
its own. A caller reaching the KMS directly still gets its secret.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-15 04:49:47 -07:00

432 lines
14 KiB
Go

// Package zap implements the application-layer hybrid post-quantum
// handshake for the KMS ZAP wire protocol.
//
// LP-022 specifies that ZAP transport runs over TLS 1.3 with Go 1.26's
// X25519MLKEM768 native hybrid curve. That covers the ingress path. For
// pod-to-pod traffic that does NOT route through ingress (in-cluster
// ZAP-to-ZAP), the connection is plaintext or classical-TLS only — no
// post-quantum key agreement at the wire layer.
//
// This package adds an application-layer hybrid handshake on top of
// every ZAP connection, irrespective of TLS configuration:
//
// 1. Classical X25519 ECDH (RFC 7748).
// 2. ML-KEM-768 KEM encapsulation (FIPS 203 / NIST PQC Level 3).
// 3. Combined shared secret = HKDF-SHA256(X25519_shared || MLKEM_shared).
// 4. Derived AES-256-GCM session key for the rest of the connection.
// 5. A binding: a second, separately labelled derivation from the same
// secret, which names this channel and no other.
//
// Capability negotiation: a 2-byte cap bitmap rides in every hello frame.
// Bit 0 = ML-KEM-768 supported. If a peer clears bit 0, both sides fall
// back to classical-only and log a warning. The fallback path exists for
// inter-version compatibility — a forward-only stack would refuse, but
// at the wire level refusing breaks rolling upgrades.
//
// Wire format (little-endian where multi-byte):
//
// ClientHello (frame opcode 0x00F0):
// +0 uint16 caps // bit 0 = ML-KEM-768 supported
// +2 [32]byte x25519_pk // ephemeral X25519 public key
// +34 [1184]byte mlkem_pk // ML-KEM-768 public key (omit if !cap0)
//
// ServerHello (frame opcode 0x00F1):
// +0 uint16 caps // bit 0 echoed (set iff both sides support)
// +2 [32]byte x25519_pk // ephemeral X25519 public key
// +34 [1088]byte mlkem_ct // ML-KEM-768 ciphertext (omit if !cap0)
//
// The combined secret feeds HKDF-SHA256 with a transcript hash salt
// (SHA-256 of the concatenated hello frames) and yields two 32-byte
// outputs under distinct labels: "kms/zap/v1/session" is the
// AES-256-GCM session key, "kms/zap/v1/bind" is the binding.
//
// The binding is what makes the key agreement worth having. Ephemeral
// key agreement alone tells each side it is talking to *somebody* over
// a private channel; it does not say to whom. A caller that already
// holds a trusted signing identity closes that gap by signing the
// binding along with its request (see pkg/envelope): the signature then
// names a channel, and the peer accepts it only on the channel it
// names. Two endpoints share a binding exactly when they share the
// secret — which is to say, exactly when nobody sits between them
// holding keys of their own.
package zap
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"hash"
"io"
mlkem "github.com/cloudflare/circl/kem/mlkem/mlkem768"
"golang.org/x/crypto/hkdf"
)
// HandshakeOpcodes are reserved at the high end of the KMS opcode space.
// See pkg/zapserver/server.go (OpSecret* = 0x0040..0x0043). 0x00F0/0x00F1
// avoid the secret opcode block and any future extensions there.
const (
OpClientHello uint16 = 0x00F0
OpServerHello uint16 = 0x00F1
)
// Capability bits. A peer ANDs its caps with the remote caps and uses
// the intersection to decide which key-agreement primitives to run.
const (
CapMLKEM768 uint16 = 1 << 0
)
// Sizes per FIPS 203 (ML-KEM-768) and RFC 7748 (X25519).
const (
X25519PubSize = 32
X25519PrivSize = 32
MLKEMPubSize = mlkem.PublicKeySize // 1184
MLKEMCtSize = mlkem.CiphertextSize // 1088
MLKEMSharedSize = mlkem.SharedKeySize // 32
SessionKeySize = 32 // AES-256-GCM
BindSize = 32 // channel binding
)
// HKDF labels. Each output is a separate expansion of the same shared
// secret, so knowing one says nothing about the other.
const (
infoSession = "kms/zap/v1/session"
infoBind = "kms/zap/v1/bind"
)
// Errors.
var (
ErrTruncated = errors.New("zap/handshake: frame truncated")
ErrCapMismatch = errors.New("zap/handshake: peer rejected ML-KEM-768")
ErrInvalidPubKey = errors.New("zap/handshake: invalid public key")
)
// HandshakeResult is the output of a completed handshake. SessionKey is
// 32 bytes suitable for AES-256-GCM. Hybrid is true iff ML-KEM-768 ran.
//
// Bind names this channel. It is 32 bytes, it is not on the wire, and
// only the two endpoints that ran this handshake can compute it. A
// caller signs it into every request so the peer can tell a request
// made to it from a request made to somebody else and passed along.
type HandshakeResult struct {
SessionKey []byte // 32 bytes
Bind []byte // 32 bytes
Hybrid bool // true = X25519+ML-KEM-768; false = X25519 only
PeerCaps uint16 // capability bitmap the peer advertised
}
// ClientHelloFrame is the parsed first message a client sends.
type ClientHelloFrame struct {
Caps uint16
X25519Pub [X25519PubSize]byte
MLKEMPub []byte // len == MLKEMPubSize iff caps&CapMLKEM768
}
// ServerHelloFrame is the parsed reply.
type ServerHelloFrame struct {
Caps uint16
X25519Pub [X25519PubSize]byte
MLKEMCt []byte // len == MLKEMCtSize iff caps&CapMLKEM768
}
// ClientState holds the ephemeral private keys a client needs to keep
// between sending hello and processing the server reply.
type ClientState struct {
caps uint16
x25519Priv *ecdh.PrivateKey
mlkemPriv *mlkem.PrivateKey
helloBytes []byte // serialized client hello, used in transcript hash
}
// ServerState holds keys derived during a server-side encapsulation; the
// caller produces a ServerHelloFrame and a SessionKey atomically via
// ServerRespond.
type ServerState struct {
helloBytes []byte // serialized server hello
}
// NewClient generates ephemeral key material and serializes a
// ClientHello. localCaps controls what the client offers; bit 0 set
// requests ML-KEM-768.
func NewClient(localCaps uint16) (*ClientState, []byte, error) {
xPriv, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("zap/handshake: gen X25519: %w", err)
}
st := &ClientState{caps: localCaps, x25519Priv: xPriv}
frame := &ClientHelloFrame{Caps: localCaps}
copy(frame.X25519Pub[:], xPriv.PublicKey().Bytes())
if localCaps&CapMLKEM768 != 0 {
mPub, mPriv, err := mlkem.GenerateKeyPair(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("zap/handshake: gen ML-KEM: %w", err)
}
st.mlkemPriv = mPriv
buf := make([]byte, MLKEMPubSize)
mPub.Pack(buf)
frame.MLKEMPub = buf
}
wire, err := MarshalClientHello(frame)
if err != nil {
return nil, nil, err
}
st.helloBytes = wire
return st, wire, nil
}
// ServerRespond consumes a serialized ClientHello, performs X25519 ECDH
// and (if both peers offer ML-KEM-768) ML-KEM encapsulation, and returns
// (ServerHello bytes, session key, hybrid bool, error).
//
// localCaps is what the server supports. The reply caps are the AND of
// local and remote caps.
func ServerRespond(localCaps uint16, clientHelloWire []byte) (replyWire []byte, result *HandshakeResult, err error) {
client, err := UnmarshalClientHello(clientHelloWire)
if err != nil {
return nil, nil, err
}
negotiated := localCaps & client.Caps
hybrid := negotiated&CapMLKEM768 != 0
// Classical X25519 ECDH.
clientX, err := ecdh.X25519().NewPublicKey(client.X25519Pub[:])
if err != nil {
return nil, nil, fmt.Errorf("%w: X25519: %v", ErrInvalidPubKey, err)
}
serverXPriv, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("zap/handshake: gen X25519: %w", err)
}
xShared, err := serverXPriv.ECDH(clientX)
if err != nil {
return nil, nil, fmt.Errorf("zap/handshake: X25519 ECDH: %w", err)
}
reply := &ServerHelloFrame{Caps: negotiated}
copy(reply.X25519Pub[:], serverXPriv.PublicKey().Bytes())
var mlkemShared []byte
if hybrid {
if len(client.MLKEMPub) != MLKEMPubSize {
return nil, nil, fmt.Errorf("%w: ML-KEM pubkey size %d", ErrInvalidPubKey, len(client.MLKEMPub))
}
var pk mlkem.PublicKey
if err := pk.Unpack(client.MLKEMPub); err != nil {
return nil, nil, fmt.Errorf("%w: ML-KEM unpack: %v", ErrInvalidPubKey, err)
}
ct := make([]byte, MLKEMCtSize)
ss := make([]byte, MLKEMSharedSize)
seed := make([]byte, mlkem.EncapsulationSeedSize)
if _, err := io.ReadFull(rand.Reader, seed); err != nil {
return nil, nil, fmt.Errorf("zap/handshake: rand seed: %w", err)
}
pk.EncapsulateTo(ct, ss, seed)
reply.MLKEMCt = ct
mlkemShared = ss
}
replyWire, err = MarshalServerHello(reply)
if err != nil {
return nil, nil, err
}
sessionKey, bind := combineAndDerive(xShared, mlkemShared, clientHelloWire, replyWire)
return replyWire, &HandshakeResult{
SessionKey: sessionKey,
Bind: bind,
Hybrid: hybrid,
PeerCaps: client.Caps,
}, nil
}
// ClientFinish consumes a ServerHello and finalizes the session key.
func (c *ClientState) ClientFinish(serverHelloWire []byte) (*HandshakeResult, error) {
server, err := UnmarshalServerHello(serverHelloWire)
if err != nil {
return nil, err
}
hybrid := server.Caps&CapMLKEM768 != 0 && c.caps&CapMLKEM768 != 0
if (c.caps&CapMLKEM768 != 0) && !hybrid {
// Peer cleared the bit; we requested it. This is the documented
// fallback path — caller logs the warning, we just continue.
}
serverX, err := ecdh.X25519().NewPublicKey(server.X25519Pub[:])
if err != nil {
return nil, fmt.Errorf("%w: X25519: %v", ErrInvalidPubKey, err)
}
xShared, err := c.x25519Priv.ECDH(serverX)
if err != nil {
return nil, fmt.Errorf("zap/handshake: X25519 ECDH: %w", err)
}
var mlkemShared []byte
if hybrid {
if c.mlkemPriv == nil {
return nil, errors.New("zap/handshake: ML-KEM priv missing on client")
}
if len(server.MLKEMCt) != MLKEMCtSize {
return nil, fmt.Errorf("%w: ML-KEM ct size %d", ErrInvalidPubKey, len(server.MLKEMCt))
}
ss := make([]byte, MLKEMSharedSize)
c.mlkemPriv.DecapsulateTo(ss, server.MLKEMCt)
mlkemShared = ss
}
sessionKey, bind := combineAndDerive(xShared, mlkemShared, c.helloBytes, serverHelloWire)
return &HandshakeResult{
SessionKey: sessionKey,
Bind: bind,
Hybrid: hybrid,
PeerCaps: server.Caps,
}, nil
}
// combineAndDerive runs the NIST hybrid construction:
//
// IKM = X25519_shared || ML_KEM_shared
// salt = SHA-256(client_hello || server_hello)
// session = HKDF-SHA256(IKM, salt, "kms/zap/v1/session", 32)
// bind = HKDF-SHA256(IKM, salt, "kms/zap/v1/bind", 32)
//
// When hybrid is false (mlkemShared == nil), IKM = X25519_shared alone.
//
// The salt covers both hello frames, so the pair is unique to this
// exchange. Anyone who substitutes a key of their own to read the
// traffic changes the secret, and lands on a different binding.
func combineAndDerive(xShared, mlkemShared, clientHello, serverHello []byte) (session, bind []byte) {
ikm := make([]byte, 0, len(xShared)+len(mlkemShared))
ikm = append(ikm, xShared...)
if mlkemShared != nil {
ikm = append(ikm, mlkemShared...)
}
var salt [sha256.Size]byte
h := sha256.New()
h.Write(clientHello)
h.Write(serverHello)
h.Sum(salt[:0])
return expand(ikm, salt[:], infoSession, SessionKeySize),
expand(ikm, salt[:], infoBind, BindSize)
}
// expand reads one labelled HKDF-SHA256 output.
func expand(ikm, salt []byte, info string, size int) []byte {
r := hkdf.New(newSHA256, ikm, salt, []byte(info))
out := make([]byte, size)
if _, err := io.ReadFull(r, out); err != nil {
// HKDF over a fixed-size SHA-256 with 32-byte output is
// infallible. Treat as an unrecoverable invariant violation.
panic(fmt.Sprintf("zap/handshake: hkdf: %v", err))
}
return out
}
func newSHA256() hash.Hash { return sha256.New() }
// SessionAEAD wraps a 32-byte session key in an AES-256-GCM AEAD ready
// for sealing or opening frame payloads. Nonce management is the
// caller's responsibility — typical usage is a per-frame counter
// concatenated with a per-direction salt.
func SessionAEAD(sessionKey []byte) (cipher.AEAD, error) {
if len(sessionKey) != SessionKeySize {
return nil, fmt.Errorf("zap/handshake: session key must be %d bytes", SessionKeySize)
}
block, err := aes.NewCipher(sessionKey)
if err != nil {
return nil, fmt.Errorf("zap/handshake: aes.NewCipher: %w", err)
}
return cipher.NewGCM(block)
}
// ---- wire (de)serialization ----
// MarshalClientHello serializes a ClientHelloFrame to its on-the-wire
// byte representation. See the ClientHello layout in the package doc.
func MarshalClientHello(f *ClientHelloFrame) ([]byte, error) {
size := 2 + X25519PubSize
if f.Caps&CapMLKEM768 != 0 {
if len(f.MLKEMPub) != MLKEMPubSize {
return nil, fmt.Errorf("zap/handshake: ML-KEM pub must be %d bytes, got %d", MLKEMPubSize, len(f.MLKEMPub))
}
size += MLKEMPubSize
}
out := make([]byte, size)
binary.LittleEndian.PutUint16(out[0:2], f.Caps)
copy(out[2:2+X25519PubSize], f.X25519Pub[:])
if f.Caps&CapMLKEM768 != 0 {
copy(out[2+X25519PubSize:], f.MLKEMPub)
}
return out, nil
}
// UnmarshalClientHello parses a ClientHello wire frame.
func UnmarshalClientHello(b []byte) (*ClientHelloFrame, error) {
if len(b) < 2+X25519PubSize {
return nil, fmt.Errorf("%w: client hello %d bytes", ErrTruncated, len(b))
}
f := &ClientHelloFrame{
Caps: binary.LittleEndian.Uint16(b[0:2]),
}
copy(f.X25519Pub[:], b[2:2+X25519PubSize])
if f.Caps&CapMLKEM768 != 0 {
off := 2 + X25519PubSize
if len(b) < off+MLKEMPubSize {
return nil, fmt.Errorf("%w: ML-KEM pub", ErrTruncated)
}
f.MLKEMPub = make([]byte, MLKEMPubSize)
copy(f.MLKEMPub, b[off:off+MLKEMPubSize])
}
return f, nil
}
// MarshalServerHello serializes a ServerHelloFrame.
func MarshalServerHello(f *ServerHelloFrame) ([]byte, error) {
size := 2 + X25519PubSize
if f.Caps&CapMLKEM768 != 0 {
if len(f.MLKEMCt) != MLKEMCtSize {
return nil, fmt.Errorf("zap/handshake: ML-KEM ct must be %d bytes, got %d", MLKEMCtSize, len(f.MLKEMCt))
}
size += MLKEMCtSize
}
out := make([]byte, size)
binary.LittleEndian.PutUint16(out[0:2], f.Caps)
copy(out[2:2+X25519PubSize], f.X25519Pub[:])
if f.Caps&CapMLKEM768 != 0 {
copy(out[2+X25519PubSize:], f.MLKEMCt)
}
return out, nil
}
// UnmarshalServerHello parses a ServerHello wire frame.
func UnmarshalServerHello(b []byte) (*ServerHelloFrame, error) {
if len(b) < 2+X25519PubSize {
return nil, fmt.Errorf("%w: server hello %d bytes", ErrTruncated, len(b))
}
f := &ServerHelloFrame{
Caps: binary.LittleEndian.Uint16(b[0:2]),
}
copy(f.X25519Pub[:], b[2:2+X25519PubSize])
if f.Caps&CapMLKEM768 != 0 {
off := 2 + X25519PubSize
if len(b) < off+MLKEMCtSize {
return nil, fmt.Errorf("%w: ML-KEM ct", ErrTruncated)
}
f.MLKEMCt = make([]byte, MLKEMCtSize)
copy(f.MLKEMCt, b[off:off+MLKEMCtSize])
}
return f, nil
}