Files
mesh-catalog/modules/mesh-vault/cmd/mesh-vault/enrolment_tools.go
T
jschoubben bb85684b2a CAT-V1: the seat mesh-vault's retire removes one module's own secret on the warrant of mesh-config's hand act, and says own-retired
The caller must be on the vault's trusted list retire-callers (the holder of mesh-config alone) and carry
a hand act it recorded for exactly this node, module and name. A waiter on rotated or own-replaced does
not wake on own-retired. Reviewer note of issue 1966: HandedOf returns a placement read error instead of
dropping the modules placed there.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:54:58 +01:00

202 lines
8.7 KiB
Go

package main
// The tools of enrolment and node rotation (novox/hq ADR 0587, ADR 0583): the seat's verbs the vault serves, and the
// tools that read what it holds of them. Arguments are strings and booleans the runtime hands; the caller is the one the
// runtime proved.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// EnrolmentHeld is one enrolment token the vault holds, as secret_holders lists it: never the token or its hash.
type EnrolmentHeld struct {
Node string `json:"node"`
User string `json:"user"`
Generation int `json:"generation"`
Fingerprint string `json:"fingerprint"`
At time.Time `json:"at"`
// Account says the node's own account is made too (node-account answered).
Account bool `json:"account_made"`
}
// EnrolmentsHeld lists the enrolment tokens held, by node.
func (v *Vault) EnrolmentsHeld() ([]EnrolmentHeld, error) {
out := []EnrolmentHeld{}
if v.Accounts == nil {
return out, nil
}
users, err := v.Accounts.Users()
if err != nil {
return out, err
}
for _, u := range users {
a, err := v.Accounts.Get(u)
if err != nil || a == nil || a.Kept != KeptEnrolment {
continue
}
node := u[len(EnrolUserPrefix):]
n, _ := v.Accounts.Get(NodeEnginePrefix + node)
out = append(out, EnrolmentHeld{Node: node, User: u, Generation: a.Generation, Fingerprint: a.Fingerprint, At: a.At,
Account: n != nil && n.Kept == KeptNode})
}
sort.Slice(out, func(i, j int) bool { return out[i].Node < out[j].Node })
return out, nil
}
func enrolmentTools(env *Env) []toolDef {
node := str("the node's name")
return []toolDef{
{Name: Seat + ".enrolment-token",
Description: "The enrolment token of a joining machine, for the seat mesh-enrolment alone (novox/hq ADR 0587 §3, §5, " +
"§8). Issue (the default): takes the machine's first tunnel, makes the one-time secret, keeps both with the operator " +
"copy, writes the secret's hash as the bus user enrol.<node>, and answers the bus's address, its certificate's " +
"fingerprint and the vault's signing key, never the token. sealed: the whole token sealed to the operator key, " +
"for the operator to open at their terminal. drop: the token and its hash gone, at every end of an enrolment.",
Input: map[string]any{"node": node, "expires": str("when the token ends, as the seat states it"),
"tunnel": map[string]any{"type": "object", "description": "the machine's first tunnel at issue: hub_key, " +
"hub_endpoint, address (and key, range)"},
"sealed": map[string]any{"type": "boolean", "description": "answer the token sealed to the operator key"},
"drop": map[string]any{"type": "boolean", "description": "drop the token and its hash"}},
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
tunnel, err := tunnelArg(a["tunnel"])
if err != nil {
return nil, err
}
return v.EnrolmentToken(caller, EnrolmentTokenArgs{Node: arg(a, "node"), Expires: arg(a, "expires"),
Tunnel: tunnel, Sealed: isTrue(a["sealed"]), Drop: isTrue(a["drop"])})
}},
{Name: Seat + ".node-account",
Description: "A joining node's account node.<node>, for the seat mesh-enrolment alone at the redeem (novox/hq ADR 0587 " +
"§3): made once for a node and the keys it presented, answered sealed to the sealing key presented, with the " +
"node's key in node-logins naming the login in use. Asked again for the same keys it answers the same account. " +
"retire: the account, its value and its key taken away at a revoke.",
Input: map[string]any{"node": node, "sealing_key": str("the machine's X25519 sealing key, raw in base64 or SPKI PEM"),
"keys_digest": str("the digest of the keys the machine presented"),
"retire": map[string]any{"type": "boolean", "description": "take the account away"}},
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
return v.NodeAccount(caller, NodeAccountArgs{Node: arg(a, "node"), SealingKey: arg(a, "sealing_key"),
KeysDigest: arg(a, "keys_digest"), Retire: isTrue(a["retire"])})
}},
{Name: Seat + ".rotate-node",
Description: "Begin the rotation of a node's login (novox/hq ADR 0583 §2, §7): answered only for a call that carries a hand " +
"act recorded through mesh-hand-acts, read back by its id. One rotation at a time mesh-wide: a call asked while " +
"another runs is queued and begun when it is done, the bus node after every other. Makes the other login " +
"(node.<n>--b or node.<n>), keeps its value for the node's engine, and says rotation-begun.",
Input: map[string]any{"node": node, "why": str("why the login rotates"),
"act": str("the id of the hand act recorded through mesh-hand-acts that warrants it")},
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
return v.RotateNode(caller, RotateNodeArgs{Node: arg(a, "node"), Why: arg(a, "why"), Act: arg(a, "act")})
}},
{Name: Seat + ".node-account-confirm",
Description: "A node-engine, dialled as its new login, says it connects with it (novox/hq ADR 0583 §3, §4): the runtime " +
"proves the login, the old login's hash is removed, and rotation-done follows when the loaded list lacks it.",
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
return v.NodeAccountConfirm(caller)
}},
{Name: Seat + ".abandon",
Description: "Abandon a node's rotation before its confirmation (novox/hq ADR 0583 §5): the new login and its value are " +
"dropped, the old hash kept, the rotating part of the node's key cleared; a rotation only asked leaves the queue.",
Input: map[string]any{"node": node},
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
return v.AbandonRotation(caller, arg(a, "node"))
}},
{Name: Seat + "." + RetireVerb,
Description: "Retire one module's own secret on a node because no manifest reads it any more (design 32): removed " +
"whole, and own-retired said (node, module, name, the ask, the caller; never a value). Answered to the holder of " +
"the seat mesh-config alone, as the vault's list retire-callers names it, and only for a hand act it recorded " +
"through mesh-hand-acts for this verb naming exactly this node, module and name (`act`), read back by its id.",
Input: map[string]any{"node": node, "module": str("the module whose secret it is"), "name": str("the secret's name"),
"act": str("the id of the hand act recorded through mesh-hand-acts that warrants it"),
"ask": str("the id of the operator's ask whose Approve this answers, kept in what is said")},
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
return v.RetireOwn(caller, RetireArgs{Node: arg(a, "node"), Module: arg(a, "module"), Name: arg(a, "name"),
Act: arg(a, "act"), Ask: arg(a, "ask")})
}},
{Name: "node_logins",
Description: "Every node's key of the vault's state node-logins: the login its node-engine dials as and, while a rotation " +
"runs, the other login and its stage; with the rotations queued and the enrolment tokens held. Never a value or a hash.",
run: func(string, map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
if v.Logins == nil {
return nil, errors.New("the vault has no state node-logins")
}
keys, err := v.Logins.Keys()
if err != nil {
return nil, err
}
sort.Strings(keys)
rows := []NodeLogin{}
for _, k := range keys {
if nl, err := v.Logins.Get(k); err == nil && nl != nil {
rows = append(rows, *nl)
}
}
held, _ := v.EnrolmentsHeld()
return map[string]any{"logins": rows, "rotation": v.RotationState(), "enrolments": held}, nil
}},
}
}
// tunnelArg reads enrolment-token's `tunnel`, an object or its JSON as a string, as mesh-sdk's TokenTunnel; nil when
// absent.
func tunnelArg(v any) (*delivery.TokenTunnel, error) {
var raw []byte
switch t := v.(type) {
case nil:
return nil, nil
case string:
if strings.TrimSpace(t) == "" {
return nil, nil
}
raw = []byte(t)
default:
b, err := json.Marshal(t)
if err != nil {
return nil, err
}
raw = b
}
var tt delivery.TokenTunnel
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&tt); err != nil {
return nil, fmt.Errorf("`tunnel` is not a first tunnel (hub_key, hub_endpoint, address, key, range): %w", err)
}
return &tt, nil
}