From 59b4c7910b30e80603ca069526a01f9a63f4bf68 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:13:46 +0100 Subject: [PATCH 01/15] Add the seat mesh-enrolment: six verbs and five events, so the module that joins a machine has a role the judge knows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0587 §1 makes joining a machine a seat held once by the module mesh-enrolment, the vault's only caller for its enrolment verbs. The seat is in the mesh's own set, its verbs not optional (the seat is new with its holder), and its events the five that carry no secret. The test is a block in TestHoldingAMeshSeatRequiresServingItsVerbs, no new test function. (issue 774, 774/S34) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/enrolment_seat.go | 92 ++++++++++++++++++++++++++++++++++ go/catalogue/seats.go | 2 + go/catalogue/verbs_test.go | 20 ++++++++ 3 files changed, 114 insertions(+) create mode 100644 go/catalogue/enrolment_seat.go diff --git a/go/catalogue/enrolment_seat.go b/go/catalogue/enrolment_seat.go new file mode 100644 index 0000000..6ff3dd0 --- /dev/null +++ b/go/catalogue/enrolment_seat.go @@ -0,0 +1,92 @@ +package catalogue + +// **Joining a machine is the seat mesh-enrolment, held once** (novox/hq ADR 0587 §1, issue 774): held for the mesh by +// the module mesh-enrolment, the only caller the vault's manifest names for its enrolment-token and node-account verbs +// and a caller it names for `sign`. Six verbs, five events, none carrying a secret or anything derived from the token: +// the one hash an event carries is the digest of the public keys a node presented. It mints and keeps no secret and +// holds no signing key. Its state and its key layout are go/delivery's (enrolment.go). + +// EnrolmentSeat is the seat mesh-enrolment. +const EnrolmentSeat = "mesh-enrolment" + +// The seat's six verbs (ADR 0587 §1, §4-§8). +const ( + EnrolmentVerbIssue = "issue" + EnrolmentVerbRedeem = "redeem" + EnrolmentVerbConfirm = "confirm" + EnrolmentVerbRevoke = "revoke" + EnrolmentVerbTokens = "tokens" + EnrolmentVerbAgain = "again" +) + +// The seat's five events (ADR 0587 §1, §2, §5): said by its holder on the seat, each carrying an enrolment key's whole +// content except enrolment-expired, which names the node and the enrolment-issued event that carried it. +const ( + EnrolmentIssuedEvent = "enrolment-issued" + NodeEnrolledEvent = "node-enrolled" + EnrolmentRefusedEvent = "enrolment-refused" + EnrolmentRevokedEvent = "enrolment-revoked" + EnrolmentExpiredEvent = "enrolment-expired" +) + +// EnrolmentEvents are the seat's events, in the order an enrolment says them; the last four each end one. +var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, EnrolmentRefusedEvent, EnrolmentRevokedEvent, + EnrolmentExpiredEvent} + +// enrolmentDecision is the record that made the seat. +const enrolmentDecision = "novox/hq ADR 0587" + +// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional: +// the seat is new with its holder, so no holder of before serves fewer. +func enrolmentVerbs() []Verb { + str := func(description string) map[string]any { + return map[string]any{"type": "string", "description": description} + } + return []Verb{ + {Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " + + "refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " + + "key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " + + "reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " + + "warrant, asks the vault for the token and says enrolment-issued. The token itself is read only through tokens.", + Input: schema(map[string]string{ + "node": "the new machine's name in the mesh", + "why": "why it joins, shown to the operator with the ask", + "expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours", + }, []string{"node", "why"})}, + {Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol. " + + "alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " + + "vault for the node's account and certificate, writes the node into placement, and answers the account sealed " + + "to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " + + "it finishes what is not done and answers the same; with other keys it is refused.", + Input: map[string]any{"type": "object", "additionalProperties": false, + "required": []string{"node", "public_key", "sealing_key", "proof"}, + "properties": map[string]any{ + "node": str("the node the token was issued for"), + "public_key": str("the identity key's public half, base64"), + "overlay_key": str("the private network key's public half"), + "sealing_key": str("the sealing key's public half, which the account is sealed to"), + "serving_key": str("the serving key's public half, which the certificate binds"), + "profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"}, + "tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"}, + "proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"), + }}}, + {Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " + + "node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " + + "enrolment.", + Input: schema(map[string]string{}, nil)}, + {Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " + + "an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " + + "it, records the act as a hand act and says enrolment-revoked.", + Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"}, + []string{"node", "why"})}, + {Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " + + "the bucket's fill against its cap, and whether issuing is on and why (the lease). For a node at live, its " + + "token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " + + "history, the ended enrolments from the seat's events, as far back as the events are kept.", + Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"}, + nil, "history")}, + {Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " + + "and nothing more.", + Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})}, + } +} diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 4dc4536..79b48f0 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -305,6 +305,8 @@ var defaultSeats = append([]Seat{ // The lease write (novox/hq ADR 0582 clause 1, grants_seat.go): the grants part hands the controller's lease to // itself and back on the operator's warrant. {Name: GrantsSeat, Scope: ScopeMesh, Decision: grantsDecision, Emits: grantsEvents(), Serves: grantsVerbs()}, + // Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment. + {Name: EnrolmentSeat, Scope: ScopeMesh, Decision: enrolmentDecision, Emits: EnrolmentEvents, Serves: enrolmentVerbs()}, // The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go). {Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"}, Serves: artifactSweepVerbs()}, diff --git a/go/catalogue/verbs_test.go b/go/catalogue/verbs_test.go index bf90983..a75b064 100644 --- a/go/catalogue/verbs_test.go +++ b/go/catalogue/verbs_test.go @@ -25,6 +25,26 @@ func TestAServedVerbIsANameOrADefinition(t *testing.T) { // Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs. func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) { + // 774/S34/AC1: the seat mesh-enrolment is held by a module serving its six verbs, and a seventh is refused. + enrolment, ok := SeatNamed(EnrolmentSeat) + if !ok || enrolment.Scope != ScopeMesh || len(enrolment.Emits) != 5 { + t.Fatalf("mesh-enrolment: in the set %v, %+v", ok, enrolment) + } + six := []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"} + holder := func(serves ...string) Manifest { + return Manifest{Module: "mesh-enrolment", Tools: serves, + Claims: []Claim{{Name: EnrolmentSeat, Scope: ScopeMesh, Serves: serves}}} + } + if err := CanHold(holder(six...), enrolment); err != nil { + t.Errorf("the holder serving the six verbs was refused: %v", err) + } + if err := CanHold(holder(six[:5]...), enrolment); err == nil || !strings.Contains(err.Error(), "does not serve again") { + t.Errorf("a holder missing again: %v", err) + } + if err := CanHold(holder(append(six, "mint")...), enrolment); err == nil || !strings.Contains(err.Error(), "mint") { + t.Errorf("a holder saying it serves mint: %v", err) + } + was := Seats() t.Cleanup(func() { UseSeats(was) }) UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test", From 959f57294a8afb33f4038b911da68844aec7340d Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:15:08 +0100 Subject: [PATCH 02/15] Add the contract of the enrolment state and of redeem, so the holder, the grants part and the engine read one shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0587 §2 gives the seat mesh-enrolment the bucket enrolments with keys node. (an Enrolment: node, address, why, caller, ask, stage, issued, expires, keys digest) and address.
(the node's name alone), no secret in either. The token's life rules (an hour by default, at most a day, §4), the event enrolment-expired's content (§2), and redeem's call, proof bytes, keys digest and signed answer (§6) live here too, because the holder in mesh-delivery and the engine in mesh-host both read them. The proof binds the node and its keys, not the token's secret, which the holder never holds (§1). Tested as rows of TestNodeRecord, no new test function. (issue 774, 774/S35) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/enrolment.go | 240 +++++++++++++++++++++++++++++++++ go/delivery/noderecord_test.go | 59 +++++++- 2 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 go/delivery/enrolment.go diff --git a/go/delivery/enrolment.go b/go/delivery/enrolment.go new file mode 100644 index 0000000..de8afad --- /dev/null +++ b/go/delivery/enrolment.go @@ -0,0 +1,240 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/netip" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// **The state of the seat mesh-enrolment** (novox/hq ADR 0587 §2, issue 774): the holder's bucket `enrolments`, holding +// no secret, with two kinds of key. `node.`, one per open enrolment, holds an Enrolment; `address.
` +// reserves one private-network address for one open enrolment and holds only the node's name. Every write is a +// compare-and-set at the revision read. The bucket keeps per-key expiry (`key-ttl`): a key at stage live carries the +// token's expiry, at asked and redeeming none (§5). It is read by the holder and, as a named reader, by the grants part, +// which composes the user `enrol.` from a key at live and the vault's hash (§4). These constants are the seat's +// contract: every reader takes the names from here. +const ( + // EnrolmentModule is the module holding the seat mesh-enrolment. + EnrolmentModule = catalogue.EnrolmentSeat + // EnrolmentsState is its state, EnrolmentsBucket the bucket, EnrolmentsRead the name a reader's manifest declares. + EnrolmentsState = "enrolments" + EnrolmentsBucket = EnrolmentModule + "_" + EnrolmentsState + EnrolmentsRead = EnrolmentModule + "." + EnrolmentsState + + // The stages of an open enrolment (§2, §4, §5). + EnrolmentAsked = "asked" + EnrolmentLive = "live" + EnrolmentRedeeming = "redeeming" + + // DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (§4). + DefaultTokenLife = time.Hour + MostTokenLife = 24 * time.Hour + + // EnrolmentUserPrefix is the bus user a joining machine dials as, `enrol.` (§4). + EnrolmentUserPrefix = "enrol." + + enrolmentNodePrefix = "node." + enrolmentAddressPrefix = "address." +) + +// Enrolment is one key `node.` of `enrolments`: the node's name, the private-network address chosen at issue, the +// why, the caller as the runtime proved it, the ask's id, the stage, when it was issued and when the token expires, and +// from redeem the digest of the public keys presented (KeysDigest). No secret and nothing derived from the token. +type Enrolment struct { + V int `json:"v"` + Node string `json:"node"` + Address string `json:"address"` + Why string `json:"why"` + Caller string `json:"caller"` + Ask string `json:"ask,omitempty"` + Stage string `json:"stage"` + Issued time.Time `json:"issued"` + Expires time.Time `json:"expires"` + KeysDigest string `json:"keys-digest,omitempty"` +} + +// EnrolmentAddress is one key `address.
`: the node the address is reserved for, and nothing else. +type EnrolmentAddress struct { + V int `json:"v"` + Node string `json:"node"` +} + +// EnrolmentNodeKey is a node's key in `enrolments`. +func EnrolmentNodeKey(node string) string { return enrolmentNodePrefix + node } + +// EnrolmentAddressKey is an address's key in `enrolments`: the address as it is, its dots kept (a key's tokens). +func EnrolmentAddressKey(address string) string { return enrolmentAddressPrefix + address } + +// EnrolmentKeyNode is the node a key `node.` names, and whether it is one. +func EnrolmentKeyNode(key string) (string, bool) { + node, ok := strings.CutPrefix(key, enrolmentNodePrefix) + return node, ok && checkName("node", node) == nil +} + +// EnrolmentKeyAddress is the address a key `address.
` names, and whether it is one. +func EnrolmentKeyAddress(key string) (string, bool) { + address, ok := strings.CutPrefix(key, enrolmentAddressPrefix) + if !ok { + return "", false + } + _, err := netip.ParseAddr(address) + return address, err == nil +} + +// EnrolmentUser is the bus user a node enrols as. +func EnrolmentUser(node string) string { return EnrolmentUserPrefix + node } + +func (e Enrolment) check() error { + if err := checkName("node", e.Node); err != nil { + return err + } + if _, err := netip.ParseAddr(e.Address); err != nil { + return fmt.Errorf("delivery: the enrolment of %s names the address %q, which is not an address", e.Node, e.Address) + } + if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" { + return fmt.Errorf("delivery: the enrolment of %s names no why or no caller", e.Node) + } + switch e.Stage { + case EnrolmentAsked, EnrolmentLive: + if e.KeysDigest != "" { + return fmt.Errorf("delivery: the enrolment of %s at %s names presented keys, which only a redeem gives", e.Node, e.Stage) + } + case EnrolmentRedeeming: + if err := checkDigest(e.KeysDigest, "the enrolment's keys digest"); err != nil { + return err + } + default: + return fmt.Errorf("delivery: the enrolment of %s is at stage %q, not asked, live or redeeming", e.Node, e.Stage) + } + if e.Issued.IsZero() || !e.Expires.After(e.Issued) { + return fmt.Errorf("delivery: the enrolment of %s names no issue or an expiry not after it", e.Node) + } + return nil +} + +// EncodeEnrolment is e as JSON at Version; it refuses what DecodeEnrolment refuses. +func EncodeEnrolment(e Enrolment) ([]byte, error) { + if err := e.check(); err != nil { + return nil, err + } + e.V = Version + return json.Marshal(e) +} + +// DecodeEnrolment reads a key `node.`; it refuses an unknown version (ErrUnknownVersion), a node that is not a +// name, an address that is not one, an unknown stage, presented keys before redeem or none at it, and an expiry not +// after the issue. +func DecodeEnrolment(data []byte) (Enrolment, error) { + var e Enrolment + if err := decode(data, &e); err != nil { + return Enrolment{}, err + } + if err := e.check(); err != nil { + return Enrolment{}, err + } + return e, nil +} + +// EncodeEnrolmentAddress is a key `address.
` reserving it for node. +func EncodeEnrolmentAddress(node string) ([]byte, error) { + if err := checkName("node", node); err != nil { + return nil, err + } + return json.Marshal(EnrolmentAddress{V: Version, Node: node}) +} + +// DecodeEnrolmentAddress reads a key `address.
`. +func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) { + var a EnrolmentAddress + if err := decode(data, &a); err != nil { + return EnrolmentAddress{}, err + } + if err := checkName("node", a.Node); err != nil { + return EnrolmentAddress{}, err + } + return a, nil +} + +// TokenLife is the life issue's `expires` names: DefaultTokenLife when empty, refused when it is not a duration, not +// positive or longer than MostTokenLife. +func TokenLife(expires string) (time.Duration, error) { + if strings.TrimSpace(expires) == "" { + return DefaultTokenLife, nil + } + d, err := time.ParseDuration(strings.TrimSpace(expires)) + if err != nil || d <= 0 || d > MostTokenLife { + return 0, fmt.Errorf("delivery: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires) + } + return d, nil +} + +// EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the +// key's content, by its sequence on the events' stream, since the bus deleted the key before the holder heard of it. +type EnrolmentExpiry struct { + V int `json:"v"` + Node string `json:"node"` + Issued uint64 `json:"issued-event"` +} + +// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine, +// which live in two repositories. + +// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over +// RedeemProof. No token: the bus's login check verified it. +type Redeem struct { + Node string `json:"node"` + PublicKey string `json:"public_key"` + OverlayKey string `json:"overlay_key,omitempty"` + SealingKey string `json:"sealing_key"` + ServingKey string `json:"serving_key,omitempty"` + Profile map[string]any `json:"profile,omitempty"` + Tunnel json.RawMessage `json:"tunnel,omitempty"` + Proof string `json:"proof"` +} + +// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it +// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes. +func RedeemProof(node, public, overlay, sealing, serving string) []byte { + return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving) +} + +// KeysDigest is the digest of the public keys a redeem presents, `sha256:`: what the key at redeeming and the +// events carry, the one hash an event carries (§1). +func KeysDigest(public, overlay, sealing, serving string) string { + sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00"))) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// KeysDigest is r's keys' digest. +func (r Redeem) KeysDigest() string { + return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey) +} + +// ProofBytes is r's proof decoded from base64. +func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) } + +// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it +// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over +// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries. +type RedeemAnswer struct { + Node string `json:"node"` + Login string `json:"login"` + Account string `json:"account"` + Certificate string `json:"certificate"` + Signer string `json:"signer"` + Signature string `json:"signature,omitempty"` +} + +// SignedBytes is what the vault signs of a: a without its signature, as JSON. +func (a RedeemAnswer) SignedBytes() []byte { + a.Signature = "" + raw, _ := json.Marshal(a) + return raw +} diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index 1e985e9..0a39b1a 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -5,6 +5,7 @@ import ( "regexp" "strings" "testing" + "time" "git.novox.be/novox/mesh-sdk/go/catalogue" ) @@ -13,7 +14,7 @@ import ( // check (ADR 0201 §13) refuses such a field in a state every node reads. var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`) -// Issue 1126 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and +// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and // neither it nor the placement file names a field like a secret. func TestNodeRecord(t *testing.T) { hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub", @@ -68,6 +69,62 @@ func TestNodeRecord(t *testing.T) { } } walk(reflect.TypeOf(NodeRecord{}), "NodeRecord") + // 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the + // grants part reads them (ADR 0587 §2). + issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) + for _, e := range []Enrolment{ + {Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive, + Issued: issued, Expires: issued.Add(DefaultTokenLife)}, + {Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming, + Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")}, + } { + raw, err := EncodeEnrolment(e) + if err != nil { + t.Fatal(err) + } + back, err := DecodeEnrolment(raw) + e.V = Version + if err != nil || !reflect.DeepEqual(back, e) { + t.Errorf("enrolment round trip: %+v, %v", back, err) + } + } + if raw, err := EncodeEnrolmentAddress("lab"); err != nil { + t.Fatal(err) + } else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" { + t.Errorf("address round trip: %+v, %v", back, err) + } + for name, e := range map[string]Enrolment{ + "keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive, + Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")}, + "no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming, + Issued: issued, Expires: issued.Add(time.Hour)}, + "no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued, Expires: issued.Add(time.Hour)}, + "other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", Issued: issued, + Expires: issued.Add(time.Hour)}, + } { + if _, err := EncodeEnrolment(e); err == nil { + t.Errorf("enrolment with %s: taken", name) + } + } + if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" { + t.Errorf("address key: %q %v", key, ok) + } + if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" { + t.Errorf("node key: %q %v", node, ok) + } + for _, life := range []string{"", "1h", "24h"} { + if _, err := TokenLife(life); err != nil { + t.Errorf("a life of %q: %v", life, err) + } + } + for _, life := range []string{"25h", "-1h", "a day"} { + if _, err := TokenLife(life); err == nil { + t.Errorf("a life of %q: taken", life) + } + } + walk(reflect.TypeOf(Enrolment{}), "Enrolment") + walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress") + walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry") // And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential. walk(reflect.TypeOf(Placement{}), "Placement") } From 2ee75954adf94960cb45a7ad7ecc57674b8586e6 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:16:06 +0100 Subject: [PATCH 03/15] Move the joining token's shape into mesh-sdk, so the vault that makes it, mesh-cli and the engine read one type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the lease write the vault makes the token (ADR 0587 §3), mesh-cli opens and prints it, and the node-engine parses it: three repositories, so the type leaves mesh-host's internal/identity (which keeps an alias) for go/delivery, field names unchanged from the controller's token issue, the tunnel kept (the lead's settlement 7). Encode is added, refusing a token with a part missing. The test reads a token the controller's own code made and encodes it back to the same line. Paid by merging TestGateBuckets into TestKeysOfPlainNamesAreUnchanged. (issue 774, 774/S36) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/gate_test.go | 9 ++- go/delivery/token.go | 118 ++++++++++++++++++++++++++++++++++++++ go/delivery/token_test.go | 53 +++++++++++++++++ 3 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 go/delivery/token.go create mode 100644 go/delivery/token_test.go diff --git a/go/delivery/gate_test.go b/go/delivery/gate_test.go index bd4e7bc..0927506 100644 --- a/go/delivery/gate_test.go +++ b/go/delivery/gate_test.go @@ -356,17 +356,16 @@ func TestGateSame(t *testing.T) { // 1201/AC6 } } -func TestGateBuckets(t *testing.T) { +// today's keys: names of letters, digits, - / and _ key exactly as before the escape. +func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2 + // And the buckets and their history are the decided layout (once TestGateBuckets; merged to pay for issue 774's + // token test). if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" || OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" || MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 || TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 { t.Error("a bucket or history differs from the decided layout") } -} - -// today's keys: names of letters, digits, - / and _ key exactly as before the escape. -func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2 hh, _ := HeadsHash(gateHeads()) for _, name := range []string{"feat/x", "fix/1323-escape", "feat/delivery_source-keys", "main", "A/B_c-9"} { if k, err := MergesKey(name); err != nil || k != name { diff --git a/go/delivery/token.go b/go/delivery/token.go new file mode 100644 index 0000000..9a376da --- /dev/null +++ b/go/delivery/token.go @@ -0,0 +1,118 @@ +package delivery + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the bus is, what certificate +// to expect there, whose signature to believe afterwards, and a one-time right to join; and, when it was issued for the +// machine's tunnel key, the machine's first tunnel (ADR 0169). +// +// Moved here from the node-engine (mesh-host internal/identity, which keeps an alias) so the vault, which makes the +// token from the lease write on (novox/hq ADR 0587 §3, issue 774), mesh-cli, which opens and prints it, and the +// node-engine, which parses it, read one shape. The field names are the wire format the controller's `token issue` +// writes, unchanged, so a token of either issuer reads the same; the tunnel stays in it (the lead's settlement of +// ADR 0587 §3, 2026-11-02). +type Token struct { + Version int `json:"v"` + + // Node is what the mesh calls this machine: the bus user it dials as is named after it, so it must be known before + // the mesh can say anything. + Node string `json:"node,omitempty"` + + Broker string `json:"broker,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Signer []byte `json:"signer,omitempty"` + Secret string `json:"secret"` + + // Adopted says this node joins adopted (novox/hq ADR 0100); absent for a converged node. + Adopted bool `json:"adopted,omitempty"` + + // Tunnel is this machine's first tunnel, when the token was issued for the key it made (novox/hq ADR 0169): its own + // address and the hub to reach, so it reaches the bus over the private network and the bus never faces the internet. + Tunnel *TokenTunnel `json:"tunnel,omitempty"` +} + +// TokenTunnel is the joining machine's side of its first tunnel, in the field names the controller writes. +type TokenTunnel struct { + Key string `json:"key"` + Address string `json:"address"` + Range string `json:"range"` + HubKey string `json:"hub_key"` + HubEndpoint string `json:"hub_endpoint"` +} + +// ParseToken reads a token a person pasted. Every refusal says *this is not a token* rather than *this is the wrong +// token*: telling "not from the mesh I joined" apart from "malformed" is a signature check later (ADR 0004). A token +// missing a part is refused whole, naming every part, never used partially: without the fingerprint a node connects +// to whatever answers, without the signing key it cannot tell a declaration from a forgery. +func ParseToken(encoded string) (Token, error) { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return Token{}, fmt.Errorf("this is not a token: %w", err) + } + var t Token + if err := json.Unmarshal(raw, &t); err != nil { + return Token{}, fmt.Errorf("this is not a token: %w", err) + } + if t.Version != 1 { + return Token{}, fmt.Errorf( + "this token says it is version %d, and this host understands version 1", t.Version) + } + if missing := t.Missing(); len(missing) > 0 { + return Token{}, fmt.Errorf( + "this token is missing %s, so it cannot be used to join anything", + strings.Join(missing, ", ")) + } + return t, nil +} + +// Missing names every part of t a joining machine needs and t lacks. +func (t Token) Missing() []string { + var missing []string + if strings.TrimSpace(t.Broker) == "" { + missing = append(missing, "the broker's address") + } + if strings.TrimSpace(t.Fingerprint) == "" { + missing = append(missing, "the broker certificate's fingerprint") + } + if len(t.Signer) != ed25519.PublicKeySize { + missing = append(missing, "the control plane's signing key") + } + if strings.TrimSpace(t.Secret) == "" { + missing = append(missing, "the one-time secret") + } + if tt := t.Tunnel; tt != nil { + for _, part := range []struct{ value, says string }{ + {tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"}, + {tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"}, + {tt.HubEndpoint, "where the hub's tunnel is dialled"}, + } { + if strings.TrimSpace(part.value) == "" { + missing = append(missing, part.says) + } + } + } + return missing +} + +// Encode is t as one line a person carries: base64 (URL alphabet, no padding) of its JSON at version 1, as the +// controller's `token issue` writes it. Refused when a part is missing. +func (t Token) Encode() (string, error) { + t.Version = 1 + if missing := t.Missing(); len(missing) > 0 { + return "", fmt.Errorf("delivery: the token is missing %s", strings.Join(missing, ", ")) + } + raw, err := json.Marshal(t) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// SignerKey is the signing key the token carries, as a key: the controller's before the lease write, the vault's after. +func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) } diff --git a/go/delivery/token_test.go b/go/delivery/token_test.go new file mode 100644 index 0000000..5e7d73d --- /dev/null +++ b/go/delivery/token_test.go @@ -0,0 +1,53 @@ +package delivery + +import ( + "encoding/hex" + "reflect" + "strings" + "testing" +) + +// controllerToken is a token made by the controller's own `token issue` code: mesh-control origin/main 5c281ad9, +// internal/token Token.Encode, run on 2026-11-02 for node anchor, adopted, with a first tunnel, its signer the ed25519 +// key of the seed 00 01 .. 1f. Copied, never written by hand. +const controllerToken = "eyJ2IjoxLCJub2RlIjoiYW5jaG9yIiwiYnJva2VyIjoiMTkyLjAuMi4xMDo0MjIyIiwiZmluZ2VycHJpbnQiOiJzaGEyNTY6YWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYiIsInNpZ25lciI6IkE2RUh2L1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmc9Iiwic2VjcmV0IjoiYS1vbmUtdGltZS1zZWNyZXQiLCJhZG9wdGVkIjp0cnVlLCJ0dW5uZWwiOnsia2V5IjoiZEdobExXMWhZMmhwYm1WekxXdGxlUT09IiwiYWRkcmVzcyI6IjEwLjAuMC43LzI0IiwicmFuZ2UiOiIxMC4wLjAuMC8yNCIsImh1Yl9rZXkiOiJkR2hsTFdoMVluTXRhMlY1IiwiaHViX2VuZHBvaW50IjoiMTk4LjUxLjEwMC4xOjUxODIwIn19" + +// 774/S36/AC1: a token the controller made reads, with the moved code, field for field as it was written; encoding it +// again gives the same line; a part missing is refused naming it; garbage and another version are not tokens. +func TestATokenTheControllerMadeReads(t *testing.T) { + signer, _ := hex.DecodeString("03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8") + want := Token{Version: 1, Node: "anchor", Broker: "192.0.2.10:4222", + Fingerprint: "sha256:" + strings.Repeat("ab", 32), Signer: signer, Secret: "a-one-time-secret", Adopted: true, + Tunnel: &TokenTunnel{Key: "dGhlLW1hY2hpbmVzLWtleQ==", Address: "10.0.0.7/24", Range: "10.0.0.0/24", + HubKey: "dGhlLWh1YnMta2V5", HubEndpoint: "198.51.100.1:51820"}} + got, err := ParseToken(" " + controllerToken + "\n") + if err != nil || !reflect.DeepEqual(got, want) || len(got.SignerKey()) != 32 { + t.Fatalf("parsed %+v, %v", got, err) + } + if again, err := got.Encode(); err != nil || again != controllerToken { + t.Errorf("encoded again: %q, %v", again, err) + } + for _, c := range []struct { + cut func(*Token) + expect string + }{ + {func(t *Token) { t.Broker = "" }, "broker's address"}, + {func(t *Token) { t.Fingerprint = "" }, "fingerprint"}, + {func(t *Token) { t.Signer = t.Signer[:5] }, "signing key"}, + {func(t *Token) { t.Secret = "" }, "one-time secret"}, + {func(t *Token) { t.Tunnel = &TokenTunnel{Key: "k"} }, "where the hub's tunnel is dialled"}, + } { + cut := want + cut.Tunnel = &TokenTunnel{} + *cut.Tunnel = *want.Tunnel + c.cut(&cut) + if _, err := cut.Encode(); err == nil || !strings.Contains(err.Error(), c.expect) { + t.Errorf("a token without %s: %v", c.expect, err) + } + } + for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8", "eyJ2Ijo5OX0"} { + if _, err := ParseToken(bad); err == nil { + t.Errorf("%q parsed as a token", bad) + } + } +} From d7a00e1f26540a2d8c2afdbf20ca2c635e206cf1 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:17:08 +0100 Subject: [PATCH 04/15] Add the vault's enrolment verbs to its seat, so mesh-enrolment asks the vault to mint and sign and holds nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0587 §3 and §5 (changing ADR 0561 §5) have the vault make the token and its hash (enrolment-token), answer it sealed to the operator key for tokens to relay (sealed, §8), drop both at every end (drop), and mint, answer again and retire a joining node's account (node-account), with mesh-enrolment their only caller; sign names mesh-enrolment for a joining node's certificate (with the serving key it presented, since the node has no record yet) and for a redeem answer. Both verbs optional until the vault that runs serves them. Their answers' shapes are in go/delivery. Tested as rows of TestTheVaultsSeatPromisesItsIntakeVerbs, no new test function. (issue 774, 774/S37) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/seats.go | 34 +++++++++++++++++++++++++++------ go/catalogue/vault_seat_test.go | 6 +++++- go/delivery/enrolment.go | 28 +++++++++++++++++++++++++++ go/delivery/signers.go | 4 ++-- 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 79b48f0..0180575 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -233,13 +233,35 @@ var defaultSeats = append([]Seat{ "controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.", Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})}, {Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " + - "kind node-certificate signs the certificate of the calling machine's internal name for the serving key " + - "its record states, built from bus state alone and asked by that machine's node-engine only; kind " + - "enrolment signs an enrolment reply under its own context, for a caller the adoption record names. " + - "Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.", + "kind node-certificate signs the certificate of a machine's internal name, asked by that machine's " + + "node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for " + + "a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a " + + "redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature " + + "and the signer's fingerprint, and says signed; never a key.", Input: schema(map[string]string{"kind": "node-certificate or enrolment", - "node": "the machine, for a node certificate: the caller's own", - "reply": "the enrolment reply's bytes in base64, for an enrolment"}, []string{"kind"})}, + "node": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols", + "serving_key": "from mesh-enrolment: the serving key's public half the enrolling node presented", + "reply": "the redeem answer's bytes in base64, for an enrolment"}, []string{"kind"})}, + // Enrolment (novox/hq ADR 0561 §5, ADR 0587 §3, §5; issue 774): mesh-enrolment is the only caller the vault's + // manifest names for these two. + {Name: "enrolment-token", Optional: true, Description: "Make a one-time token for a node mesh-enrolment " + + "issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash " + + "written as the bus user enrol.. Answers only what the joining machine must trust (the bus's " + + "address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " + + "sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " + + "dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write.", + Input: schema(map[string]string{"node": "the node the token is for", + "expires": "when the token expires (RFC 3339), as the enrolment's key says", + "sealed": "\"true\": answer the token sealed to the operator key", + "drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop")}, + {Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node., sealed " + + "to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " + + "account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " + + "node's node-logins key naming node.. Asked by mesh-enrolment alone. Never the value in the clear.", + Input: schema(map[string]string{"node": "the joining node", + "sealing_key": "the sealing key's public half the node presented, which the account is sealed to", + "keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)", + "retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")}, }}, // The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets // mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10). diff --git a/go/catalogue/vault_seat_test.go b/go/catalogue/vault_seat_test.go index 77630ee..7fd3265 100644 --- a/go/catalogue/vault_seat_test.go +++ b/go/catalogue/vault_seat_test.go @@ -65,6 +65,10 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { "secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"}, "issued": {"provision", "provider", "module", "call", "sealed", "holders"}, "rotate": {"node", "module", "name", "provider", "provision", "by"}, + // 774/S37/AC1: the enrolment verbs mesh-enrolment asks (ADR 0587 §3, §5). + "enrolment-token": {"node", "expires", "sealed", "drop"}, + "node-account": {"node", "sealing_key", "keys_digest", "retire"}, + "sign": {"kind", "node", "serving_key", "reply"}, } for name, args := range want { var verb *Verb @@ -105,7 +109,7 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { } serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}, Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh, - Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}} + Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account"}}}} if err := CanHold(serving, seat); err != nil { t.Fatalf("the vault serving its verbs was refused: %v", err) } diff --git a/go/delivery/enrolment.go b/go/delivery/enrolment.go index de8afad..0a07c7a 100644 --- a/go/delivery/enrolment.go +++ b/go/delivery/enrolment.go @@ -238,3 +238,31 @@ func (a RedeemAnswer) SignedBytes() []byte { raw, _ := json.Marshal(a) return raw } + +// **The vault's enrolment verbs** (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone. +const ( + EnrolmentTokenVerb = "enrolment-token" + NodeAccountVerb = "node-account" +) + +// EnrolmentTokenAnswer is what enrolment-token answers: what the joining machine must trust (the bus's address, its +// certificate's fingerprint, the vault's public signing key in base64), never the token; Sealed holds the token sealed +// to the operator key when asked with sealed, relayed by tokens as it is; Dropped says a drop left neither the token +// nor its hash. +type EnrolmentTokenAnswer struct { + Node string `json:"node"` + Broker string `json:"broker,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Signer string `json:"signer,omitempty"` + Sealed string `json:"sealed,omitempty"` + Dropped bool `json:"dropped,omitempty"` +} + +// NodeAccountAnswer is what node-account answers: the login minted, the account sealed to the sealing key the node +// presented (base64), never in the clear; Retired says a retire left no account. +type NodeAccountAnswer struct { + Node string `json:"node"` + Login string `json:"login,omitempty"` + Account string `json:"account,omitempty"` + Retired bool `json:"retired,omitempty"` +} diff --git a/go/delivery/signers.go b/go/delivery/signers.go index 811ea13..d98f430 100644 --- a/go/delivery/signers.go +++ b/go/delivery/signers.go @@ -48,8 +48,8 @@ const ( // KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone. KindNodeCertificate = "node-certificate" - // KindEnrolment is an enrolment reply, signed under its own context; no caller is named until adoption's record - // (ADR 0561 §5). + // KindEnrolment is a redeem answer (RedeemAnswer), signed under its own context for mesh-enrolment alone (ADR 0587 + // §3, §6). KindEnrolment = "enrolment" // KindAuthority is the vault's own authority certificate, which the vault signs for itself. KindAuthority = "authority" From 69270a30d5b4211ecb1f10b1e236992d4c33e2f3 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:19:11 +0100 Subject: [PATCH 05/15] Add the vault's rotation verbs and the state node-logins, so a node's account rotates over two logins on one contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0583 §1 (insight 2026-11-01) keeps which login a node uses in the vault's state node-logins, one key per node: the login in use, the one rotating and the stage (begun, ready, confirmed), with the list revision that held both. §2, §3 and §5 give the vault's seat rotate-node (with the recorded hand act's id), node-account-confirm (the caller is the proof) and abandon, optional until the vault serves them; the events rotation-begun, node-account-ready, rotation-done and the engine's finding node-account-fallback are named beside them. The codec refuses a stage without a rotation and a rotation to anything but the node's other login; an empty rotating reads as none. Tested as rows of TestNodeRecord and TestTheVaultsSeatPromisesItsIntakeVerbs, no new test function. (issue 774, 774/S38) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/seats.go | 17 +++++ go/catalogue/vault_seat_test.go | 7 +- go/delivery/node_logins.go | 130 ++++++++++++++++++++++++++++++++ go/delivery/noderecord_test.go | 35 +++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 go/delivery/node_logins.go diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 0180575..e0da9b7 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -262,6 +262,23 @@ var defaultSeats = append([]Seat{ "sealing_key": "the sealing key's public half the node presented, which the account is sealed to", "keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)", "retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")}, + // A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus + // node last; the state node-logins says where each node stands. + {Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " + + "answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " + + "its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " + + "rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " + + "node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.", + Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence", + "act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})}, + {Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " + + "asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " + + "The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " + + "without it.", + Input: schema(map[string]string{}, nil)}, + {Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " + + "the new login and its value dropped, the old one kept; a node that switched switches back first.", + Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})}, }}, // The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets // mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10). diff --git a/go/catalogue/vault_seat_test.go b/go/catalogue/vault_seat_test.go index 7fd3265..28cd326 100644 --- a/go/catalogue/vault_seat_test.go +++ b/go/catalogue/vault_seat_test.go @@ -69,6 +69,10 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { "enrolment-token": {"node", "expires", "sealed", "drop"}, "node-account": {"node", "sealing_key", "keys_digest", "retire"}, "sign": {"kind", "node", "serving_key", "reply"}, + // 774/S38: the rotation verbs (ADR 0583 §2, §3, §5). + "rotate-node": {"node", "why", "act"}, + "node-account-confirm": {}, + "abandon": {"node"}, } for name, args := range want { var verb *Verb @@ -109,7 +113,8 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { } serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}, Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh, - Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account"}}}} + Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account", + "rotate-node", "node-account-confirm", "abandon"}}}} if err := CanHold(serving, seat); err != nil { t.Fatalf("the vault serving its verbs was refused: %v", err) } diff --git a/go/delivery/node_logins.go b/go/delivery/node_logins.go new file mode 100644 index 0000000..d7a6517 --- /dev/null +++ b/go/delivery/node_logins.go @@ -0,0 +1,130 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// **A node's two logins** (novox/hq ADR 0583, issue 774). A node's bus account rotates between the login `node.` and +// `node.--b`, each with the node's permissions and its inbox `_INBOX.node.` unchanged (§1). Which one a node uses +// is the vault's state `node-logins`, one key per node (§1, insight 2026-11-01), written by the vault alone: the login +// in use, the login rotating if any, and the rotation's stage. Each node-engine reads its own node's key alone; the +// grants part reads them all and composes both logins while a node rotates (§2). The key lives while the node does: +// its rotating part is cleared at rotation-done or abandon, and the key deleted when the node leaves placement. +const ( + NodeLoginsState = "node-logins" + NodeLoginsBucket = VaultModule + "_" + NodeLoginsState + NodeLoginsRead = VaultModule + "." + NodeLoginsState + + // The stages of a rotation (§2-§4): begun when the vault made the other login's value and wrote it rotating; ready + // when the bus node's have shows a list holding both, said node-account-ready; confirmed when the engine called + // node-account-confirm as the new login. Done (rotation-done) is no stage: the rotating part is cleared. + RotationBegun = "begun" + RotationReady = "ready" + RotationConfirmed = "confirmed" + + // The vault's rotation verbs on its seat (§2, §3, §5). + RotateNodeVerb = "rotate-node" + NodeAccountConfirmVerb = "node-account-confirm" + AbandonVerb = "abandon" + + // The vault's events of a rotation, said as its own (§2, §4), each carrying the node's NodeLogin. + RotationBegunEvent = "rotation-begun" + NodeAccountReadyEvent = "node-account-ready" + RotationDoneEvent = "rotation-done" + + // NodeAccountFallbackFinding is what a node-engine says when it fell back to its old login after repeated + // authentication failures with the new one, before rotation-done; the watchman raises it (§6). + NodeAccountFallbackFinding = "node-account-fallback" + + nodeLoginPrefix = "node." + // SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114). + SecondLoginSuffix = "--b" +) + +// RotationEventSubject is where the vault says event of a rotation. +func RotationEventSubject(event string) string { return "mesh.mod." + VaultModule + ".event." + event } + +// NodeLogin is one key of `node-logins`, the node's name: the login in use, the login rotating to (empty when none, and +// then no stage), the rotation's stage, since when the key says what it says, and the user list's revision that held +// both logins, from ready on. +type NodeLogin struct { + V int `json:"v"` + Node string `json:"node"` + Login string `json:"login"` + Rotating string `json:"rotating,omitempty"` + Stage string `json:"stage,omitempty"` + Since time.Time `json:"since"` + ListRevision uint64 `json:"list-revision,omitempty"` +} + +// FirstLogin is a node's first login, `node.`, the one every node had before rotation; SecondLogin its other. +func FirstLogin(node string) string { return nodeLoginPrefix + node } +func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix } + +// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second. +func OtherLogin(node, login string) string { + if login == SecondLogin(node) { + return FirstLogin(node) + } + return SecondLogin(node) +} + +// LoginNode is the node a node login names, either of its two, and whether login is one. +func LoginNode(login string) (string, bool) { + rest, ok := strings.CutPrefix(login, nodeLoginPrefix) + rest = strings.TrimSuffix(rest, SecondLoginSuffix) + return rest, ok && checkName("node", rest) == nil +} + +// Rotates says the node is rotating: a login rotating is named. +func (l NodeLogin) Rotates() bool { return l.Rotating != "" } + +func (l NodeLogin) check() error { + if err := checkName("node", l.Node); err != nil { + return err + } + if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) { + return fmt.Errorf("delivery: the node %s logs in as %q, neither of its two logins", l.Node, l.Login) + } + if !l.Rotates() { + if l.Stage != "" || l.ListRevision != 0 { + return fmt.Errorf("delivery: the node %s rotates to nothing and names stage %q", l.Node, l.Stage) + } + return nil + } + if l.Rotating != OtherLogin(l.Node, l.Login) { + return fmt.Errorf("delivery: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating) + } + switch l.Stage { + case RotationBegun, RotationReady, RotationConfirmed: + default: + return fmt.Errorf("delivery: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage) + } + return nil +} + +// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses. +func EncodeNodeLogin(l NodeLogin) ([]byte, error) { + if err := l.check(); err != nil { + return nil, err + } + l.V = Version + return json.Marshal(l) +} + +// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion), a login that is not +// one of the node's two, a rotation to anything but the other, and a stage without a rotation or an unknown one. An +// empty rotating reads as none. +func DecodeNodeLogin(data []byte) (NodeLogin, error) { + var l NodeLogin + if err := decode(data, &l); err != nil { + return NodeLogin{}, err + } + if err := l.check(); err != nil { + return NodeLogin{}, err + } + return l, nil +} diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index 0a39b1a..a80b3c8 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -122,6 +122,41 @@ func TestNodeRecord(t *testing.T) { t.Errorf("a life of %q: taken", life) } } + // 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a + // rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1). + for _, l := range []NodeLogin{ + {Node: "g14", Login: "node.g14", Since: issued}, + {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41}, + {Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued}, + } { + raw, err := EncodeNodeLogin(l) + if err != nil { + t.Fatal(err) + } + back, err := DecodeNodeLogin(raw) + l.V = Version + if err != nil || !reflect.DeepEqual(back, l) { + t.Errorf("node login round trip: %+v, %v", back, err) + } + } + if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() { + t.Errorf("an empty rotating: %+v, %v", l, err) + } + for name, l := range map[string]NodeLogin{ + "a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun}, + "another node's login": {Node: "g14", Login: "node.ace"}, + "a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun}, + "a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"}, + "a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"}, + } { + if _, err := EncodeNodeLogin(l); err == nil { + t.Errorf("node login with %s: taken", name) + } + } + if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" { + t.Errorf("the second login's node: %q %v", node, ok) + } + walk(reflect.TypeOf(NodeLogin{}), "NodeLogin") walk(reflect.TypeOf(Enrolment{}), "Enrolment") walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress") walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry") From 8c2ce57eeed5f3b18a81740b09a8ffacb7c70474 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:21:05 +0100 Subject: [PATCH 06/15] Compose a node's two logins from the vault's node-logins, so a rotation can switch with both loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0583 §1: a node's account rotates between node. and node.--b, each with the node's permissions and its inbox _INBOX.node.. Records gains NodeLogins (each node's key of node-logins; the context file's NodeRotating, renamed because a node that rotated and rotates no more logs in as its second login alone), and Users composes one node user per login: the one in use and, while it rotates, the other. Each login calls in its own name, which is how the vault tells the login that confirms (§3). While the vault serves rotate-node, each node reads its own node's key of node-logins alone and may call node-account-confirm in its own name (§1 insight); the grants part is the state's one named reader. With no rotation every list is the controller's golden one, unchanged. Tested as a subtest of TestTheCopiedComposerComposesAsTheControllers, no new test function. (issue 774, 774/S39) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buscompose/buscompose_test.go | 71 ++++++++++++++++++++++++++++++++ go/buscompose/nats.go | 46 ++++++++++++++++++++- go/buscompose/userlist_switch.go | 5 ++- go/buscompose/users.go | 40 +++++++++++++++--- 4 files changed, 155 insertions(+), 7 deletions(-) diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 913a30a..6bf897b 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -3,6 +3,7 @@ package buscompose import ( "encoding/json" "errors" + "maps" "os" "path/filepath" "reflect" @@ -419,6 +420,76 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { } } }) + // 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one + // inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list + // is the one above (AC2: the controller's golden lists, unchanged). + t.Run("a node rotating has two logins of one node", func(t *testing.T) { + vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}} + since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) + users, err := Users(Records{Nodes: []string{"ace", "g14"}, + Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}}, + NodeLogins: map[string]delivery.NodeLogin{ + "g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since}, + // A node that rotated before and rotates no more logs in as its second login alone. + "ace": {Node: "ace", Login: "node.ace--b", Since: since}}}) + if err != nil { + t.Fatal(err) + } + perms := map[string]Permissions{} + for _, u := range users { + if u.Kind == KindNode { + p, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + perms[u.Username()] = p + } + } + if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil { + t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms))) + } + // Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3). + own := func(p Permissions, login string) Permissions { + swap := func(in []string) []string { + out := make([]string, len(in)) + for i, s := range in { + out[i] = strings.ReplaceAll(s, CallerToken(login), "") + } + return out + } + return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe), + AllowResponses: p.AllowResponses} + } + if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) { + t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b) + } + for login, p := range perms { + node, _ := delivery.LoginNode(login) + if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node { + t.Errorf("%s's inbox: %v", login, p.Subscribe) + } + } + // AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name. + get := func(key string) string { + return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key + } + confirm := func(login string) string { + return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login) + } + for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false, + confirm("node.g14--b"): true, confirm("node.g14"): false} { + if MayPublish(perms["node.g14--b"], subject) != want { + t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want) + } + } + if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) { + t.Error("a node reads node-logins while the vault does not rotate") + } + // And nothing but the grants part names node-logins among its reads. + if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) { + t.Error("node-logins' named readers") + } + }) t.Run("the controller's golden server configuration", func(t *testing.T) { seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} got, err := Compose( diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 8b56c96..7fea9e0 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -253,6 +253,16 @@ type Principal struct { // stream and name. Created only, never updated or deleted. CreatesConsumers []ModuleConsumer + // Login is a node principal's login when it is not `node.`: its second login, `node.--b`, composed beside or + // in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted + // the node's permissions and the node's inbox `_INBOX.node.`; what it calls, it calls in its own name, so the + // vault tells the login that confirms a rotation from the one it replaces (§3). + Login string + // RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins, + // and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight, + // §3). Set while the vault's holder serves rotate-node. + RotatesItsLogin bool + // PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal // and never appears here: this file is written to a node's disk and read by a server, and a // secret that can be read from a configuration file is a secret with a wider blast radius @@ -380,6 +390,23 @@ var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"} // ADR 0550 §2); granted as Principal.AsksTheVaultToSign says. var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb} +// VaultRotateNode is the vault's verb that begins a node's account rotation; while its holder serves it, every node +// principal reads its own node's key of node-logins (novox/hq ADR 0583 §1, §2). VaultNodeAccountConfirm is the verb a +// node-engine confirms a switch with, in its new login's name (§3). +var ( + VaultRotateNode = SeatVerb{Seat: "mesh-vault", Verb: delivery.RotateNodeVerb} + VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb} +) + +// NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched, +// and no other (novox/hq ADR 0583 §1 insight). +func NodeLoginsReads(node string) []string { + if !safeSubject.MatchString(node) { + return nil + } + return readGrants(delivery.NodeLoginsBucket, node) +} + // VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant // chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the // controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release @@ -528,6 +555,9 @@ func (p Principal) Username() string { // never the module's own name alone, which is a different account with a different secret. return p.Node + "." + p.Module + "." + p.Process case KindNode: + if p.Login != "" { + return p.Login + } return "node." + p.Node case KindController: return "controller" @@ -587,7 +617,14 @@ func AskAccessActSubject(node string) string { return "mesh.node." + node + ".as // inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25 // §4): with one account, inbox privacy is the permission list or it is nothing, so each user's // inbox is derived from its own identity and its permissions name that prefix and no other. -func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" } +// +// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as. +func (p Principal) inbox() string { + if p.Kind == KindNode { + return "_INBOX.node." + p.Node + ".>" + } + return "_INBOX." + p.Username() + ".>" +} // Permissions is what a principal may publish and subscribe, and whether it may answer. type Permissions struct { @@ -1003,6 +1040,13 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, readGrants(delivery.SignersBucket, "")...) pub = append(pub, readGrants(LeaseBucket, leaseKey)...) } + // And, while the vault rotates node accounts, its own node's key of node-logins, read and watched, and no other + // key (novox/hq ADR 0583 §1 insight): the login it dials as; and the confirmation of a switch in its own name (§3). + if p.RotatesItsLogin { + pub = append(pub, NodeLoginsReads(p.Node)...) + pub = append(pub, "mesh.seat."+VaultNodeAccountConfirm.Seat+"."+CallKind+"."+VaultNodeAccountConfirm.Verb+"."+ + CallerToken(p.Username())) + } // And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3). ep, es := engineGrants(p) pub = append(pub, ep...) diff --git a/go/buscompose/userlist_switch.go b/go/buscompose/userlist_switch.go index 5f9d7b1..15f3342 100644 --- a/go/buscompose/userlist_switch.go +++ b/go/buscompose/userlist_switch.go @@ -104,7 +104,10 @@ var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsA ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {}, // ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys // (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here. - delivery.EngineDataRead: {health.DiskLoadModule}} + delivery.EngineDataRead: {health.DiskLoadModule}, + // ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its + // machine principal (NodeLoginsReads), never through `reads`. + delivery.NodeLoginsRead: {GrantsModule}} // ReaderMay says whether module may read the state read (`.`) by the named readers: always, for a state // that names none. diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 720b030..ebd11dd 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -2,10 +2,12 @@ package buscompose import ( "fmt" - "git.novox.be/novox/mesh-sdk/go/announce" "slices" "sort" "strings" + + "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Every user the composed file should contain, derived from what the mesh knows. @@ -145,6 +147,9 @@ type Records struct { // grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account. // Nil where its manifest was not read. Engine *Declared + // NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one + // rotating, each composed as a user of the node's permissions. A node absent logs in as `node.` alone. + NodeLogins map[string]delivery.NodeLogin // Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the // controller keeps it; a module absent has announced nothing yet. Nil where none was read. Announced map[string]announce.States @@ -180,10 +185,14 @@ func Users(r Records) ([]Principal, error) { } checks = append(checks, d.Checks...) } - out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks, - MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), - AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), - FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)}) + // **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too, + // each with the node's permissions. + for _, login := range nodeLoginsOf(r, node) { + out = append(out, Principal{Kind: KindNode, Node: node, Login: login, WitnessesController: witness, Checks: checks, + MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), + AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), + FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)}) + } // **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one // per machine that runs it, delivered to the module running it. for _, d := range r.Assigned[node] { @@ -385,6 +394,27 @@ func asksSeatOf(r Records) []Seat { return nil } +// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.`): the one its +// node-logins key says is in use and the one rotating, in that order; `node.` alone when the key is absent or does +// not hold (a key naming another node's login is no login of this node's). +func nodeLoginsOf(r Records, node string) []string { + l, ok := r.NodeLogins[node] + if !ok || l.Node != node || (l.Login != delivery.FirstLogin(node) && l.Login != delivery.SecondLogin(node)) { + return []string{""} + } + login := func(name string) string { + if name == delivery.FirstLogin(node) { + return "" + } + return name + } + out := []string{login(l.Login)} + if l.Rotates() && l.Rotating == delivery.OtherLogin(node, l.Login) { + out = append(out, login(l.Rotating)) + } + return out +} + // VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name. const VaultSeat = "mesh-vault" From 916983021014f1488d41b7d35c9f703191b946ee Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:22:19 +0100 Subject: [PATCH 07/15] Grant an enrolling node's user the seat's redeem alone, so from the lease write no token reaches the controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0587 §4: the grants part composes enrol. granted only to call mesh-enrolment.redeem and to hear its own inbox; §6: from the lease write the controller's enrolment subject is not served. The enrolment principal now calls redeem in its own name while the seat's holder serves it, and keeps mesh.control.enrol only until Records says the lease is handed away (a field the grants part sets and the controller's composer never does). A user left able to enrol nowhere is refused by name rather than composed empty. Golden lists unchanged. Tested as a subtest of TestTheCopiedComposerComposesAsTheControllers, no new test function. (issue 774, 774/S40) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buscompose/buscompose_test.go | 38 ++++++++++++++++++++++++++++++++ go/buscompose/nats.go | 32 ++++++++++++++++++++++++++- go/buscompose/users.go | 10 +++++++-- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 6bf897b..b5a4fd2 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -490,6 +490,44 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Error("node-logins' named readers") } }) + // 774/S40/AC1 (ADR 0587 §4, §6): from the lease write an enrolling node's user may publish the redeem call in its + // own name and nothing else, and subscribe its own inbox alone; before it, the controller's subject beside. + t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) { + holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}} + for _, handed := range []bool{true, false} { + users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"}, LeaseHandedAway: handed, + Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}}) + if err != nil { + t.Fatal(err) + } + var lab *Principal + for i := range users { + if users[i].Username() == "enrol.lab" { + lab = &users[i] + } + } + if lab == nil { + t.Fatal("no user enrol.lab") + } + perms, err := PermissionsFor(*lab) + if err != nil { + t.Fatal(err) + } + want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"} + if !handed { + want = []string{"mesh.control.enrol", "mesh.seat.mesh-enrolment.call.redeem.enrol~lab"} + } + if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) || + perms.AllowResponses { + t.Errorf("handed %v: enrol.lab publishes %v, subscribes %v, answers %v", handed, perms.Publish, perms.Subscribe, + perms.AllowResponses) + } + } + // No holder serving redeem and the lease handed: the user may enrol nowhere, refused by name. + if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab", LeaseHandedAway: true}); err == nil { + t.Error("an enrolment user that may enrol nowhere was composed") + } + }) t.Run("the controller's golden server configuration", func(t *testing.T) { seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} got, err := Compose( diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 7fea9e0..461b36d 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -22,6 +22,7 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -263,6 +264,12 @@ type Principal struct { // §3). Set while the vault's holder serves rotate-node. RotatesItsLogin bool + // Redeems is an enrolment principal that may call the seat mesh-enrolment's redeem in its own name, and nothing else + // of it (novox/hq ADR 0587 §4): set while the seat's holder serves redeem. LeaseHandedAway drops the controller's + // enrolment subject from it, which is served no more from the lease write (§6). + Redeems bool + LeaseHandedAway bool + // PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal // and never appears here: this file is written to a node's disk and read by a server, and a // secret that can be read from a configuration file is a secret with a wider blast radius @@ -398,6 +405,16 @@ var ( VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb} ) +// EnrolmentRedeem is the seat mesh-enrolment's verb a joining node-engine calls as enrol. (novox/hq ADR 0587 §4, +// §6). +var EnrolmentRedeem = SeatVerb{Seat: catalogue.EnrolmentSeat, Verb: catalogue.EnrolmentVerbRedeem} + +// EnrolmentRedeemCall is the one subject an enrolment user may publish once it enrols through the seat: redeem, in its +// own name. +func EnrolmentRedeemCall(p Principal) string { + return "mesh.seat." + EnrolmentRedeem.Seat + "." + CallKind + "." + EnrolmentRedeem.Verb + "." + CallerToken(p.Username()) +} + // NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched, // and no other (novox/hq ADR 0583 §1 insight). func NodeLoginsReads(node string) []string { @@ -926,7 +943,20 @@ func PermissionsFor(p Principal) (Permissions, error) { "enrolment: a token is issued for a node record, and that record's name is " + "the token's id") } - pub = []string{"mesh.control.enrol"} + // **From the lease write a node enrols through the seat mesh-enrolment alone** (novox/hq ADR 0587 §4, §6): its + // user calls redeem in its own name, while the seat's holder serves it, and the controller's enrolment subject is + // kept only while the controller holds the lease. + pub = nil + if !p.LeaseHandedAway { + pub = append(pub, "mesh.control.enrol") + } + if p.Redeems { + pub = append(pub, EnrolmentRedeemCall(p)) + } + if len(pub) == 0 { + return Permissions{}, fmt.Errorf("the enrolment user of %s may enrol nowhere: the lease is handed away and "+ + "no holder of %s serves %s", p.Node, EnrolmentRedeem.Seat, EnrolmentRedeem.Verb) + } sub = []string{p.inbox()} case KindNode: diff --git a/go/buscompose/users.go b/go/buscompose/users.go index ebd11dd..6802033 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -131,7 +131,8 @@ type Records struct { Assigned map[string][]Declared // Enrolling is every node with a live token — one enrolment user each, because the inbox an // answer goes to is scoped to the token and a shared one is one machine reading another's - // sealed credentials (design 25 §6). + // sealed credentials (design 25 §6). The grants part fills it from the seat mesh-enrolment's keys at live or + // redeeming (novox/hq ADR 0587 §4), the controller from its store. Enrolling []string // People is each person's name against the tools they may invoke, `*` for an administrator. People map[string][]string @@ -147,6 +148,10 @@ type Records struct { // grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account. // Nil where its manifest was not read. Engine *Declared + // LeaseHandedAway says the controller's lease names a holder that is no controller (ADR 0582 §3), as the grants part + // reads it: an enrolment user then enrols through the seat mesh-enrolment alone (novox/hq ADR 0587 §6). The + // controller's composer never sets it. + LeaseHandedAway bool // NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one // rotating, each composed as a user of the node's permissions. A node absent logs in as `node.` alone. NodeLogins map[string]delivery.NodeLogin @@ -284,7 +289,8 @@ func Users(r Records) ([]Principal, error) { } } for _, node := range sortedCopy(r.Enrolling) { - out = append(out, Principal{Kind: KindEnrolment, Node: node}) + out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: servesVerb(r, EnrolmentRedeem), + LeaseHandedAway: r.LeaseHandedAway}) } for _, person := range sortedNames(r.People) { p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]} From 362dddae401c36dc68a626320e90409b2a15fab8 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:24:07 +0100 Subject: [PATCH 08/15] Compose mesh-enrolment's grants: its bucket written by it alone, the vault's enrolment verbs called by it alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0587 §1-§3 (changing ADR 0561 §5) make mesh-enrolment the only caller of the vault's enrolment-token and node-account and a caller of sign, and §2 its bucket enrolments read by it and by the grants part as a named reader. The holder calls the vault through its manifest's invokes, in its own name, as any caller does; while the vault serves either enrolment verb every other principal is denied both, however wide its grant (a runtime's or an agent's `*`), as ControllerOnly denies the desk's prompt. The writers table gains the open enrolments (mesh-enrolment) and the node-logins (the vault), each one writer; NamedReaders names the grants part for enrolments. Golden lists unchanged. Tested as a subtest of TestTheCopiedComposerComposesAsTheControllers, no new test function. (issue 774, 774/S41) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buscompose/buscompose_test.go | 42 ++++++++++++++++++++++++++++++++ go/buscompose/nats.go | 28 +++++++++++++++++++++ go/buscompose/userlist_switch.go | 4 ++- go/buscompose/users.go | 7 ++++++ go/buscompose/writers.go | 23 +++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index b5a4fd2..b896812 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -528,6 +528,48 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Error("an enrolment user that may enrol nowhere was composed") } }) + // 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the + // vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two, + // however wide its grant; and its bucket is read by the grants part alone besides it. + t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) { + vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}} + holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}} + calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"} + users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": { + {Module: "mesh-vault", Holds: []Seat{vault}}, + // One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner. + {Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls, + State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}}, + {Module: "shop", Invokes: calls[:2]}, + {Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}}) + if err != nil { + t.Fatal(err) + } + call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) } + for _, u := range users { + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + name := u.Username() + for _, verb := range []string{"enrolment-token", "node-account"} { + enrols := name == "ace.mesh-enrolment" + if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) { + t.Errorf("%s may call %s: %v", name, verb, !enrols) + } + } + if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) || + !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) { + t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish) + } + if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") { + t.Errorf("%s writes the enrolments", name) + } + } + if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) { + t.Error("the enrolments' named readers") + } + }) t.Run("the controller's golden server configuration", func(t *testing.T) { seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} got, err := Compose( diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 461b36d..ccdb259 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -270,6 +270,10 @@ type Principal struct { Redeems bool LeaseHandedAway bool + // VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but + // the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1). + VaultEnrols bool + // PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal // and never appears here: this file is written to a node's disk and read by a server, and a // secret that can be read from a configuration file is a secret with a wider blast radius @@ -320,6 +324,22 @@ func ControllerOnly() []string { return out } +// VerbsTheEnrolmentAsksTheVault are the vault's verbs the seat mesh-enrolment's holder alone calls (novox/hq ADR 0587 +// §1, §3; ADR 0561 §5): a token and its hash made and dropped, a joining node's account minted and retired. +var VerbsTheEnrolmentAsksTheVault = []SeatVerb{{Seat: VaultSeat, Verb: delivery.EnrolmentTokenVerb}, + {Seat: VaultSeat, Verb: delivery.NodeAccountVerb}} + +// EnrolmentOnly are the subjects the holder of mesh-enrolment alone may publish, however wide another's grant: the +// vault's two enrolment verbs, by the tool's and the caller-named shape (Principal.VaultEnrols denies them). +func EnrolmentOnly() []string { + var out []string + for _, v := range VerbsTheEnrolmentAsksTheVault { + out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb, "mesh.seat."+v.Seat+".tool."+v.Verb+".>", + "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".>") + } + return out +} + // MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does. func MayPublish(perms Permissions, subject string) bool { for _, d := range perms.PublishDeny { @@ -1514,6 +1534,14 @@ func PermissionsFor(p Principal) (Permissions, error) { } } } + // And the vault's enrolment verbs to every principal but mesh-enrolment's (novox/hq ADR 0587 §1). + if p.VaultEnrols && !((p.Kind == KindModule || p.Kind == KindProcess) && p.Module == delivery.EnrolmentModule) { + for _, only := range EnrolmentOnly() { + if slices.ContainsFunc(pub, func(a string) bool { return SubjectsOverlap(a, only) }) { + deny = append(deny, only) + } + } + } return Permissions{ Publish: pub, PublishDeny: deny, diff --git a/go/buscompose/userlist_switch.go b/go/buscompose/userlist_switch.go index 15f3342..5eee315 100644 --- a/go/buscompose/userlist_switch.go +++ b/go/buscompose/userlist_switch.go @@ -107,7 +107,9 @@ var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsA delivery.EngineDataRead: {health.DiskLoadModule}, // ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its // machine principal (NodeLoginsReads), never through `reads`. - delivery.NodeLoginsRead: {GrantsModule}} + delivery.NodeLoginsRead: {GrantsModule}, + // ADR 0587 §2: the open enrolments are read by their holder and, as a named reader, by the grants part. + delivery.EnrolmentsRead: {GrantsModule}} // ReaderMay says whether module may read the state read (`.`) by the named readers: always, for a state // that names none. diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 6802033..2e33efe 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -301,6 +301,13 @@ func Users(r Records) ([]Principal, error) { out = append(out, p) } + // The vault's enrolment verbs are mesh-enrolment's alone, however wide another's grant (novox/hq ADR 0587 §1). + if slices.ContainsFunc(VerbsTheEnrolmentAsksTheVault, func(v SeatVerb) bool { return servesVerb(r, v) }) { + for i := range out { + out[i].VaultEnrols = true + } + } + // The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name. if grants >= 0 { out[grants].CreatesConsumers = AccountConsumers(out) diff --git a/go/buscompose/writers.go b/go/buscompose/writers.go index 39d78f7..fe5db6f 100644 --- a/go/buscompose/writers.go +++ b/go/buscompose/writers.go @@ -96,6 +96,19 @@ func ownModule(p Principal, pattern string) bool { return false } +// moduleNamed is the writer that is module: its own principal, a process of it, or the node tools carrying it. +func moduleNamed(module string) func(Principal, string) bool { + return func(p Principal, _ string) bool { + switch p.Kind { + case KindModule, KindProcess: + return p.Module == module + case KindNodeTools: + return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module }) + } + return false + } +} + // ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the // controller saying what it keeps itself (novox/hq ADR 0495 clause 2): each under its own name alone. func ownAnnouncer(p Principal, pattern string) bool { @@ -218,6 +231,16 @@ var WritersTable = []WriterRow{ Writes: ownModule}, {State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state", Others: "—"}, + // The seat mesh-enrolment's open enrolments (novox/hq ADR 0587 §2, issue 774): its holder alone writes them, each a + // compare-and-set at the revision read; the grants part reads them as a named reader. + {State: "the open enrolments", Writer: "the seat mesh-enrolment's holder, compare-and-set", + KeptIn: "key-value " + delivery.EnrolmentsBucket + ", with per-key expiry", + Others: "the grants part reads them (a named reader) and composes each enrol. user from them", + Subjects: kvOf(delivery.EnrolmentsBucket), Writes: moduleNamed(delivery.EnrolmentModule)}, + // Which login each node uses (novox/hq ADR 0583 §1 insight): the vault alone writes it. + {State: "a node's logins", Writer: "the vault", KeptIn: "key-value " + delivery.NodeLoginsBucket, + Others: "the grants part reads every key, each node's engine its own node's key alone", + Subjects: kvOf(delivery.NodeLoginsBucket), Writes: moduleNamed(delivery.VaultModule)}, {State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest", Others: "the build seat reads"}, // What a consumer gave up on (novox/hq issue 330): copied by the controller from the server's notice, From 90a5fc57977a0e9538d98260f7a6be980da32f57 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:25:40 +0100 Subject: [PATCH 09/15] Let placement take an add-node with its enrolment's warrant, so one Approve lets a machine join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0587 §7: at redeem mesh-enrolment proposes one change, the node with its decided fields (the approved address, never the hub) and the public halves of its keys, carrying the issue's warrant; mesh-placement checks the warrant names this node and address and that the caller is mesh-enrolment, and writes the key with no ask of its own (changing ADR 0567 §2 and §5 for this one change by this one caller). The proposal codec refuses an add-node beside other changes, without a warrant, or whose warrant names another node or address, and a warrant on any other proposal; AddNodeRefusal adds the caller's check for the assigner; LayChanges adds the node and refuses one placed already. Tested as rows of TestPlacement's proposal subtest, no new test function. (issue 774, 774/S42) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/placement_seat.go | 11 +++- go/delivery/placement_proposal.go | 94 ++++++++++++++++++++++++++++++- go/delivery/placement_test.go | 49 ++++++++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) diff --git a/go/catalogue/placement_seat.go b/go/catalogue/placement_seat.go index 7fa69de..615d256 100644 --- a/go/catalogue/placement_seat.go +++ b/go/catalogue/placement_seat.go @@ -31,7 +31,8 @@ func placementVerbs() []Verb { "place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " + "unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " + "(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " + - "layer, mesh-wide-values, values)", + "layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " + + "proposal's one change, with the enrolment's warrant; ADR 0587 §7)", "items": map[string]any{"type": "object", "required": []string{"op", "node"}}} return []Verb{ {Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " + @@ -52,11 +53,17 @@ func placementVerbs() []Verb { "on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " + "hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " + "it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " + - "caller, as the runtime proves it.", + "caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " + + "is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).", Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"}, "properties": map[string]any{ "why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"}, "changes": changes, + // ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose + // Approve is the proposal's; the holder writes it with no ask of its own. + "warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " + + "warrant {node, address, ask, warrant}, naming the change's node and address", + "required": []string{"node", "address", "ask", "warrant"}}, }}}, {Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " + "candidates are deleted and placement-withdrawn carries it whole. With why.", diff --git a/go/delivery/placement_proposal.go b/go/delivery/placement_proposal.go index 66f6cb1..9fbb374 100644 --- a/go/delivery/placement_proposal.go +++ b/go/delivery/placement_proposal.go @@ -65,6 +65,9 @@ const ( OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them OpConfig = "config" // set a module's config placement or values on the node OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller + // OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the + // enrolment that issued its token (novox/hq ADR 0587 §7): mesh-enrolment's alone, the proposal's one change. + OpAddNode = "add-node" ) // The states of an open proposal. @@ -107,6 +110,32 @@ type PlacementProposalChange struct { Values map[string]any `json:"values,omitempty"` // Whole is the node's whole placement (OpSeed). Whole *Placement `json:"whole,omitempty"` + // Decided and Keys are a joining node's (OpAddNode): its decided fields, the address the operator approved among + // them, and the public halves of the keys it presented at redeem. + Decided *NodeDecided `json:"decided,omitempty"` + Keys *JoiningKeys `json:"keys,omitempty"` +} + +// JoiningKeys are the public halves of the keys a joining node presented at redeem (ADR 0587 §6, §7): its identity, +// private network, sealing and serving keys, as Redeem carries them. Public halves alone, never a private key. +type JoiningKeys struct { + Identity string `json:"identity"` + Overlay string `json:"overlay,omitempty"` + Sealing string `json:"sealing"` + Serving string `json:"serving,omitempty"` +} + +// Digest is the keys' digest, as the enrolment's key holds it (KeysDigest). +func (k JoiningKeys) Digest() string { return KeysDigest(k.Identity, k.Overlay, k.Sealing, k.Serving) } + +// AddNodeWarrant is the warrant an add-node carries (ADR 0587 §7): the enrolment's node and address as its issue's ask +// was bound to them, the ask's id, and the warrant's id as the teller wrote it. mesh-placement checks it names the +// change's node and address, and that the caller is mesh-enrolment as the runtime proves it (AddNodeRefusal). +type AddNodeWarrant struct { + Node string `json:"node"` + Address string `json:"address"` + Ask string `json:"ask"` + Warrant string `json:"warrant"` } func (c PlacementProposalChange) check() error { @@ -135,8 +164,19 @@ func (c PlacementProposalChange) check() error { return fmt.Errorf("delivery: the seed of %s names no placement of that node", c.Node) } return c.Whole.check() + case OpAddNode: + if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Identity == "" || c.Keys.Sealing == "" { + return fmt.Errorf("delivery: the add-node of %s names no address or no identity and sealing key", c.Node) + } + if c.Decided.Hub { + return fmt.Errorf("delivery: the add-node of %s makes it the hub, which a joining node never is", c.Node) + } + if c.Module != "" || len(c.Ports)+len(c.Bindings) > 0 || c.Whole != nil { + return fmt.Errorf("delivery: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node) + } + return nil } - return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide or config", c.Op) + return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config or add-node", c.Op) } // LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given; @@ -175,6 +215,13 @@ func LayChanges(current map[string]Placement, changes []PlacementProposalChange) switch c.Op { case OpSeed: p = clonePlacement(*c.Whole) + case OpAddNode: + // A joining node has no key yet (ADR 0587 §7): one that has is no joining node. + if _, has := current[c.Node]; has { + findings = append(findings, fmt.Sprintf("change %d: %s is placed already, so it does not join", i+1, c.Node)) + continue + } + p = Placement{V: Version, Node: c.Node, Decided: *c.Decided, Modules: []PlacedModule{}} case OpPlace: m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings} if at >= 0 { @@ -378,6 +425,9 @@ type PlacementProposal struct { // Again is when `again` asked the holder to judge it now and raise its ask again (§10); the holder does once per // value. Again time.Time `json:"again,omitempty"` + // Warrant is the enrolment's warrant an add-node carries (ADR 0587 §7): its Approve is the issue's, so the holder + // writes it with no ask of its own. Only an add-node carries one. + Warrant *AddNodeWarrant `json:"warrant,omitempty"` // Withdrawn is who withdrew it and why (`withdraw`, §2): the holder then deletes it and its candidates and says // placement-withdrawn, which only the holder's process can say on its stream. Withdrawn string `json:"withdrawn,omitempty"` @@ -419,9 +469,51 @@ func (p PlacementProposal) check() error { if p.Seed && p.ExportDigest == "" { return fmt.Errorf("delivery: the seed %s names no export digest", p.ID) } + return p.checkAddNode() +} + +// checkAddNode refuses an add-node that is not the proposal's one change, carries no warrant, or whose warrant names +// another node or address than the change; and a warrant on any other proposal (ADR 0587 §7). +func (p PlacementProposal) checkAddNode() error { + adds := 0 + for _, c := range p.Changes { + if c.Op == OpAddNode { + adds++ + } + } + if adds == 0 { + if p.Warrant != nil { + return fmt.Errorf("delivery: the proposal %s carries an enrolment's warrant and adds no node", p.ID) + } + return nil + } + if adds != 1 || len(p.Changes) != 1 || p.HandBack || p.Seed { + return fmt.Errorf("delivery: the proposal %s adds a node beside other changes; an add-node is a proposal's one change", p.ID) + } + c, w := p.Changes[0], p.Warrant + if w == nil || w.Ask == "" || w.Warrant == "" { + return fmt.Errorf("delivery: the add-node of %s carries no warrant of its enrolment", c.Node) + } + if w.Node != c.Node || w.Address != c.Decided.Address { + return fmt.Errorf("delivery: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address, + w.Node, w.Address) + } return nil } +// AddNodeRefusal is why the holder of mesh-placement refuses an add-node from caller, the module the runtime proved +// called (ADR 0587 §7), or "": only mesh-enrolment adds a node; what the proposal itself must hold, DecodePlacementProposal +// refuses. +func AddNodeRefusal(p PlacementProposal, caller string) string { + if err := p.checkAddNode(); err != nil { + return err.Error() + } + if p.Warrant != nil && caller != EnrolmentModule { + return fmt.Sprintf("an add-node is mesh-enrolment's alone, and %s proposed it", caller) + } + return "" +} + // EncodePlacementProposal is p as JSON at Version; it refuses what DecodePlacementProposal refuses. func EncodePlacementProposal(p PlacementProposal) ([]byte, error) { if err := p.check(); err != nil { diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index 59d8ae6..fa9b9e7 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -689,6 +689,55 @@ func TestPlacement(t *testing.T) { if ChangesDigest(p) == ChangesDigest(seed) { t.Error("two proposals' changes digest alike") } + // 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one + // change, from mesh-enrolment alone; a warrant naming another node or address is refused. + joining := PlacementProposalChange{Op: OpAddNode, Node: "lab", Decided: &NodeDecided{Address: "10.0.0.7"}, + Keys: &JoiningKeys{Identity: "id", Sealing: "seal"}} + add := PlacementProposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(), + Changes: []PlacementProposalChange{joining}, Base: map[string]uint64{"lab": 0}, State: ProposalAnswering, + Warrant: &AddNodeWarrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}} + if raw, err := EncodePlacementProposal(add); err != nil { + t.Errorf("an add-node: %v", err) + } else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Digest() != KeysDigest("id", "", "seal", "") { + t.Errorf("an add-node round trip: %+v, %v", back, err) + } + if AddNodeRefusal(add, EnrolmentModule) != "" || !strings.Contains(AddNodeRefusal(add, AssignerModule), "mesh-enrolment's alone") { + t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, EnrolmentModule), AddNodeRefusal(add, AssignerModule)) + } + if laid, f := LayChanges(current, add.Changes); len(f) != 0 || laid["lab"].Decided.Address != "10.0.0.7" || len(laid["lab"].Modules) != 0 { + t.Errorf("an add-node laid: %+v, %v", laid, f) + } + if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpAddNode, Node: "g14", Decided: &NodeDecided{Address: "10.0.0.9"}, + Keys: &JoiningKeys{Identity: "id", Sealing: "seal"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") { + t.Errorf("an add-node of a placed node: %v", f) + } + for name, mut := range map[string]func(*PlacementProposal){ + "warrant of another node": func(p *PlacementProposal) { + p.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"} + }, + "warrant of another address": func(p *PlacementProposal) { + p.Warrant = &AddNodeWarrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"} + }, + "no warrant": func(p *PlacementProposal) { p.Warrant = nil }, + "beside another change": func(p *PlacementProposal) { + p.Changes = append(p.Changes, PlacementProposalChange{Op: OpPlace, Node: "lab", Module: "zsh"}) + }, + "the hub": func(p *PlacementProposal) { + c := joining + c.Decided = &NodeDecided{Address: "10.0.0.7", Hub: true} + p.Changes = []PlacementProposalChange{c} + }, + "a warrant on a move": func(p *PlacementProposal) { + p.Changes, p.Base = []PlacementProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, map[string]uint64{"g14": 4} + }, + } { + q := add + q.Changes = append([]PlacementProposalChange(nil), add.Changes...) + mut(&q) + if _, err := EncodePlacementProposal(q); err == nil { + t.Errorf("an add-node with %s: taken", name) + } + } for name, mut := range map[string]func(*PlacementProposal){ "no id": func(p *PlacementProposal) { p.ID = "" }, "no proposer": func(p *PlacementProposal) { p.By = "" }, From 79ad8b539cf6f12c3d2b140997e0ab4435a5fb66 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:56:56 +0100 Subject: [PATCH 10/15] Name the expired enrolment's issued event by its id, and carry a node's login fall back in its health report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 6: enrolment-expired carries the enrolment-issued event's message id (IssuedID), since a bundle's publish answers no stream sequence and ADR 0587 §2 asks only that the event be named; it gains a codec refusing no event named. Blocker 7: NodeHealth gains Fallback (node, the login fallen back from, why, at), from which the watchman raises node-account-fallback (ADR 0583 §6); a fall back naming another node is refused. A token missing its signer now says the vault's signing key, not the retired word. Tested as rows of TestNodeRecord and the control records test; no new test function (1438 unit tests). (issue 774, review round 1) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/control_records_test.go | 8 ++++++ go/delivery/enrolment.go | 41 ++++++++++++++++++++++++++--- go/delivery/nodehealth.go | 20 ++++++++++++-- go/delivery/noderecord_test.go | 16 +++++++++++ go/delivery/token.go | 2 +- 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/go/delivery/control_records_test.go b/go/delivery/control_records_test.go index 419ee62..c98f15f 100644 --- a/go/delivery/control_records_test.go +++ b/go/delivery/control_records_test.go @@ -40,6 +40,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) { {"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` + `"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` + `"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}}, + // A node fallen back to its old login (ADR 0583 §6), which the watchman raises as node-account-fallback. + {"a node's health report, fallen back to its old login", `{"v":1,"node":"anchor","fallback":{"node":"anchor",` + + `"from":"node.anchor--b","why":"the new login's test-dial was refused","at":"2026-11-02T01:00:00Z"},` + + `"at":"2026-11-02T01:00:00Z"}`, &NodeHealth{}}, {"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}}, } { if err := json.Unmarshal([]byte(c.raw), c.into); err != nil { @@ -60,6 +64,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) { if _, err := EncodeNodeHealth(NodeHealth{}); err == nil { t.Error("a report naming no node was encoded") } + if _, err := EncodeNodeHealth(NodeHealth{Node: "anchor", Fallback: &LoginFallback{Node: "ace", From: "node.anchor--b", + Why: "refused", At: time.Now()}}); err == nil { + t.Error("a fall back naming another node was encoded") + } // ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node. held := SeatHoldersOf(map[string]Placement{ "ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}}, diff --git a/go/delivery/enrolment.go b/go/delivery/enrolment.go index 0a07c7a..82166e0 100644 --- a/go/delivery/enrolment.go +++ b/go/delivery/enrolment.go @@ -176,11 +176,44 @@ func TokenLife(expires string) (time.Duration, error) { } // EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the -// key's content, by its sequence on the events' stream, since the bus deleted the key before the holder heard of it. +// key's content, named by its message id (the bundle's publish answers no stream sequence, and §2 asks only that the +// event be named), since the bus deleted the key before the holder heard of it. type EnrolmentExpiry struct { - V int `json:"v"` - Node string `json:"node"` - Issued uint64 `json:"issued-event"` + V int `json:"v"` + Node string `json:"node"` + IssuedID string `json:"issued-event"` +} + +func (x EnrolmentExpiry) check() error { + if err := checkName("node", x.Node); err != nil { + return err + } + if strings.TrimSpace(x.IssuedID) == "" { + return fmt.Errorf("delivery: the expiry of %s's enrolment names no enrolment-issued event", x.Node) + } + return nil +} + +// EncodeEnrolmentExpiry is x as JSON at Version; it refuses what DecodeEnrolmentExpiry refuses. +func EncodeEnrolmentExpiry(x EnrolmentExpiry) ([]byte, error) { + if err := x.check(); err != nil { + return nil, err + } + x.V = Version + return json.Marshal(x) +} + +// DecodeEnrolmentExpiry reads enrolment-expired's body; it refuses an unknown version, a node that is not a name and +// no issued event named. +func DecodeEnrolmentExpiry(data []byte) (EnrolmentExpiry, error) { + var x EnrolmentExpiry + if err := decode(data, &x); err != nil { + return EnrolmentExpiry{}, err + } + if err := x.check(); err != nil { + return EnrolmentExpiry{}, err + } + return x, nil } // **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine, diff --git a/go/delivery/nodehealth.go b/go/delivery/nodehealth.go index 62c541a..742a604 100644 --- a/go/delivery/nodehealth.go +++ b/go/delivery/nodehealth.go @@ -13,8 +13,8 @@ import ( // what it says changed. The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the // passing health message: machine-network (Network), network-rewritten (the resolver file's part, Writer and Owner), // network-unreachable (Peers, read across every node's report), machine-units (Units), relogin-needed (Relogin) and -// directory-used-as-found (Found). Moved here from mesh-host internal/link (65fbc1e6) unchanged; the engine builds a -// report from its own health statement there (NodeHealthOf). +// directory-used-as-found (Found); and for node-account-fallback (Fallback, ADR 0583 §6). Moved here from mesh-host +// internal/link (65fbc1e6); the engine builds a report from its own health statement there (NodeHealthOf). // NodeHealthState and NodeHealthBucket are the node-engine's state health, as a reader names it and as the bus does. const ( @@ -40,6 +40,10 @@ type NodeHealth struct { Relogin []NodeAccount `json:"relogin,omitempty"` // Found are the directories used as found. Found []NodeDirectory `json:"found,omitempty"` + // Fallback is set when the engine fell back to its node's old login after the new one failed its test-dial or its + // confirm (novox/hq ADR 0583 §6): the watchman raises node-account-fallback from it. Nil while the node logs in as + // its login in force. + Fallback *LoginFallback `json:"fallback,omitempty"` // Root are the engine's verdicts on each account a module declares never to become root without a person (novox/hq // ADR 0266), sorted: the agent account's among them where a module declares it. What the self-check's DA and D-root // judge an agent's confinement by, on the machine's own word. @@ -51,6 +55,15 @@ type NodeHealth struct { At time.Time `json:"at"` } +// LoginFallback is a node's fall back to its old login (ADR 0583 §6): the node, the login it fell back from (the one the +// rotation moved to), why, in words with no credential, and when. +type LoginFallback struct { + Node string `json:"node"` + From string `json:"from"` + Why string `json:"why"` + At time.Time `json:"at"` +} + // NetworkHealth is the machine's networking (ADR 0241): the worst of its parts, since when, and each part. type NetworkHealth struct { State string `json:"state"` @@ -150,6 +163,9 @@ func EncodeNodeHealth(h NodeHealth) ([]byte, error) { if h.Node == "" { return nil, errors.New("a node's health report names its node") } + if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) { + return nil, fmt.Errorf("a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node) + } return json.Marshal(h) } diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index a80b3c8..dc81b41 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -160,6 +160,22 @@ func TestNodeRecord(t *testing.T) { walk(reflect.TypeOf(Enrolment{}), "Enrolment") walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress") walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry") + // enrolment-expired names the issued event by its message id (§2), never a stream sequence. + if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil { + t.Errorf("an expiry: %v", err) + } else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" || + !strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) { + t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw) + } + for name, raw := range map[string]string{ + "no issued event": `{"v":1,"node":"lab"}`, + "a sequence for it": `{"v":1,"node":"lab","issued-event":7}`, + "a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`, + } { + if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil { + t.Errorf("an expiry with %s: taken", name) + } + } // And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential. walk(reflect.TypeOf(Placement{}), "Placement") } diff --git a/go/delivery/token.go b/go/delivery/token.go index 9a376da..9cfcde9 100644 --- a/go/delivery/token.go +++ b/go/delivery/token.go @@ -81,7 +81,7 @@ func (t Token) Missing() []string { missing = append(missing, "the broker certificate's fingerprint") } if len(t.Signer) != ed25519.PublicKeySize { - missing = append(missing, "the control plane's signing key") + missing = append(missing, "the vault's signing key") } if strings.TrimSpace(t.Secret) == "" { missing = append(missing, "the one-time secret") From 38a0bbca44e36b2ff721c42629b39ba6dd4372f9 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:57:45 +0100 Subject: [PATCH 11/15] Let enrolment-token take the first tunnel, so the token the vault makes carries it as today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lead's decision on milestone point 7 (a progressive insight of ADR 0587 §3): mesh-enrolment builds the joining node's first tunnel at issue and passes it to the vault's enrolment-token as `tunnel`, an object of hub_key, hub_endpoint and address, each required; the vault puts it in the token (delivery.TokenTunnel). Tested as a row of TestTheVaultsSeatPromisesItsIntakeVerbs; no new test function (1438 unit tests). (issue 774, review round 1) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/seats.go | 11 ++++++++--- go/catalogue/vault_seat_test.go | 15 +++++++++++++-- go/catalogue/verbs.go | 10 ++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index e0da9b7..6de2c61 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -249,11 +249,16 @@ var defaultSeats = append([]Seat{ "written as the bus user enrol.. Answers only what the joining machine must trust (the bus's " + "address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " + "sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " + - "dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write.", - Input: schema(map[string]string{"node": "the node the token is for", + "dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token " + + "carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).", + Input: withObject(schema(map[string]string{"node": "the node the token is for", "expires": "when the token expires (RFC 3339), as the enrolment's key says", "sealed": "\"true\": answer the token sealed to the operator key", - "drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop")}, + "drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"), + "tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+ + "public key, the hub's endpoint and the node's tunnel address", + map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port", + "address": "the joining node's tunnel address"})}, {Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node., sealed " + "to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " + "account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " + diff --git a/go/catalogue/vault_seat_test.go b/go/catalogue/vault_seat_test.go index 28cd326..b960619 100644 --- a/go/catalogue/vault_seat_test.go +++ b/go/catalogue/vault_seat_test.go @@ -1,6 +1,9 @@ package catalogue -import "testing" +import ( + "slices" + "testing" +) // The vault's provision is one the controller itself dereferences — every minted credential is // sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second @@ -66,7 +69,7 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { "issued": {"provision", "provider", "module", "call", "sealed", "holders"}, "rotate": {"node", "module", "name", "provider", "provision", "by"}, // 774/S37/AC1: the enrolment verbs mesh-enrolment asks (ADR 0587 §3, §5). - "enrolment-token": {"node", "expires", "sealed", "drop"}, + "enrolment-token": {"node", "expires", "sealed", "drop", "tunnel"}, "node-account": {"node", "sealing_key", "keys_digest", "retire"}, "sign": {"kind", "node", "serving_key", "reply"}, // 774/S38: the rotation verbs (ADR 0583 §2, §3, §5). @@ -97,6 +100,14 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { if props["value"] != nil { t.Errorf("%s takes a value in the clear", name) } + // The first tunnel the token carries (ADR 0587 §3, insight of 2026-11-02): an object of the hub's key, its endpoint + // and the node's address. + if name == "enrolment-token" { + tunnel, _ := props["tunnel"].(map[string]any) + if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key"}) { + t.Errorf("enrolment-token's tunnel is %v", tunnel) + } + } } // The holders `issued` names (issue 749) are a list of {node, module}. for _, verb := range seat.Serves { diff --git a/go/catalogue/verbs.go b/go/catalogue/verbs.go index 9f980fb..9c1bdac 100644 --- a/go/catalogue/verbs.go +++ b/go/catalogue/verbs.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + "maps" + "slices" ) // A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of @@ -596,6 +598,14 @@ var ControllerVerbs = []Verb{ // // **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not // name is refused when the verb is called, never passed over. +// withObject adds to a verb's input s the property name, an object of the string fields given, each required. +func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any { + inner := schema(fields, slices.Sorted(maps.Keys(fields))) + inner["description"], inner["additionalProperties"] = description, false + s["properties"].(map[string]any)[name] = inner + return s +} + func schema(properties map[string]string, required []string, switches ...string) map[string]any { isSwitch := map[string]bool{} for _, s := range switches { From f913918a607141f1f699b1c0e0771c598c20f702 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 01:08:49 +0100 Subject: [PATCH 12/15] Take the tunnel key at issue and place a joining node on the operator's yes, so the hub peers it before redeem The operator's decision of 2026-11-02 01:05: issue requires overlay_key, the machine's tunnel public key; the vault's enrolment-token tunnel declares all five fields a token needs (hub_key, hub_endpoint, address, key, range). An add-node is proposed at the yes with the tunnel key alone; the identity keys come whole at redeem or not at all. A new remove-node, mesh-enrolment's alone on the same warrant and the proposal's one change, takes a joining node running nothing off placement when its enrolment ends unconfirmed (RemovedNodes names the key the holder deletes; LayChanges lays nothing for it and refuses a node that runs modules or holds seats). NodeLogin gains Acts, the hand acts that warranted its rotations. Tested as rows of TestPlacement, TestNodeRecord and the two seat tests; no new test function (1438 unit tests). (issue 774, review round 1b) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/enrolment_seat.go | 8 +++- go/catalogue/placement_seat.go | 4 +- go/catalogue/seats.go | 6 ++- go/catalogue/vault_seat_test.go | 2 +- go/catalogue/verbs_test.go | 5 +++ go/delivery/node_logins.go | 7 ++++ go/delivery/noderecord_test.go | 6 +++ go/delivery/placement_proposal.go | 65 +++++++++++++++++++++++++------ go/delivery/placement_test.go | 43 ++++++++++++++++++-- 9 files changed, 126 insertions(+), 20 deletions(-) diff --git a/go/catalogue/enrolment_seat.go b/go/catalogue/enrolment_seat.go index 6ff3dd0..ee41572 100644 --- a/go/catalogue/enrolment_seat.go +++ b/go/catalogue/enrolment_seat.go @@ -47,12 +47,16 @@ func enrolmentVerbs() []Verb { "refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " + "key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " + "reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " + - "warrant, asks the vault for the token and says enrolment-issued. The token itself is read only through tokens.", + "warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " + + "The token itself is read only through tokens.", Input: schema(map[string]string{ "node": "the new machine's name in the mesh", "why": "why it joins, shown to the operator with the ask", "expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours", - }, []string{"node", "why"})}, + // The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node + // enters placement with it and the hub peers it before redeem. + "overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it", + }, []string{"node", "why", "overlay_key"})}, {Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol. " + "alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " + "vault for the node's account and certificate, writes the node into placement, and answers the account sealed " + diff --git a/go/catalogue/placement_seat.go b/go/catalogue/placement_seat.go index 615d256..9bb9f51 100644 --- a/go/catalogue/placement_seat.go +++ b/go/catalogue/placement_seat.go @@ -32,7 +32,9 @@ func placementVerbs() []Verb { "unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " + "(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " + "layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " + - "proposal's one change, with the enrolment's warrant; ADR 0587 §7)", + "proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " + + "remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " + + "on the same warrant)", "items": map[string]any{"type": "object", "required": []string{"op", "node"}}} return []Verb{ {Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " + diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 6de2c61..727f4c2 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -256,9 +256,11 @@ var defaultSeats = append([]Seat{ "sealed": "\"true\": answer the token sealed to the operator key", "drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"), "tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+ - "public key, the hub's endpoint and the node's tunnel address", + "public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range", map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port", - "address": "the joining node's tunnel address"})}, + "address": "the joining node's tunnel address", + "key": "the joining machine's own tunnel public key, as given at issue", + "range": "the private network's range, a prefix"})}, {Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node., sealed " + "to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " + "account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " + diff --git a/go/catalogue/vault_seat_test.go b/go/catalogue/vault_seat_test.go index b960619..69a2f73 100644 --- a/go/catalogue/vault_seat_test.go +++ b/go/catalogue/vault_seat_test.go @@ -104,7 +104,7 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { // and the node's address. if name == "enrolment-token" { tunnel, _ := props["tunnel"].(map[string]any) - if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key"}) { + if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key", "key", "range"}) { t.Errorf("enrolment-token's tunnel is %v", tunnel) } } diff --git a/go/catalogue/verbs_test.go b/go/catalogue/verbs_test.go index a75b064..9a2b973 100644 --- a/go/catalogue/verbs_test.go +++ b/go/catalogue/verbs_test.go @@ -1,6 +1,7 @@ package catalogue import ( + "slices" "strings" "testing" ) @@ -31,6 +32,10 @@ func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) { t.Fatalf("mesh-enrolment: in the set %v, %+v", ok, enrolment) } six := []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"} + // The tunnel key is given at issue (the operator, 2026-11-02 01:05). + if issue := enrolment.Serves[0]; issue.Name != "issue" || !slices.Contains(issue.Input["required"].([]string), "overlay_key") { + t.Errorf("issue requires %v", issue.Input["required"]) + } holder := func(serves ...string) Manifest { return Manifest{Module: "mesh-enrolment", Tools: serves, Claims: []Claim{{Name: EnrolmentSeat, Scope: ScopeMesh, Serves: serves}}} diff --git a/go/delivery/node_logins.go b/go/delivery/node_logins.go index d7a6517..2a65964 100644 --- a/go/delivery/node_logins.go +++ b/go/delivery/node_logins.go @@ -58,6 +58,8 @@ type NodeLogin struct { Stage string `json:"stage,omitempty"` Since time.Time `json:"since"` ListRevision uint64 `json:"list-revision,omitempty"` + // Acts are the hand-act ids that warranted the node's rotations, oldest first (ADR 0583 §2): the vault records each. + Acts []string `json:"acts,omitempty"` } // FirstLogin is a node's first login, `node.`, the one every node had before rotation; SecondLogin its other. @@ -89,6 +91,11 @@ func (l NodeLogin) check() error { if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) { return fmt.Errorf("delivery: the node %s logs in as %q, neither of its two logins", l.Node, l.Login) } + for _, act := range l.Acts { + if strings.TrimSpace(act) == "" { + return fmt.Errorf("delivery: the node %s names an empty hand act among its rotations' warrants", l.Node) + } + } if !l.Rotates() { if l.Stage != "" || l.ListRevision != 0 { return fmt.Errorf("delivery: the node %s rotates to nothing and names stage %q", l.Node, l.Stage) diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index dc81b41..2cf9ec7 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -139,6 +139,11 @@ func TestNodeRecord(t *testing.T) { t.Errorf("node login round trip: %+v, %v", back, err) } } + // The hand acts that warranted its rotations, as the vault records them. + if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil || + !strings.Contains(string(raw), `"acts":["act-1"]`) { + t.Errorf("a login with its acts: %s, %v", raw, err) + } if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() { t.Errorf("an empty rotating: %+v, %v", l, err) } @@ -148,6 +153,7 @@ func TestNodeRecord(t *testing.T) { "a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun}, "a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"}, "a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"}, + "an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}}, } { if _, err := EncodeNodeLogin(l); err == nil { t.Errorf("node login with %s: taken", name) diff --git a/go/delivery/placement_proposal.go b/go/delivery/placement_proposal.go index 9fbb374..ab6995a 100644 --- a/go/delivery/placement_proposal.go +++ b/go/delivery/placement_proposal.go @@ -68,6 +68,10 @@ const ( // OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the // enrolment that issued its token (novox/hq ADR 0587 §7): mesh-enrolment's alone, the proposal's one change. OpAddNode = "add-node" + // OpRemoveNode takes a joining node off placement again when its enrolment ends unconfirmed, revoked or expired + // (the operator's decision of 2026-11-02 01:05): mesh-enrolment's alone, on the same warrant, the proposal's one + // change, of a node with no module and no holding. The holder deletes its key (RemovedNodes). + OpRemoveNode = "remove-node" ) // The states of an open proposal. @@ -111,13 +115,15 @@ type PlacementProposalChange struct { // Whole is the node's whole placement (OpSeed). Whole *Placement `json:"whole,omitempty"` // Decided and Keys are a joining node's (OpAddNode): its decided fields, the address the operator approved among - // them, and the public halves of the keys it presented at redeem. + // them, and the public halves of its keys: at the operator's yes its tunnel key alone, given at issue (the operator's + // decision of 2026-11-02 01:05); the identity keys are bound at redeem, checked against the node account's digest. Decided *NodeDecided `json:"decided,omitempty"` Keys *JoiningKeys `json:"keys,omitempty"` } -// JoiningKeys are the public halves of the keys a joining node presented at redeem (ADR 0587 §6, §7): its identity, -// private network, sealing and serving keys, as Redeem carries them. Public halves alone, never a private key. +// JoiningKeys are the public halves of a joining node's keys (ADR 0587 §6, §7): its private network (tunnel) key, given +// at issue, and its identity, sealing and serving keys, presented at redeem, as Redeem carries them. An add-node at the +// operator's yes carries the tunnel key alone. Public halves alone, never a private key. type JoiningKeys struct { Identity string `json:"identity"` Overlay string `json:"overlay,omitempty"` @@ -165,8 +171,12 @@ func (c PlacementProposalChange) check() error { } return c.Whole.check() case OpAddNode: - if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Identity == "" || c.Keys.Sealing == "" { - return fmt.Errorf("delivery: the add-node of %s names no address or no identity and sealing key", c.Node) + if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" { + return fmt.Errorf("delivery: the add-node of %s names no address or no tunnel key", c.Node) + } + // The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse. + if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") { + return fmt.Errorf("delivery: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node) } if c.Decided.Hub { return fmt.Errorf("delivery: the add-node of %s makes it the hub, which a joining node never is", c.Node) @@ -175,8 +185,13 @@ func (c PlacementProposalChange) check() error { return fmt.Errorf("delivery: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node) } return nil + case OpRemoveNode: + if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil { + return fmt.Errorf("delivery: the remove-node of %s names more than its node", c.Node) + } + return nil } - return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config or add-node", c.Op) + return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op) } // LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given; @@ -222,6 +237,16 @@ func LayChanges(current map[string]Placement, changes []PlacementProposalChange) continue } p = Placement{V: Version, Node: c.Node, Decided: *c.Decided, Modules: []PlacedModule{}} + case OpRemoveNode: + // Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs + // anything is taken off module by module, by a proposal the operator approves). + cur, has := current[c.Node] + if !has { + findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node)) + } else if len(cur.Modules)+len(cur.Holds) > 0 { + findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node)) + } + continue case OpPlace: m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings} if at >= 0 { @@ -472,12 +497,13 @@ func (p PlacementProposal) check() error { return p.checkAddNode() } -// checkAddNode refuses an add-node that is not the proposal's one change, carries no warrant, or whose warrant names -// another node or address than the change; and a warrant on any other proposal (ADR 0587 §7). +// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose +// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other +// proposal (ADR 0587 §7). func (p PlacementProposal) checkAddNode() error { adds := 0 for _, c := range p.Changes { - if c.Op == OpAddNode { + if c.Op == OpAddNode || c.Op == OpRemoveNode { adds++ } } @@ -494,6 +520,12 @@ func (p PlacementProposal) checkAddNode() error { if w == nil || w.Ask == "" || w.Warrant == "" { return fmt.Errorf("delivery: the add-node of %s carries no warrant of its enrolment", c.Node) } + if c.Op == OpRemoveNode { + if w.Node != c.Node { + return fmt.Errorf("delivery: the remove-node of %s carries the warrant of %s", c.Node, w.Node) + } + return nil + } if w.Node != c.Node || w.Address != c.Decided.Address { return fmt.Errorf("delivery: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address, w.Node, w.Address) @@ -501,7 +533,18 @@ func (p PlacementProposal) checkAddNode() error { return nil } -// AddNodeRefusal is why the holder of mesh-placement refuses an add-node from caller, the module the runtime proved +// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays. +func (p PlacementProposal) RemovedNodes() []string { + var out []string + for _, c := range p.Changes { + if c.Op == OpRemoveNode { + out = append(out, c.Node) + } + } + return out +} + +// AddNodeRefusal is why the holder of mesh-placement refuses an add-node or a remove-node from caller, the module the runtime proved // called (ADR 0587 §7), or "": only mesh-enrolment adds a node; what the proposal itself must hold, DecodePlacementProposal // refuses. func AddNodeRefusal(p PlacementProposal, caller string) string { @@ -509,7 +552,7 @@ func AddNodeRefusal(p PlacementProposal, caller string) string { return err.Error() } if p.Warrant != nil && caller != EnrolmentModule { - return fmt.Sprintf("an add-node is mesh-enrolment's alone, and %s proposed it", caller) + return fmt.Sprintf("an add-node or a remove-node is mesh-enrolment's alone, and %s proposed it", caller) } return "" } diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index fa9b9e7..d4a5f96 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -691,14 +691,15 @@ func TestPlacement(t *testing.T) { } // 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one // change, from mesh-enrolment alone; a warrant naming another node or address is refused. + // At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05). joining := PlacementProposalChange{Op: OpAddNode, Node: "lab", Decided: &NodeDecided{Address: "10.0.0.7"}, - Keys: &JoiningKeys{Identity: "id", Sealing: "seal"}} + Keys: &JoiningKeys{Overlay: "wg"}} add := PlacementProposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(), Changes: []PlacementProposalChange{joining}, Base: map[string]uint64{"lab": 0}, State: ProposalAnswering, Warrant: &AddNodeWarrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}} if raw, err := EncodePlacementProposal(add); err != nil { t.Errorf("an add-node: %v", err) - } else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Digest() != KeysDigest("id", "", "seal", "") { + } else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Overlay != "wg" { t.Errorf("an add-node round trip: %+v, %v", back, err) } if AddNodeRefusal(add, EnrolmentModule) != "" || !strings.Contains(AddNodeRefusal(add, AssignerModule), "mesh-enrolment's alone") { @@ -708,7 +709,7 @@ func TestPlacement(t *testing.T) { t.Errorf("an add-node laid: %+v, %v", laid, f) } if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpAddNode, Node: "g14", Decided: &NodeDecided{Address: "10.0.0.9"}, - Keys: &JoiningKeys{Identity: "id", Sealing: "seal"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") { + Keys: &JoiningKeys{Overlay: "wg"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") { t.Errorf("an add-node of a placed node: %v", f) } for name, mut := range map[string]func(*PlacementProposal){ @@ -719,6 +720,16 @@ func TestPlacement(t *testing.T) { p.Warrant = &AddNodeWarrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"} }, "no warrant": func(p *PlacementProposal) { p.Warrant = nil }, + "no tunnel key": func(p *PlacementProposal) { + c := joining + c.Keys = &JoiningKeys{Identity: "id", Sealing: "seal"} + p.Changes = []PlacementProposalChange{c} + }, + "half its identity keys": func(p *PlacementProposal) { + c := joining + c.Keys = &JoiningKeys{Overlay: "wg", Identity: "id"} + p.Changes = []PlacementProposalChange{c} + }, "beside another change": func(p *PlacementProposal) { p.Changes = append(p.Changes, PlacementProposalChange{Op: OpPlace, Node: "lab", Module: "zsh"}) }, @@ -738,6 +749,32 @@ func TestPlacement(t *testing.T) { t.Errorf("an add-node with %s: taken", name) } } + // With its identity keys whole, as bound at redeem, it is taken too. + whole := add + whole.Changes = []PlacementProposalChange{joining} + whole.Changes[0].Keys = &JoiningKeys{Overlay: "wg", Identity: "id", Sealing: "seal"} + if _, err := EncodePlacementProposal(whole); err != nil { + t.Errorf("an add-node with its identity keys: %v", err) + } + // An enrolment's end takes its never-confirmed node off again: mesh-enrolment's alone, of a node running nothing. + remove := add + remove.Changes = []PlacementProposalChange{{Op: OpRemoveNode, Node: "lab"}} + if _, err := EncodePlacementProposal(remove); err != nil || AddNodeRefusal(remove, AssignerModule) == "" || + AddNodeRefusal(remove, EnrolmentModule) != "" || !slices.Equal(remove.RemovedNodes(), []string{"lab"}) { + t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, AssignerModule)) + } + joined := map[string]Placement{"lab": {V: Version, Node: "lab", Decided: NodeDecided{Address: "10.0.0.7"}, Modules: []PlacedModule{}}} + if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 { + t.Errorf("a joining node removed: %v, %v", laid, f) + } + if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpRemoveNode, Node: "g14"}}); len(f) != 1 || + !strings.Contains(f[0], "runs modules") { + t.Errorf("a running node removed: %v", f) + } + remove.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"} + if _, err := EncodePlacementProposal(remove); err == nil { + t.Error("a remove-node on another node's warrant: taken") + } for name, mut := range map[string]func(*PlacementProposal){ "no id": func(p *PlacementProposal) { p.ID = "" }, "no proposer": func(p *PlacementProposal) { p.By = "" }, From 2d717777b1ff71c1b9e29ba754462220a8a73d8b Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 01:15:12 +0100 Subject: [PATCH 13/15] Carry the machine's tunnel key on its enrolment, so the holder places it on the operator's yes The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue. Enrolment gains overlay-key, required at every stage and refused unless it is 32 bytes in base64. Tested as rows of TestNodeRecord; no new test function (1438 unit tests). (issue 774, review round 1b) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/enrolment.go | 6 ++++++ go/delivery/noderecord_test.go | 19 +++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/go/delivery/enrolment.go b/go/delivery/enrolment.go index 82166e0..d93eced 100644 --- a/go/delivery/enrolment.go +++ b/go/delivery/enrolment.go @@ -58,6 +58,9 @@ type Enrolment struct { Issued time.Time `json:"issued"` Expires time.Time `json:"expires"` KeysDigest string `json:"keys-digest,omitempty"` + // OverlayKey is the machine's tunnel public key, given at issue (the operator's decision of 2026-11-02 01:05), + // base64 of 32 bytes: at every stage, since the node enters placement with it on the operator's yes. + OverlayKey string `json:"overlay-key"` } // EnrolmentAddress is one key `address.
`: the node the address is reserved for, and nothing else. @@ -101,6 +104,9 @@ func (e Enrolment) check() error { if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" { return fmt.Errorf("delivery: the enrolment of %s names no why or no caller", e.Node) } + if k, err := base64.StdEncoding.DecodeString(e.OverlayKey); err != nil || len(k) != 32 { + return fmt.Errorf("delivery: the enrolment of %s names no tunnel key of 32 bytes in base64", e.Node) + } switch e.Stage { case EnrolmentAsked, EnrolmentLive: if e.KeysDigest != "" { diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index 2cf9ec7..1aadc1c 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -1,6 +1,7 @@ package delivery import ( + "encoding/base64" "reflect" "regexp" "strings" @@ -72,11 +73,13 @@ func TestNodeRecord(t *testing.T) { // 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the // grants part reads them (ADR 0587 §2). issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) + // The tunnel key given at issue, at every stage. + wg := base64.StdEncoding.EncodeToString(make([]byte, 32)) for _, e := range []Enrolment{ {Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive, - Issued: issued, Expires: issued.Add(DefaultTokenLife)}, + OverlayKey: wg, Issued: issued, Expires: issued.Add(DefaultTokenLife)}, {Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming, - Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")}, + OverlayKey: wg, Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")}, } { raw, err := EncodeEnrolment(e) if err != nil { @@ -95,11 +98,15 @@ func TestNodeRecord(t *testing.T) { } for name, e := range map[string]Enrolment{ "keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive, - Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")}, + OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")}, "no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming, - Issued: issued, Expires: issued.Add(time.Hour)}, - "no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued, Expires: issued.Add(time.Hour)}, - "other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", Issued: issued, + OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)}, + "no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)}, + "no tunnel key": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued, + Expires: issued.Add(time.Hour)}, + "a tunnel key not of 32 bytes": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, + OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 31)), Issued: issued, Expires: issued.Add(time.Hour)}, + "other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)}, } { if _, err := EncodeEnrolment(e); err == nil { From c816666f83ed61f6d43e3da4610a349abd6745ae Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 01:24:32 +0100 Subject: [PATCH 14/15] Name the enrolment an address is reserved for, from its ask, and the issued event's id in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0589 §6: an address key gains the issue's ask and time, written when the ask is raised. The key is written at the reservation, before the ask, so both are empty then and both set from the ask; one without the other is refused, as is no node. EncodeEnrolmentAddress takes node, ask and issued. EnrolmentIssuedID builds the enrolment-issued event's id, enrolment-issued.., as the holder names it today. Tested as rows of TestNodeRecord; no new test function (1438 unit tests). (issue 774, review round 1b) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/enrolment.go | 45 ++++++++++++++++++++++++++-------- go/delivery/noderecord_test.go | 25 ++++++++++++++++--- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/go/delivery/enrolment.go b/go/delivery/enrolment.go index d93eced..24b624e 100644 --- a/go/delivery/enrolment.go +++ b/go/delivery/enrolment.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/netip" + "strconv" "strings" "time" @@ -15,7 +16,7 @@ import ( // **The state of the seat mesh-enrolment** (novox/hq ADR 0587 §2, issue 774): the holder's bucket `enrolments`, holding // no secret, with two kinds of key. `node.`, one per open enrolment, holds an Enrolment; `address.
` -// reserves one private-network address for one open enrolment and holds only the node's name. Every write is a +// reserves one private-network address for one open enrolment and holds the node's name and, from the ask, the ask and the issue's time. Every write is a // compare-and-set at the revision read. The bucket keeps per-key expiry (`key-ttl`): a key at stage live carries the // token's expiry, at asked and redeeming none (§5). It is read by the holder and, as a named reader, by the grants part, // which composes the user `enrol.` from a key at live and the vault's hash (§4). These constants are the seat's @@ -63,10 +64,31 @@ type Enrolment struct { OverlayKey string `json:"overlay-key"` } -// EnrolmentAddress is one key `address.
`: the node the address is reserved for, and nothing else. +// EnrolmentAddress is one key `address.
`: the node the address is reserved for and, from when the issue's ask +// is raised, that ask's id and the issue's time (ADR 0589 §6), so the reservation names the enrolment it holds the +// address for. The key is written when the address is reserved, before the ask: Ask and Issued are then both empty, and +// from the ask both set; one without the other is refused. type EnrolmentAddress struct { - V int `json:"v"` - Node string `json:"node"` + V int `json:"v"` + Node string `json:"node"` + Ask string `json:"ask,omitempty"` + Issued time.Time `json:"issued,omitzero"` +} + +func (a EnrolmentAddress) check() error { + if err := checkName("node", a.Node); err != nil { + return err + } + if (strings.TrimSpace(a.Ask) == "") != a.Issued.IsZero() { + return fmt.Errorf("delivery: the address reserved for %s names its ask without the issue's time, or the reverse", a.Node) + } + return nil +} + +// EnrolmentIssuedID is the id of the enrolment-issued event of node's enrolment issued at issued: +// `enrolment-issued..`, what enrolment-expired names (EnrolmentExpiry). +func EnrolmentIssuedID(node string, issued time.Time) string { + return catalogue.EnrolmentIssuedEvent + "." + node + "." + strconv.FormatInt(issued.UnixNano(), 10) } // EnrolmentNodeKey is a node's key in `enrolments`. @@ -148,21 +170,24 @@ func DecodeEnrolment(data []byte) (Enrolment, error) { return e, nil } -// EncodeEnrolmentAddress is a key `address.
` reserving it for node. -func EncodeEnrolmentAddress(node string) ([]byte, error) { - if err := checkName("node", node); err != nil { +// EncodeEnrolmentAddress is a key `address.
` reserving it for node: with no ask and a zero issued when it is +// reserved, with the issue's ask and time once the ask is raised. It refuses what DecodeEnrolmentAddress refuses. +func EncodeEnrolmentAddress(node, ask string, issued time.Time) ([]byte, error) { + a := EnrolmentAddress{V: Version, Node: node, Ask: ask, Issued: issued} + if err := a.check(); err != nil { return nil, err } - return json.Marshal(EnrolmentAddress{V: Version, Node: node}) + return json.Marshal(a) } -// DecodeEnrolmentAddress reads a key `address.
`. +// DecodeEnrolmentAddress reads a key `address.
`; it refuses no node, and an ask without the issue's time or +// the reverse. func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) { var a EnrolmentAddress if err := decode(data, &a); err != nil { return EnrolmentAddress{}, err } - if err := checkName("node", a.Node); err != nil { + if err := a.check(); err != nil { return EnrolmentAddress{}, err } return a, nil diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index 1aadc1c..a896628 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "reflect" "regexp" + "strconv" "strings" "testing" "time" @@ -91,10 +92,28 @@ func TestNodeRecord(t *testing.T) { t.Errorf("enrolment round trip: %+v, %v", back, err) } } - if raw, err := EncodeEnrolmentAddress("lab"); err != nil { + // ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both. + if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil { t.Fatal(err) - } else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" { - t.Errorf("address round trip: %+v, %v", back, err) + } else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") { + t.Errorf("address round trip: %+v, %v (%s)", back, err, raw) + } + if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil { + t.Fatal(err) + } else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) { + t.Errorf("address at the ask round trip: %+v, %v", back, err) + } + for name, raw := range map[string]string{ + "no node": `{"v":1}`, + "an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`, + "an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`, + } { + if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil { + t.Errorf("an address with %s: taken", name) + } + } + if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) { + t.Errorf("the issued event's id: %q", id) } for name, e := range map[string]Enrolment{ "keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive, From dff0ce2abb2100da90d0a5dad719c3e45d40079e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 01:41:46 +0100 Subject: [PATCH 15/15] Say why LeaseHandedAway and LeaseCut are two fields: the cut precedes the holder's write and outlasts it at a hand-back (issue 774) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buscompose/users.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 2e33efe..647075c 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -150,7 +150,8 @@ type Records struct { Engine *Declared // LeaseHandedAway says the controller's lease names a holder that is no controller (ADR 0582 §3), as the grants part // reads it: an enrolment user then enrols through the seat mesh-enrolment alone (novox/hq ADR 0587 §6). The - // controller's composer never sets it. + // controller's composer never sets it. It is not LeaseCut: the cut is composed before the holder is written, so the + // grants part may write it, and outlasts the holder at a hand-back; the two are true together only in between. LeaseHandedAway bool // NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one // rotating, each composed as a user of the node's permissions. A node absent logs in as `node.` alone.