From f913918a607141f1f699b1c0e0771c598c20f702 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 01:08:49 +0100 Subject: [PATCH] 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 = "" },