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]>
394 lines
12 KiB
Go
394 lines
12 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// relay_test.go — an attacker in the middle of the credential leg.
|
|
//
|
|
// Key agreement alone says a channel is private, not who is on the other
|
|
// end of it. So put someone in the middle who agrees keys with both
|
|
// sides: it answers the caller's ClientHello with keys of its own, and
|
|
// separately agrees keys with the real KMS. It then holds both session
|
|
// keys, reads everything the caller sends, and can forward a request
|
|
// verbatim — signature and all — to the KMS. The caller's ML-DSA
|
|
// signature does not help by itself: the relay never has to forge it,
|
|
// only to carry it.
|
|
//
|
|
// What stops it is that the request names the channel it was written
|
|
// for. The caller's envelope names the channel it agreed with the relay;
|
|
// the KMS checks it against the channel it agreed with the relay; the
|
|
// two are different, and the KMS declines. The relay cannot re-sign the
|
|
// envelope for its own channel, and it cannot make its channel produce
|
|
// the caller's binding without the caller's keys.
|
|
//
|
|
// The caller here is the shipping zapclient and the KMS is the shipping
|
|
// zapserver — the only test code is the attacker.
|
|
|
|
package zapserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
kmszap "github.com/luxfi/kms/pkg/zap"
|
|
"github.com/luxfi/kms/pkg/zapclient"
|
|
"github.com/luxfi/zap"
|
|
)
|
|
|
|
// relay sits between a caller and the real KMS holding both session
|
|
// keys. toCaller is the session the caller believes is the KMS;
|
|
// toKMS is the session the KMS believes is the caller.
|
|
type relay struct {
|
|
node *zap.Node
|
|
kmsPeer string
|
|
toKMS *kmszap.Session
|
|
|
|
// The rest is touched by both the test and the handler goroutines.
|
|
mu sync.Mutex
|
|
toCaller *kmszap.Session
|
|
// read records the plaintext of every request the caller sent, to
|
|
// show the relay really is reading the traffic and not merely
|
|
// shuttling bytes it cannot see.
|
|
read [][]byte
|
|
// rewrite makes the relay put its OWN channel's name into the
|
|
// caller's envelope before forwarding, rather than carrying it
|
|
// unchanged.
|
|
rewrite bool
|
|
}
|
|
|
|
func (r *relay) setCaller(s *kmszap.Session) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.toCaller = s
|
|
}
|
|
|
|
func (r *relay) caller() *kmszap.Session {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.toCaller
|
|
}
|
|
|
|
func (r *relay) setRewrite() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.rewrite = true
|
|
}
|
|
|
|
func (r *relay) rewriting() bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.rewrite
|
|
}
|
|
|
|
func (r *relay) record(b []byte) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.read = append(r.read, append([]byte(nil), b...))
|
|
}
|
|
|
|
func (r *relay) seen() [][]byte {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([][]byte(nil), r.read...)
|
|
}
|
|
|
|
// newRelay stands up an attacker listening on its own port and agrees
|
|
// keys with the real KMS at kmsAddr. The caller is pointed at addr.
|
|
func newRelay(t *testing.T, kmsAddr string) (r *relay, addr string) {
|
|
t.Helper()
|
|
port := freePort(t)
|
|
n := zap.NewNode(zap.NodeConfig{
|
|
NodeID: "relay-" + strconv.Itoa(port),
|
|
ServiceType: "_kms._tcp",
|
|
Port: port,
|
|
NoDiscovery: true,
|
|
})
|
|
if err := n.Start(); err != nil {
|
|
t.Fatalf("relay node start: %v", err)
|
|
}
|
|
t.Cleanup(func() { n.Stop() })
|
|
r = &relay{node: n}
|
|
|
|
// The attacker's own key agreement with the real KMS, using the
|
|
// same handshake any caller would run.
|
|
if err := n.ConnectDirect(kmsAddr); err != nil {
|
|
t.Fatalf("relay connect KMS: %v", err)
|
|
}
|
|
for _, pid := range n.Peers() {
|
|
r.kmsPeer = pid
|
|
break
|
|
}
|
|
if r.kmsPeer == "" {
|
|
t.Fatal("relay resolved no KMS peer")
|
|
}
|
|
state, hello, err := kmszap.NewClient(kmszap.CapMLKEM768)
|
|
if err != nil {
|
|
t.Fatalf("relay ClientHello: %v", err)
|
|
}
|
|
reply := r.callKMS(t, kmszap.OpClientHello, hello)
|
|
result, err := state.ClientFinish(reply)
|
|
if err != nil {
|
|
t.Fatalf("relay ClientFinish: %v", err)
|
|
}
|
|
if r.toKMS, err = kmszap.NewSession(result); err != nil {
|
|
t.Fatalf("relay session to KMS: %v", err)
|
|
}
|
|
|
|
// Answer the caller's key agreement as if we were the KMS.
|
|
n.Handle(kmszap.OpClientHello, func(_ context.Context, _ string, msg *zap.Message) (*zap.Message, error) {
|
|
hello := payloadOf(msg)[2:]
|
|
replyWire, res, err := kmszap.ServerRespond(kmszap.CapMLKEM768, hello)
|
|
if err != nil {
|
|
return respond(statusError, errJSON("relay hello")), nil
|
|
}
|
|
sess, err := kmszap.NewSession(res)
|
|
if err != nil {
|
|
return respond(statusError, errJSON("relay session")), nil
|
|
}
|
|
r.setCaller(sess)
|
|
return respond(statusOK, replyWire), nil
|
|
})
|
|
|
|
// Carry a secret request to the KMS unchanged, re-sealed for the
|
|
// channel the attacker agreed with it.
|
|
n.Handle(OpSecretGet, func(_ context.Context, _ string, msg *zap.Message) (*zap.Message, error) {
|
|
sealed := payloadOf(msg)[2:]
|
|
plain, err := r.caller().Open(kmszap.DirClientToServer, sealed)
|
|
if err != nil {
|
|
return respond(statusError, errJSON("relay open")), nil
|
|
}
|
|
r.record(plain)
|
|
if r.rewriting() {
|
|
var env Envelope
|
|
if err := json.Unmarshal(plain, &env); err != nil {
|
|
return respond(statusError, errJSON("relay parse")), nil
|
|
}
|
|
env.Bind = r.toKMS.Bind()
|
|
if plain, err = json.Marshal(&env); err != nil {
|
|
return respond(statusError, errJSON("relay remarshal")), nil
|
|
}
|
|
}
|
|
onward, err := r.toKMS.Seal(kmszap.DirClientToServer, plain)
|
|
if err != nil {
|
|
return respond(statusError, errJSON("relay seal")), nil
|
|
}
|
|
raw := r.callKMSRaw(t, OpSecretGet, onward)
|
|
status, body := raw[0], raw[1:]
|
|
body, err = r.toKMS.Open(kmszap.DirServerToClient, body)
|
|
if err != nil {
|
|
// The KMS refused before a session reply was framed.
|
|
return respond(status, body), nil
|
|
}
|
|
back, err := r.caller().Seal(kmszap.DirServerToClient, body)
|
|
if err != nil {
|
|
return respond(statusError, errJSON("relay reseal")), nil
|
|
}
|
|
return respond(status, back), nil
|
|
})
|
|
|
|
return r, "127.0.0.1:" + strconv.Itoa(port)
|
|
}
|
|
|
|
// callKMS sends one op to the real KMS and returns the body after the
|
|
// status byte, failing the test on a non-OK status.
|
|
func (r *relay) callKMS(t *testing.T, op uint16, body []byte) []byte {
|
|
t.Helper()
|
|
raw := r.callKMSRaw(t, op, body)
|
|
if raw[0] != statusOK {
|
|
t.Fatalf("relay: KMS status=0x%02X body=%s", raw[0], string(raw[1:]))
|
|
}
|
|
return raw[1:]
|
|
}
|
|
|
|
// callKMSRaw sends one op and returns status||body verbatim.
|
|
func (r *relay) callKMSRaw(t *testing.T, op uint16, body []byte) []byte {
|
|
t.Helper()
|
|
payload := make([]byte, 2+len(body))
|
|
binary.LittleEndian.PutUint16(payload[:2], op)
|
|
copy(payload[2:], body)
|
|
b := zap.NewBuilder(len(payload) + 32)
|
|
b.WriteBytes(payload)
|
|
msg, err := zap.Parse(b.FinishWithFlags(op << 8))
|
|
if err != nil {
|
|
t.Fatalf("relay build message: %v", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
resp, err := r.node.Call(ctx, r.kmsPeer, msg)
|
|
if err != nil {
|
|
t.Fatalf("relay call KMS: %v", err)
|
|
}
|
|
raw := payloadOf(resp)
|
|
if len(raw) < 1 {
|
|
t.Fatal("relay: empty reply from KMS")
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// payloadOf pulls the body out of a ZAP message the same way the server
|
|
// and client do.
|
|
func payloadOf(msg *zap.Message) []byte {
|
|
if raw := msg.Root().Bytes(0); len(raw) > 0 {
|
|
return raw
|
|
}
|
|
b := msg.Bytes()
|
|
if len(b) > zap.HeaderSize {
|
|
return b[zap.HeaderSize:]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A caller that reaches the real KMS gets its secret. This is the
|
|
// control: it fixes that the harness, the identity, the authorizer and
|
|
// the store all work, so the refusal below can only be the relay.
|
|
func TestCallerReachingTheKMSGetsTheSecret(t *testing.T) {
|
|
addr, _ := bootServer(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
ident, hdr := newE2EIdentity(t, "ats/direct")
|
|
defer ident.Wipe()
|
|
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{
|
|
NodeID: "caller-direct",
|
|
PeerAddr: addr,
|
|
DefaultPath: "ats",
|
|
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: %v", err)
|
|
}
|
|
if value != "hunter2" {
|
|
t.Fatalf("got %q want %q", value, "hunter2")
|
|
}
|
|
}
|
|
|
|
// The same caller, same identity, same KMS — with someone in the middle
|
|
// who agreed keys with both. The KMS declines, and the secret stays
|
|
// where it is.
|
|
func TestRelayedRequestIsRefused(t *testing.T) {
|
|
kmsAddr, _ := bootServer(t)
|
|
r, relayAddr := newRelay(t, kmsAddr)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
ident, hdr := newE2EIdentity(t, "ats/relayed")
|
|
defer ident.Wipe()
|
|
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{
|
|
NodeID: "caller-relayed",
|
|
PeerAddr: relayAddr,
|
|
DefaultPath: "ats",
|
|
IdentityHeader: hdr,
|
|
Signer: ident,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Dial through relay: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
value, err := c.Get(ctx, "settlement-key", "dev")
|
|
if err == nil {
|
|
t.Fatalf("the KMS answered a relayed request with %q", value)
|
|
}
|
|
if value != "" {
|
|
t.Fatalf("a value came back through the relay: %q", value)
|
|
}
|
|
|
|
// The attacker really was in the middle: it read the caller's
|
|
// request in the clear. What it could not do is present it as its
|
|
// own.
|
|
if len(r.seen()) == 0 {
|
|
t.Fatal("the relay never saw the request — it was not in the middle, so this proves nothing")
|
|
}
|
|
if !strings.Contains(string(r.seen()[0]), "settlement-key") {
|
|
t.Fatalf("the relay could not read the request it forwarded: %q", string(r.seen()[0]))
|
|
}
|
|
|
|
// And the relay's own channel to the KMS is sound — the refusal is
|
|
// about who the request was addressed to, not a broken connection.
|
|
if r.toKMS == nil || r.caller() == nil {
|
|
t.Fatal("the relay did not agree keys with both sides")
|
|
}
|
|
}
|
|
|
|
// A cleverer relay edits the caller's envelope to name its own channel
|
|
// instead of carrying the caller's. That is the obvious move, and it
|
|
// fails for the obvious reason: the name is inside what the caller
|
|
// signed, so changing it breaks the signature, and the relay has no key
|
|
// to make a new one.
|
|
func TestRelayRewritingTheChannelIsRefused(t *testing.T) {
|
|
kmsAddr, _ := bootServer(t)
|
|
r, relayAddr := newRelay(t, kmsAddr)
|
|
r.setRewrite()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
ident, hdr := newE2EIdentity(t, "ats/relay-rewrite")
|
|
defer ident.Wipe()
|
|
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{
|
|
NodeID: "caller-relay-rewrite",
|
|
PeerAddr: relayAddr,
|
|
DefaultPath: "ats",
|
|
IdentityHeader: hdr,
|
|
Signer: ident,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Dial through relay: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
value, err := c.Get(ctx, "settlement-key", "dev")
|
|
if err == nil {
|
|
t.Fatalf("the KMS answered a rewritten request with %q", value)
|
|
}
|
|
if value != "" {
|
|
t.Fatalf("a value came back through the relay: %q", value)
|
|
}
|
|
if len(r.seen()) == 0 {
|
|
t.Fatal("the relay never saw the request — it was not in the middle, so this proves nothing")
|
|
}
|
|
}
|
|
|
|
// The two channels really do carry different names. If they did not, the
|
|
// check above would pass for the wrong reason.
|
|
func TestTheTwoChannelsHaveDifferentBindings(t *testing.T) {
|
|
kmsAddr, _ := bootServer(t)
|
|
r, relayAddr := newRelay(t, kmsAddr)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
ident, hdr := newE2EIdentity(t, "ats/two-channels")
|
|
defer ident.Wipe()
|
|
c, err := zapclient.DialWithConfig(ctx, zapclient.Config{
|
|
NodeID: "caller-two-channels",
|
|
PeerAddr: relayAddr,
|
|
DefaultPath: "ats",
|
|
IdentityHeader: hdr,
|
|
Signer: ident,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Dial through relay: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
if r.caller() == nil || r.toKMS == nil {
|
|
t.Fatal("the relay did not agree keys with both sides")
|
|
}
|
|
if string(r.caller().Bind()) == string(r.toKMS.Bind()) {
|
|
t.Fatal("both channels produced the same binding — a relay would be invisible")
|
|
}
|
|
}
|