diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 913a30a..b896812 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,156 @@ 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") + } + }) + // 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") + } + }) + // 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 8b56c96..ccdb259 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" ) @@ -253,6 +254,26 @@ 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 + + // 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 + + // 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 @@ -303,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 { @@ -380,6 +417,33 @@ 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} +) + +// 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 { + 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 +592,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 +654,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 { @@ -889,7 +963,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: @@ -1003,6 +1090,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...) @@ -1440,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 5f9d7b1..5eee315 100644 --- a/go/buscompose/userlist_switch.go +++ b/go/buscompose/userlist_switch.go @@ -104,7 +104,12 @@ 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}, + // 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 720b030..647075c 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. @@ -129,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 @@ -145,6 +148,14 @@ 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. 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. + 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 +191,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] { @@ -275,7 +290,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]} @@ -286,6 +302,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) @@ -385,6 +408,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" 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, diff --git a/go/catalogue/enrolment_seat.go b/go/catalogue/enrolment_seat.go new file mode 100644 index 0000000..ee41572 --- /dev/null +++ b/go/catalogue/enrolment_seat.go @@ -0,0 +1,96 @@ +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, 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", + // 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 " + + "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/placement_seat.go b/go/catalogue/placement_seat.go index 7fa69de..9bb9f51 100644 --- a/go/catalogue/placement_seat.go +++ b/go/catalogue/placement_seat.go @@ -31,7 +31,10 @@ 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, 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 " + @@ -52,11 +55,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/catalogue/seats.go b/go/catalogue/seats.go index 4dc4536..727f4c2 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -233,13 +233,59 @@ 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. 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"), + "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, 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", + "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 " + + "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")}, + // 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). @@ -305,6 +351,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/vault_seat_test.go b/go/catalogue/vault_seat_test.go index 77630ee..69a2f73 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 @@ -65,6 +68,14 @@ 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", "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). + "rotate-node": {"node", "why", "act"}, + "node-account-confirm": {}, + "abandon": {"node"}, } for name, args := range want { var verb *Verb @@ -89,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", "key", "range"}) { + 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 { @@ -105,7 +124,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"}}}} + 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/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 { diff --git a/go/catalogue/verbs_test.go b/go/catalogue/verbs_test.go index bf90983..9a2b973 100644 --- a/go/catalogue/verbs_test.go +++ b/go/catalogue/verbs_test.go @@ -1,6 +1,7 @@ package catalogue import ( + "slices" "strings" "testing" ) @@ -25,6 +26,30 @@ 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"} + // 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}}} + } + 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", 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 new file mode 100644 index 0000000..24b624e --- /dev/null +++ b/go/delivery/enrolment.go @@ -0,0 +1,332 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/netip" + "strconv" + "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 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 +// 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"` + // 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, 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"` + 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`. +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) + } + 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 != "" { + 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: 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(a) +} + +// 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 := a.check(); 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, 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"` + 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, +// 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 +} + +// **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/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/node_logins.go b/go/delivery/node_logins.go new file mode 100644 index 0000000..2a65964 --- /dev/null +++ b/go/delivery/node_logins.go @@ -0,0 +1,137 @@ +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"` + // 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. +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) + } + 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) + } + 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/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 1e985e9..a896628 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -1,10 +1,13 @@ package delivery import ( + "encoding/base64" "reflect" "regexp" + "strconv" "strings" "testing" + "time" "git.novox.be/novox/mesh-sdk/go/catalogue" ) @@ -13,7 +16,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 +71,143 @@ 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) + // 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, + 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, + OverlayKey: wg, 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) + } + } + // 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" || 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, + 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, + 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 { + 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) + } + } + // 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) + } + } + // 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) + } + 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"}, + "an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}}, + } { + 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") + // 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/placement_proposal.go b/go/delivery/placement_proposal.go index 66f6cb1..ab6995a 100644 --- a/go/delivery/placement_proposal.go +++ b/go/delivery/placement_proposal.go @@ -65,6 +65,13 @@ 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" + // 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. @@ -107,6 +114,34 @@ 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 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 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"` + 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 +170,28 @@ 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.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) + } + 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 + 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 or config", 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; @@ -175,6 +230,23 @@ 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 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 { @@ -378,6 +450,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 +494,69 @@ 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 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 || c.Op == OpRemoveNode { + 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 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) + } return nil } +// 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 { + if err := p.checkAddNode(); err != nil { + return err.Error() + } + if p.Warrant != nil && caller != EnrolmentModule { + return fmt.Sprintf("an add-node or a remove-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..d4a5f96 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -689,6 +689,92 @@ 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. + // 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{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.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") { + 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{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){ + "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 }, + "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"}) + }, + "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) + } + } + // 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 = "" }, 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" diff --git a/go/delivery/token.go b/go/delivery/token.go new file mode 100644 index 0000000..9cfcde9 --- /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 vault'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) + } + } +}