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]>
177 lines
5.2 KiB
Go
177 lines
5.2 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package envelope_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/luxfi/keys"
|
|
"github.com/luxfi/kms/pkg/envelope"
|
|
)
|
|
|
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
|
"abandon abandon abandon abandon abandon about"
|
|
|
|
// testBind stands in for the binding a real transport derives with its
|
|
// peer. Any 32 bytes will do here — what the tests care about is that
|
|
// the same value is on both sides.
|
|
var testBind = bytes.Repeat([]byte{0x5A}, envelope.BindSize)
|
|
|
|
func mustIdent(t *testing.T, path string) *keys.ServiceIdentity {
|
|
t.Helper()
|
|
id, err := keys.NewServiceIdentity(testMnemonic, path)
|
|
if err != nil {
|
|
t.Fatalf("NewServiceIdentity: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func mustHeader(ident *keys.ServiceIdentity) envelope.IdentityHeader {
|
|
return envelope.IdentityHeader{
|
|
NodeID: ident.NodeID,
|
|
FullDigest: ident.FullDigest,
|
|
ServicePath: ident.ServicePath,
|
|
PublicKey: ident.PublicKey,
|
|
}
|
|
}
|
|
|
|
// TestEnvelope_BuildVerify_RoundTrip — a freshly built envelope
|
|
// verifies under the canonical keys.VerifyServiceEnvelope hook.
|
|
func TestEnvelope_BuildVerify_RoundTrip(t *testing.T) {
|
|
ident := mustIdent(t, "hanzo/kms-operator")
|
|
defer ident.Wipe()
|
|
req := json.RawMessage(`{"path":"hanzo/commerce","name":"api-key","env":"prod"}`)
|
|
now := time.Unix(1_717_200_000, 0)
|
|
|
|
env, err := envelope.Build(mustHeader(ident), ident, 0x0040, req, "nonce-1", testBind, now)
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
raw, err := json.Marshal(env)
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
parsed, err := envelope.Parse(raw)
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
verified, err := envelope.Verify(parsed, now, keys.VerifyServiceEnvelope, testBind)
|
|
if err != nil {
|
|
t.Fatalf("Verify: %v", err)
|
|
}
|
|
if verified.NodeID != ident.NodeID {
|
|
t.Errorf("NodeID mismatch")
|
|
}
|
|
if verified.ServicePath != ident.ServicePath {
|
|
t.Errorf("path mismatch: got %q want %q", verified.ServicePath, ident.ServicePath)
|
|
}
|
|
}
|
|
|
|
// TestEnvelope_Stale_Rejected — outside the freshness window fails.
|
|
func TestEnvelope_Stale_Rejected(t *testing.T) {
|
|
ident := mustIdent(t, "hanzo/auto")
|
|
defer ident.Wipe()
|
|
old := time.Unix(1_717_200_000, 0)
|
|
env, err := envelope.Build(mustHeader(ident), ident, 0x0040, json.RawMessage(`{}`), "n", testBind, old)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tooLate := old.Add(envelope.MaxClockSkew + time.Second)
|
|
if _, err := envelope.Verify(env, tooLate, keys.VerifyServiceEnvelope, testBind); err == nil {
|
|
t.Errorf("stale envelope should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestEnvelope_Tamper_Sig_Rejected — flipping a sig byte fails.
|
|
func TestEnvelope_Tamper_Sig_Rejected(t *testing.T) {
|
|
ident := mustIdent(t, "hanzo/auto")
|
|
defer ident.Wipe()
|
|
now := time.Unix(1_717_200_000, 0)
|
|
env, err := envelope.Build(mustHeader(ident), ident, 0x0040, json.RawMessage(`{}`), "n", testBind, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env.Sig[0] ^= 0xFF
|
|
if _, err := envelope.Verify(env, now, keys.VerifyServiceEnvelope, testBind); err == nil {
|
|
t.Errorf("tampered sig should be rejected")
|
|
}
|
|
}
|
|
|
|
// TestEnvelope_BadShape_Rejected — version, scheme, missing fields.
|
|
func TestEnvelope_BadShape_Rejected(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
}{
|
|
{"empty", ``},
|
|
{"not json", `not json`},
|
|
{"wrong version", `{"v":99,"id":{"scheme":66,"node":"NodeID-",
|
|
"digest":"","path":"","pubkey":""},"ts":0,"nonce":"x","op":0,"req":{},"sig":"x"}`},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if _, err := envelope.Parse([]byte(c.raw)); err == nil {
|
|
t.Errorf("expected reject for %q", c.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An envelope that names no channel does not parse. Refusing it at the
|
|
// shape check means no later code has to remember to look.
|
|
func TestAnEnvelopeThatNamesNoChannelDoesNotParse(t *testing.T) {
|
|
ident := mustIdent(t, "hanzo/auto")
|
|
defer ident.Wipe()
|
|
now := time.Unix(1_717_200_000, 0)
|
|
env, err := envelope.Build(mustHeader(ident), ident, 0x0040, json.RawMessage(`{}`), "n", testBind, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range []struct {
|
|
name string
|
|
bind []byte
|
|
}{
|
|
{"absent", nil},
|
|
{"empty", []byte{}},
|
|
{"short", testBind[:envelope.BindSize-1]},
|
|
{"long", append(append([]byte{}, testBind...), 0x00)},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
env.Bind = c.bind
|
|
raw, err := json.Marshal(env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := envelope.Parse(raw); err == nil {
|
|
t.Error("parsed an envelope with no usable channel name")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Building one is refused for the same reason: there is no way to sign a
|
|
// request that is not addressed to anybody.
|
|
func TestAnEnvelopeCannotBeBuiltWithoutAChannel(t *testing.T) {
|
|
ident := mustIdent(t, "hanzo/auto")
|
|
defer ident.Wipe()
|
|
now := time.Unix(1_717_200_000, 0)
|
|
for _, c := range []struct {
|
|
name string
|
|
bind []byte
|
|
}{
|
|
{"absent", nil},
|
|
{"empty", []byte{}},
|
|
{"short", testBind[:envelope.BindSize-1]},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if _, err := envelope.Build(mustHeader(ident), ident, 0x0040, json.RawMessage(`{}`), "n", c.bind, now); err == nil {
|
|
t.Error("built a signed envelope addressed to nobody")
|
|
}
|
|
})
|
|
}
|
|
}
|