mirror of
https://github.com/hanzoai/kms.git
synced 2026-10-17 11:18:16 +00:00
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
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]>
286 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|