mirror of
https://github.com/luxfi/kms.git
synced 2026-10-30 16:23:36 +00:00
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]>
432 lines
14 KiB
Go
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
|
|
}
|