Files
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

129 lines
4.7 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, plus the binding that names
// the channel. Hybrid records whether ML-KEM-768 ran, for
// operator-visible logging.
type Session struct {
aead cipher.AEAD
bind []byte
hybrid bool
sendCtr atomic.Uint64
recvCtr atomic.Uint64
}
// NewSession turns a completed handshake into a Session ready to seal
// and open frames.
//
// It takes the whole result rather than the key alone so that a session
// always carries the binding derived alongside its key. There is no
// other constructor, and therefore no session whose binding belongs to
// some other exchange.
func NewSession(r *HandshakeResult) (*Session, error) {
if r == nil {
return nil, errors.New("zap/session: handshake result is nil")
}
if len(r.Bind) != BindSize {
return nil, fmt.Errorf("zap/session: binding must be %d bytes, got %d", BindSize, len(r.Bind))
}
a, err := SessionAEAD(r.SessionKey)
if err != nil {
return nil, err
}
return &Session{aead: a, bind: r.Bind, hybrid: r.Hybrid}, nil
}
// Bind returns the binding a request must carry to be honoured on this
// session. Both endpoints compute the same 32 bytes; nobody else can.
func (s *Session) Bind() []byte { return s.bind }
// 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
}