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]>
158 lines
6.1 KiB
Go
158 lines
6.1 KiB
Go
package node
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// PublicKey is an uncompressed secp256k1 public key, 0x04 ‖ X ‖ Y. A record
|
|
// publishes one so a reader can confirm the address derives from it without
|
|
// asking the KMS to vouch for its own answer.
|
|
type PublicKey [65]byte
|
|
|
|
// String returns the 0x-hex form.
|
|
func (p PublicKey) String() string { return "0x" + hexOf(p[:]) }
|
|
|
|
// ParsePublicKey decodes 65 bytes of hex, with or without the 0x prefix.
|
|
func ParsePublicKey(s string) (PublicKey, error) {
|
|
var p PublicKey
|
|
return p, decodeInto(p[:], s, "public key")
|
|
}
|
|
|
|
func (p PublicKey) MarshalJSON() ([]byte, error) { return json.Marshal(p.String()) }
|
|
func (p *PublicKey) UnmarshalJSON(b []byte) error {
|
|
return unmarshal(b, p[:], "public key")
|
|
}
|
|
|
|
// TEE names a CPU trusted execution environment family. The vocabulary matches
|
|
// the attestation package the KMS gates epoch keys with, spelled out, because
|
|
// this value is read by people in a fleet listing and written by the node daemon
|
|
// in a request body.
|
|
type TEE string
|
|
|
|
const (
|
|
// None: no CPU TEE. The measurement is declared by the node.
|
|
None TEE = "none"
|
|
// SevSnp, Tdx and Sgx are hardware families. Naming one obliges the caller
|
|
// to supply a report the KMS can check against the vendor's root.
|
|
SevSnp TEE = "sev-snp"
|
|
Tdx TEE = "tdx"
|
|
Sgx TEE = "sgx"
|
|
)
|
|
|
|
// UnmarshalJSON accepts a family name in any case, with surrounding space. The
|
|
// vocabulary is lower case; normalising here rather than at each boundary means
|
|
// the client, the server and the stored record all hold one spelling, and a
|
|
// daemon that writes "SEV-SNP" is not refused for a difference that carries no
|
|
// meaning.
|
|
func (t *TEE) UnmarshalJSON(b []byte) error {
|
|
var s string
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return err
|
|
}
|
|
*t = TEE(strings.ToLower(strings.TrimSpace(s)))
|
|
return nil
|
|
}
|
|
|
|
// Known reports whether t is a family this protocol defines. An empty TEE reads
|
|
// as None and is known.
|
|
func (t TEE) Known() bool {
|
|
switch t {
|
|
case "", None, SevSnp, Tdx, Sgx:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Hardware reports whether t names a CPU TEE, and so whether a report has to
|
|
// back the measurement before it means anything.
|
|
func (t TEE) Hardware() bool { return t.Known() && t != "" && t != None }
|
|
|
|
// Evidence is what a node presents to say which image it is running. It
|
|
// accompanies every request that touches key material, because the measurement
|
|
// is the associated data the key is sealed under: without it the key does not
|
|
// open.
|
|
type Evidence struct {
|
|
// TEE names the family that produced Measurement. Empty reads as None.
|
|
TEE TEE `json:"tee"`
|
|
// Measurement is the image digest the key is bound to.
|
|
Measurement Measurement `json:"measurement"`
|
|
// Quote is the hardware attestation report backing Measurement. It belongs
|
|
// only with a hardware family; supplying one under None is a contradiction.
|
|
Quote []byte `json:"quote,omitempty"`
|
|
}
|
|
|
|
// Declare returns evidence for an image digest on hardware with no CPU TEE,
|
|
// which is what a host running the microVM under the Virtualization framework,
|
|
// or on an x86-64 or arm64 host without SEV-SNP or TDX, presents today.
|
|
func Declare(m Measurement) Evidence { return Evidence{TEE: None, Measurement: m} }
|
|
|
|
// Status is a node identity's lifecycle state. Only an active identity signs.
|
|
type Status string
|
|
|
|
const (
|
|
// Active: the key is sealed, usable, and is the node's current identity.
|
|
Active Status = "active"
|
|
// Superseded: rotated. The record carries the succession its predecessor
|
|
// signed; its material is destroyed, so it can never sign again.
|
|
Superseded Status = "superseded"
|
|
// Revoked: decommissioned or slashed. Material destroyed, signing refused
|
|
// permanently. The record survives so the fleet history and the address stay
|
|
// enumerable and the settlement L1 can attribute a slash.
|
|
Revoked Status = "revoked"
|
|
)
|
|
|
|
// Record is what the KMS knows about one node identity.
|
|
//
|
|
// It carries no key material and has no field for any: the sealed private key
|
|
// lives under a different store key entirely, so no serialization of this type
|
|
// can leak one.
|
|
type Record struct {
|
|
// Address is the wallet, and so the node's identity everywhere: on the
|
|
// settlement L1, in the staking account, in the reward account, and in the
|
|
// fleet view.
|
|
Address Address `json:"address"`
|
|
// Org is the tenant that operates the node. A Hanzo-owned node and a third
|
|
// party's node differ only in this field.
|
|
Org string `json:"org"`
|
|
// Subject is the IAM subject the identity was enrolled to — the only
|
|
// principal that may sign with it.
|
|
Subject string `json:"subject"`
|
|
// PublicKey is the address's uncompressed public key.
|
|
PublicKey PublicKey `json:"public_key"`
|
|
// Measurement is the image the key is sealed to and usable from.
|
|
Measurement Measurement `json:"measurement"`
|
|
// TEE is the family that produced Measurement.
|
|
TEE TEE `json:"tee"`
|
|
// Attested reports whether hardware produced Measurement. False means the
|
|
// node declared it: the key is still pinned to that one image, but the value
|
|
// is the node's own claim.
|
|
Attested bool `json:"attested"`
|
|
// Status is the lifecycle state.
|
|
Status Status `json:"status"`
|
|
// Epoch is the last epoch this identity signed at. The next signature must
|
|
// exceed it.
|
|
Epoch uint64 `json:"epoch"`
|
|
// Prev and Next link a rotation chain. Nil at either end.
|
|
Prev *Address `json:"prev,omitempty"`
|
|
Next *Address `json:"next,omitempty"`
|
|
// Succession is the statement this identity signed to hand itself on,
|
|
// present on a superseded record. It is what the settlement L1 reads to move
|
|
// the stake, and it stays readable after the key is gone.
|
|
Succession *Handover `json:"succession,omitempty"`
|
|
// Reason records why an identity was revoked.
|
|
Reason string `json:"reason,omitempty"`
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Fleet is a tenant's node identities. Truncated reports that the fleet exceeded
|
|
// what one listing returns and Nodes is a bounded prefix, so a caller never
|
|
// mistakes a capped answer for the whole fleet.
|
|
type Fleet struct {
|
|
Nodes []*Record `json:"nodes"`
|
|
Truncated bool `json:"truncated"`
|
|
}
|