Files
zeekayandHanzo Dev e699578ba8
Docker Build & Push (Reusable) / create-manifest (push) Blocked by required conditions
Docker Build & Push (Reusable) / tag-single-arch (push) Blocked by required conditions
Docker Build & Push (Reusable) / deploy (push) Blocked by required conditions
Docker Build & Push (Reusable) / sbom (push) Blocked by required conditions
Docker Build & Push (Reusable) / build-amd64 (push) Waiting to run
Docker Build & Push (Reusable) / build-arm64 (push) Skipped
Build kms / build (push) Waiting to run
Release / Build binaries (linux/amd64) (push) Waiting to run
Release / GitHub Release (push) Blocked by required conditions
Release / Multi-arch Docker (ghcr.io/hanzoai/kms) (push) Blocked by required conditions
Release / Notify universe (auto-bump pin) (push) Blocked by required conditions
Release / Build binaries (linux/arm64) (push) Failing after 0s
Release / Build binaries (darwin/arm64) (push) Failing after 0s
Release / Build binaries (darwin/amd64) (push) Failing after 0s
feat(custody): hold node identity keys, sign on their behalf
A Hanzo node is named by a wallet: one secp256k1 keypair is its identity on
the settlement L1, the account it stakes from, the account its work rewards
pay to, and the row the fleet view lists it under. One key with four uses is
a key whose copy is a forged identity and a spendable stake, so the node
does not hold it. The KMS generates it, seals it, and returns it to nobody;
the node asks for signatures.

The measurement is not compared, it is required. A key is sealed with
store.Seal at path="node", name=address, env=measurement, so the image is
the envelope's associated data, and unseal overwrites all three coordinates
with what the CALLER presents. A wrong image is a GCM authentication
failure, not a policy check that a later edit could drop.

The signing surface is a closed set. Every digest is keccak-256 over a
fixed-length preimage led by a purpose tag and committing to the address,
the sealed measurement and a strictly increasing epoch, so no digest minted
here is an RLP transaction hash: the wallet cannot be spent through this
surface, and a captured signature cannot be replayed forward. Signatures are
canonical (low-s), so one statement has one encoding.

Rotation is a change of identity, because a wallet's key cannot change under
a fixed address: the outgoing key signs a succession naming its successor,
that statement is recorded where it stays readable, and the outgoing
material is destroyed in the same transaction. Revocation destroys material
and keeps the row, so a slashed node stays attributable.

custody is a library, not a surface. It holds keys and enforces what is a
property of a key; it derives no permission from a credential and never sees
one. That follows the division this repository already made for secrets: it
answers who signed a token, and the org-scoped plane with its authorization
lives in cloud (apps/kms). Paths therefore carry no tenant — the server
reads it from the credential, as the secret surface does. custody/mount_test.go
is the reference mount, written out in full and driven by the real client, so
the serving side has a worked example rather than a description.

The contract has one definition. sdk/go/node holds the statements, the wire
types and the verification routine, with no store, transport or server
dependency; the KMS, the node daemon and the chain indexer all import it,
and custody builds its digests by calling the same package a verifier does.
Callers reach the surface through sdk/go/kmsclient rather than hand-rolling
requests, and refusals arrive as *kmsclient.Failure carrying the status.
Verification takes the payload the caller already holds — a receipt carries
the measurement it attests but never the payload it was given.

On hardware without SEV-SNP or TDX the node declares its measurement, which
pins the key to one image and detects drift but does not prove the image.
A claim of a hardware TEE with no linked verifier is refused rather than
downgraded: recording "attested" on an unchecked claim would make the fleet
view a lie. docs/custody.tex states what is enforced today against what a
TEE would add, and what a caller is assumed to do.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-05 23:15:43 -07:00

286 lines
9.3 KiB
Go

package kmsclient
// Unit tests for the node custody calls. What the client and the server must
// agree on end to end is covered by the contract test in the server repo, which
// runs this client against the real surface; these cover the parts that test
// cannot reach — the transport refusal, the body cap, and the exact request each
// call puts on the wire.
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/kms/sdk/go/node"
)
const bearer = "node-bearer"
func measurement(seed byte) node.Measurement {
var m node.Measurement
for i := range m {
m[i] = seed
}
return m
}
func address(seed byte) node.Address {
var a node.Address
for i := range a {
a[i] = seed
}
return a
}
// recorder keeps the request and body the handler saw, and the answer it should
// give back.
type recorder struct {
req *http.Request
body []byte
status int
answer any
}
func serve(t *testing.T, rec *recorder) *Client {
t.Helper()
iam := mockIAM(t, bearer)
t.Cleanup(iam.Close)
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.body, _ = io.ReadAll(r.Body)
rec.req = r
w.Header().Set("Content-Type", "application/json")
if rec.status != 0 {
w.WriteHeader(rec.status)
}
if rec.answer != nil {
_ = json.NewEncoder(w).Encode(rec.answer)
}
}))
t.Cleanup(kms.Close)
c, err := New(Config{
Endpoint: kms.URL, IAMEndpoint: iam.URL,
ClientID: "node", ClientSecret: "secret", Org: "hanzo",
})
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { c.Close() })
return c
}
// Each call goes to exactly one documented path, with the bearer attached and
// the body the surface expects. A rename on either side shows up here.
func TestNodeCallsUseTheCanonicalPaths(t *testing.T) {
addr := address(0xab)
ev := node.Declare(measurement(0x11))
// No tenant in the path: the server takes the org from the credential, the
// way the secret surface does.
const base = "/v1/kms/nodes"
for name, tc := range map[string]struct {
call func(*Client) error
method string
path string
status int
answer any
body map[string]any
}{
"enroll": {
func(c *Client) error { _, _, err := c.Enroll(context.Background(), ev); return err },
http.MethodPost, base, http.StatusCreated, node.Record{Address: addr},
map[string]any{"evidence": map[string]any{"tee": "none", "measurement": ev.Measurement.String()}},
},
"fleet": {
func(c *Client) error { _, err := c.Fleet(context.Background()); return err },
http.MethodGet, base, http.StatusOK, node.Fleet{}, nil,
},
"read": {
func(c *Client) error { _, err := c.Node(context.Background(), addr); return err },
http.MethodGet, base + "/" + addr.String(), http.StatusOK, node.Record{Address: addr}, nil,
},
"sign": {
func(c *Client) error {
_, err := c.Sign(context.Background(), addr, ev, 7, []byte("report"))
return err
},
http.MethodPost, base + "/" + addr.String() + "/sign", http.StatusOK, node.Receipt{},
map[string]any{"epoch": float64(7), "payload": "cmVwb3J0"},
},
"rotate": {
func(c *Client) error { _, _, err := c.Rotate(context.Background(), addr, ev, 8); return err },
http.MethodPost, base + "/" + addr.String() + "/rotate", http.StatusCreated,
map[string]any{}, map[string]any{"epoch": float64(8)},
},
"rebind": {
func(c *Client) error {
_, err := c.Rebind(context.Background(), addr, ev, node.Declare(measurement(0x22)))
return err
},
http.MethodPost, base + "/" + addr.String() + "/rebind", http.StatusOK, node.Record{},
map[string]any{
"from": map[string]any{"tee": "none", "measurement": measurement(0x11).String()},
"to": map[string]any{"tee": "none", "measurement": measurement(0x22).String()},
},
},
"revoke": {
func(c *Client) error { _, err := c.Revoke(context.Background(), addr, "slashed"); return err },
http.MethodPost, base + "/" + addr.String() + "/revoke", http.StatusOK, node.Record{},
map[string]any{"reason": "slashed"},
},
} {
t.Run(name, func(t *testing.T) {
rec := &recorder{status: tc.status, answer: tc.answer}
c := serve(t, rec)
if err := tc.call(c); err != nil {
t.Fatalf("%s: %v", name, err)
}
if rec.req.Method != tc.method || rec.req.URL.Path != tc.path {
t.Fatalf("%s %s, want %s %s", rec.req.Method, rec.req.URL.Path, tc.method, tc.path)
}
if got := rec.req.Header.Get("Authorization"); got != "Bearer "+bearer {
t.Fatalf("authorization %q", got)
}
// No path segment is ever "/api/", and the address is spelled one way.
if strings.Contains(rec.req.URL.Path, "/api/") {
t.Fatalf("path carries an /api/ segment: %s", rec.req.URL.Path)
}
for k, want := range tc.body {
var body map[string]any
if err := json.Unmarshal(rec.body, &body); err != nil {
t.Fatalf("request body: %v (%s)", err, rec.body)
}
got, ok := body[k]
if !ok {
t.Fatalf("request body has no %q: %s", k, rec.body)
}
if wantMap, isMap := want.(map[string]any); isMap {
gotJSON, _ := json.Marshal(got)
wantJSON, _ := json.Marshal(wantMap)
if string(gotJSON) != string(wantJSON) {
t.Fatalf("%q is %s, want %s", k, gotJSON, wantJSON)
}
continue
}
if got != want {
t.Fatalf("%q is %v, want %v", k, got, want)
}
}
})
}
}
// Enrolment reports which of the two things happened, because a daemon has to
// tell a first boot from a restart.
func TestEnrollReportsWhetherItMinted(t *testing.T) {
for status, want := range map[int]bool{http.StatusCreated: true, http.StatusOK: false} {
rec := &recorder{status: status, answer: node.Record{Address: address(0xab)}}
c := serve(t, rec)
_, created, err := c.Enroll(context.Background(), node.Declare(measurement(0x11)))
if err != nil {
t.Fatalf("Enroll: %v", err)
}
if created != want {
t.Fatalf("status %d reported created=%v, want %v", status, created, want)
}
}
}
// A refusal reaches the caller as a Failure carrying the status the surface
// chose, so a daemon branches on that rather than on message text.
func TestRefusalsCarryStatusAndMessage(t *testing.T) {
rec := &recorder{status: http.StatusConflict, answer: map[string]any{
"message": "custody: measurement differs from the one this identity is sealed to",
}}
c := serve(t, rec)
_, err := c.Sign(context.Background(), address(0xab), node.Declare(measurement(0x11)), 1, nil)
var refusal *Failure
if !errors.As(err, &refusal) {
t.Fatalf("got %v, want a *Failure", err)
}
if refusal.Status != http.StatusConflict || !strings.Contains(refusal.Message, "measurement differs") {
t.Fatalf("refusal does not carry the surface's answer: %+v", refusal)
}
if !strings.Contains(refusal.Error(), "409") {
t.Fatalf("Error() hides the status: %s", refusal.Error())
}
}
// A refusal whose body is not the documented shape still reaches the caller
// intact rather than as an empty message.
func TestRefusalFallsBackToTheRawBody(t *testing.T) {
rec := &recorder{status: http.StatusBadGateway, answer: "upstream exploded"}
c := serve(t, rec)
_, err := c.Fleet(context.Background())
var refusal *Failure
if !errors.As(err, &refusal) || !strings.Contains(refusal.Message, "upstream exploded") {
t.Fatalf("got %v, want the raw body in the message", err)
}
}
// The daemon reaches the KMS over the network before it has joined anything, so
// a body it cannot bound is a body that can exhaust it.
func TestResponsesAreBounded(t *testing.T) {
iam := mockIAM(t, bearer)
defer iam.Close()
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"nodes":[`))
chunk := strings.Repeat(`{"org":"hanzo"},`, 1024)
for written := 0; written < 4*maxResponse; written += len(chunk) {
if _, err := w.Write([]byte(chunk)); err != nil {
return
}
}
}))
defer kms.Close()
c, err := New(Config{
Endpoint: kms.URL, IAMEndpoint: iam.URL,
ClientID: "node", ClientSecret: "secret", Org: "hanzo",
})
if err != nil {
t.Fatalf("New: %v", err)
}
defer c.Close()
// The read stops at the cap, so the truncated JSON fails to decode rather
// than growing without bound.
if _, err := c.Fleet(context.Background()); err == nil {
t.Fatal("an unbounded response was accepted")
}
}
// Node custody is HTTP only: a node asks for its identity before it has joined
// the cluster, so the in-cluster transport is refused here rather than left to
// fail somewhere less obvious.
func TestNodeCallsRefuseTheClusterTransport(t *testing.T) {
c := &Client{transport: "zap", org: "hanzo"}
ctx := context.Background()
addr := address(0xab)
ev := node.Declare(measurement(0x11))
for name, call := range map[string]func() error{
"enroll": func() error { _, _, err := c.Enroll(ctx, ev); return err },
"fleet": func() error { _, err := c.Fleet(ctx); return err },
"read": func() error { _, err := c.Node(ctx, addr); return err },
"sign": func() error { _, err := c.Sign(ctx, addr, ev, 1, nil); return err },
"rotate": func() error { _, _, err := c.Rotate(ctx, addr, ev, 1); return err },
"rebind": func() error { _, err := c.Rebind(ctx, addr, ev, ev); return err },
"revoke": func() error { _, err := c.Revoke(ctx, addr, "x"); return err },
} {
if err := call(); !errors.Is(err, ErrTransport) {
t.Fatalf("%s over ZAP: got %v, want ErrTransport", name, err)
}
}
}