Files
kms/pkg/zapserver/handshake_e2e_test.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

280 lines
8.9 KiB
Go

// End-to-end tests for the LP-022 hybrid PQ handshake wired through
// pkg/zapserver and pkg/zapclient. We boot a real zap.Node listening on
// a loopback port, register the secret server (which advertises
// CapMLKEM768), then point a zapclient at it and assert:
//
// 1. After Dial, the client has an active hybrid session.
// 2. A Get round-trips through sealed payloads — the server
// authenticates, opens the request body, fetches the secret, and
// returns a sealed reply that the client opens to recover the
// plaintext value.
// 3. With LocalCaps=0 the client forces classical-only: the server's
// handshake handler emits a WARN and the client reports
// hybrid=false. The session still works (32-byte X25519 key).
package zapserver
import (
"context"
"crypto/rand"
"net"
"strconv"
"testing"
"time"
"github.com/luxfi/keys"
"github.com/luxfi/kms/pkg/envelope"
"github.com/luxfi/kms/pkg/store"
kmszap "github.com/luxfi/kms/pkg/zap"
"github.com/luxfi/kms/pkg/zapclient"
"github.com/luxfi/log"
"github.com/luxfi/zap"
badger "github.com/luxfi/zapdb"
)
// allowAuthorizer is the test-only ConsensusAuthorizer that lets every
// signed envelope through. The handshake e2e suite is about the PQ
// transport, not the authority predicate.
type allowAuthorizer struct{}
// Authorize always allows. Returns a single-reason allow so audit logs
// can still attribute the decision.
func (allowAuthorizer) Authorize(_ context.Context, _ Identity, _ string, _ Op) (Decision, error) {
return Allow("test-allow-all"), nil
}
// newE2EIdentity builds a *keys.ServiceIdentity + envelope.IdentityHeader
// pair for the handshake tests. Uses the canonical abandon... about
// test mnemonic so derivation is deterministic.
func newE2EIdentity(t *testing.T, servicePath string) (*keys.ServiceIdentity, envelope.IdentityHeader) {
t.Helper()
ident, err := keys.NewServiceIdentity(testMnemonic, servicePath)
if err != nil {
t.Fatalf("NewServiceIdentity(%q): %v", servicePath, err)
}
hdr := envelope.IdentityHeader{
NodeID: ident.NodeID,
FullDigest: ident.FullDigest,
ServicePath: ident.ServicePath,
PublicKey: ident.PublicKey,
}
return ident, hdr
}
// freePort grabs an OS-assigned loopback port and returns it. We need a
// known port in the client config, and zap.Node always advertises the
// configured port on its listener (it does not expose ln.Addr() back).
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := l.Addr().(*net.TCPAddr)
l.Close()
return addr.Port
}
// bootServer brings up a real zap.Node + Server with a seeded SecretStore
// and an open ACL (so ACL is not the variable under test). Returns the
// listen addr and a cleanup. Caller MUST defer cleanup() and t.Cleanup.
func bootServer(t *testing.T) (addr string, peerNodeID string) {
t.Helper()
port := freePort(t)
nodeID := "kms-test-" + strconv.Itoa(port)
n := zap.NewNode(zap.NodeConfig{
NodeID: nodeID,
ServiceType: "_kms._tcp",
Port: port,
NoDiscovery: true, // disable mDNS for hermetic tests
})
if err := n.Start(); err != nil {
t.Fatalf("zap.Node start: %v", err)
}
t.Cleanup(func() { n.Stop() })
opts := badger.DefaultOptions("").WithInMemory(true)
db, err := badger.Open(opts)
if err != nil {
t.Fatalf("zapdb open: %v", err)
}
t.Cleanup(func() { db.Close() })
mk := make([]byte, 32)
if _, err := rand.Read(mk); err != nil {
t.Fatalf("rand: %v", err)
}
secStore := store.NewSecretStore(db)
// Seed a value so Get returns OK rather than NotFound.
sec, err := store.Seal(mk, "ats", "settlement-key", "dev", []byte("hunter2"))
if err != nil {
t.Fatalf("store.Seal: %v", err)
}
if err := secStore.Put(sec); err != nil {
t.Fatalf("store.Put: %v", err)
}
// Consensus-authorizer that allows any envelope through; the
// handshake tests are about the PQ session, not the authority
// predicate. allowAuthorizer is implemented at the bottom of this
// file.
srv := New(Config{
Store: secStore,
MasterKey: mk,
Authorizer: allowAuthorizer{},
Logger: log.NewNoOpLogger(),
})
srv.Register(n)
// luxfi/zap.Node listens on 0.0.0.0:port.
return "127.0.0.1:" + strconv.Itoa(port), nodeID
}
// TestHandshakeE2E_HybridSecretRoundTrip is the full integration: client
// hello with CapMLKEM768, server replies with same, both derive a
// session, then the client runs a real Get that arrives sealed and the
// server returns a sealed reply.
func TestHandshakeE2E_HybridSecretRoundTrip(t *testing.T) {
addr, _ := bootServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ident, hdr := newE2EIdentity(t, "ats/test-service")
defer ident.Wipe()
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{
NodeID: "test-client",
PeerAddr: addr,
DefaultPath: "ats",
IdentityHeader: hdr,
Signer: ident,
})
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer c.Close()
// Drive a real Get. Under the hood the client seals
// {"path":"ats","name":"settlement-key","env":"dev"} and the server
// opens it before the ACL/store path. The server then seals
// {"value":"<base64>"} and the client opens it.
value, err := c.Get(ctx, "settlement-key", "dev")
if err != nil {
t.Fatalf("Get: %v", err)
}
if value != "hunter2" {
t.Fatalf("Get value mismatch: got %q want %q", value, "hunter2")
}
}
// TestHandshakeE2E_FallbackClassicalOnly drives the client with caps=0
// (LocalCaps explicitly set) so the negotiated session is classical only.
// Get still works because the X25519-only key is still 32 bytes and
// drives the same AEAD layer.
func TestHandshakeE2E_FallbackClassicalOnly(t *testing.T) {
addr, _ := bootServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ident, hdr := newE2EIdentity(t, "ats/test-service-classical")
defer ident.Wipe()
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{
NodeID: "test-client-classical",
PeerAddr: addr,
DefaultPath: "ats",
LocalCaps: 0,
CapsExplicit: true,
IdentityHeader: hdr,
Signer: ident,
})
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer c.Close()
value, err := c.Get(ctx, "settlement-key", "dev")
if err != nil {
t.Fatalf("Get (classical): %v", err)
}
if value != "hunter2" {
t.Fatalf("Get value mismatch: got %q want %q", value, "hunter2")
}
}
// A peer that will not agree keys cannot be talked to at all. There is
// no plaintext path to fall back to, so the dial fails and no client is
// returned — nothing exists that could have carried a secret in the
// clear. Here the peer is a bare ZAP node with no KMS server on it, so
// nothing answers OpClientHello.
func TestHandshakeE2E_PeerThatAgreesNoKeysIsRefused(t *testing.T) {
port := freePort(t)
bare := zap.NewNode(zap.NodeConfig{
NodeID: "bare-" + strconv.Itoa(port),
ServiceType: "_kms._tcp",
Port: port,
NoDiscovery: true,
})
if err := bare.Start(); err != nil {
t.Fatalf("zap.Node start: %v", err)
}
t.Cleanup(func() { bare.Stop() })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ident, hdr := newE2EIdentity(t, "ats/test-service-bare")
defer ident.Wipe()
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{
NodeID: "test-client-bare",
PeerAddr: "127.0.0.1:" + strconv.Itoa(port),
DefaultPath: "ats",
IdentityHeader: hdr,
Signer: ident,
})
if err == nil {
c.Close()
t.Fatal("dialled a peer that agreed no keys")
}
}
// TestHandshakeE2E_SessionInstalled — once the handshake completes, the
// server has a Session entry under the client's NodeID. We probe via the
// exported helper API: send a fresh ClientHello to a new client and
// verify the server reuses the same NodeID slot (last-write-wins).
func TestHandshakeE2E_SessionInstalled(t *testing.T) {
addr, _ := bootServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ident, hdr := newE2EIdentity(t, "ats/test-service-session")
defer ident.Wipe()
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{
NodeID: "test-client-session",
PeerAddr: addr,
DefaultPath: "ats",
IdentityHeader: hdr,
Signer: ident,
})
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer c.Close()
// One Get is enough to demonstrate the session is hot — the call
// would fail with "session decrypt failed" if the server did not
// hold the matching key.
if _, err := c.Get(ctx, "settlement-key", "dev"); err != nil {
t.Fatalf("Get: %v", err)
}
// Belt-and-braces: confirm the session opcode constants the client
// uses match what the server module exports — drift here would
// silently break the handshake on prod.
if kmszap.OpClientHello != 0x00F0 || kmszap.OpServerHello != 0x00F1 {
t.Fatalf("opcode drift: client=0x%04x/0x%04x", kmszap.OpClientHello, kmszap.OpServerHello)
}
}