diff --git a/go/asks/asks.go b/go/asks/asks.go index 596bb8f..480f212 100644 --- a/go/asks/asks.go +++ b/go/asks/asks.go @@ -571,3 +571,8 @@ type ProofAnswer struct { Accepted bool `json:"accepted"` Words string `json:"words"` } + +// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259 +// §2 to admit a node's engine as an asker): `node-engine-`, the last token of the by-caller subjects granted to that +// node's engine account alone, so no engine acts on another node's warrant. +func EngineAsker(node string) string { return "node-engine-" + node } diff --git a/go/buscompose/accounts.go b/go/buscompose/accounts.go new file mode 100644 index 0000000..4c2b3fd --- /dev/null +++ b/go/buscompose/accounts.go @@ -0,0 +1,244 @@ +package buscompose + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + "time" +) + +// **The accounts the grants part wants, and the one shape a held account takes** (novox/hq ADR 0561 §1, §2; issue 774). +// +// The bus composer derives every user from placement, the manifests and the wants. For each account the mesh mints (a +// module's, a module's process's, a machine's provisioner client's), the grants part writes one key of its state +// `accounts`, keyed by the bus user, holding the account's fields that are not secret: what the controller's credential +// carries today beside the password. The vault watches that state, mints the user it holds no value for, and keeps the +// value as the module's own secret on that node, built by HeldCredential, which gives byte for byte the controller's +// shape, so a runtime reads it unchanged. The state is read by the vault alone (NamedReaders), as `bus-accounts` is read +// by the grants part alone. +// +// The node accounts, the controller's own, an enrolment user and a person are never wanted here: the vault mints none +// of them (ADR 0561 §4, §5). + +// AccountsState is the grants part's state of wanted accounts, by bus user. +const AccountsState = "accounts" + +// GrantsAccounts is that state as a reader names it (`.`). +const GrantsAccounts = GrantsModule + "." + AccountsState + +// AccountsBucket is the bucket the state lives in. +var AccountsBucket = BucketName(GrantsModule, AccountsState) + +// AccountsSubject is the subject of one wanted account's key: `$KV.mesh-grants_accounts.`. +func AccountsSubject(user string) string { return "$KV." + AccountsBucket + "." + user } + +// AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash. +const AccountIssuedEvent = "account-issued" + +// AccountIssuedSubject is where it says it, as the runtime addresses mesh-vault's events. +const AccountIssuedSubject = "mesh.mod." + VaultSeat + ".event." + AccountIssuedEvent + +// ClaimedSeat is one seat a module claims, as its runtime needs it (the controller's seatClaimed): the name, the scope (a +// node-scoped seat's verb carries the machine) and the verbs the runtime serves for it. +type ClaimedSeat struct { + Seat string `json:"seat"` + Scope string `json:"scope"` + Serves []string `json:"serves,omitempty"` +} + +// WantedAccount is one key of the grants part's state `accounts`: an account the mesh needs, without its password. +type WantedAccount struct { + User string `json:"user"` + // Kind is KindModule (KindNodeTools for the runtime, named as a module is), KindProcess or KindProvisioner. + Kind Kind `json:"kind"` + Node string `json:"node"` + Module string `json:"module"` + // Process is a KindProcess account's process id. + Process string `json:"process,omitempty"` + // URL is the bus's address as that node reaches it, `nats://:`; Fingerprint the bus certificate's. + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + // Claims are a module's claims with the verbs each seat promises: none for a process or a provisioner client. + Claims []ClaimedSeat `json:"claims,omitempty"` + // At is when the grants part wrote the key: not compared (Same). + At time.Time `json:"at"` +} + +// mintedKinds are the kinds of account the vault mints (ADR 0561 §1). +var mintedKinds = []Kind{KindModule, KindNodeTools, KindProcess, KindProvisioner} + +// principal is the account as the composer names it. +func (w WantedAccount) principal() Principal { + return Principal{Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process} +} + +// Valid says why w is not an account the vault may mint, or nil: a kind it mints, every name a subject token, the user +// the one the composer derives from them, and an address to reach the bus at. +func (w WantedAccount) Valid() error { + if !slices.Contains(mintedKinds, w.Kind) { + return fmt.Errorf("%s is a %s account, which the vault never mints (ADR 0561 §4)", w.User, w.Kind) + } + for what, v := range map[string]string{"node": w.Node, "module": w.Module} { + if !safeSubject.MatchString(v) { + return fmt.Errorf("%s: the %s %q is not a name the mesh writes", w.User, what, v) + } + } + if w.Kind == KindProcess && !safeSubject.MatchString(w.Process) { + return fmt.Errorf("%s: the process %q is not a name the mesh writes", w.User, w.Process) + } + if w.Kind != KindProcess && w.Process != "" { + return fmt.Errorf("%s: only a process's account names a process", w.User) + } + if want := w.principal().Username(); w.User != want { + return fmt.Errorf("%s is not the user of a %s account of %s on %s (that is %s)", w.User, w.Kind, w.Module, w.Node, want) + } + if !strings.HasPrefix(w.URL, "nats://") || len(w.URL) == len("nats://") { + return fmt.Errorf("%s names no bus address (url, nats://:)", w.User) + } + if len(w.Claims) > 0 && w.Kind != KindModule && w.Kind != KindNodeTools { + return fmt.Errorf("%s: a %s account holds no seat, so it carries no claims", w.User, w.Kind) + } + return nil +} + +// Same says whether two wanted accounts want the same account: every field but when it was written. +func (w WantedAccount) Same(o WantedAccount) bool { + a, b := w, o + a.At, b.At = time.Time{}, time.Time{} + ra, _ := json.Marshal(a) + rb, _ := json.Marshal(b) + return string(ra) == string(rb) +} + +// OwnSecret is the module's own secret the account is held in: `broker`, `broker-` or `provisioner-broker` +// (the controller's names, ADR 0436 §2, ADR 0349 §11). +func (w WantedAccount) OwnSecret() string { + switch w.Kind { + case KindProcess: + return "broker-" + w.Process + case KindProvisioner: + return "provisioner-broker" + } + return "broker" +} + +// ReadWantedAccount reads the key user of the state: refused when it is not a valid wanted account of that user. +func ReadWantedAccount(user string, raw []byte) (WantedAccount, error) { + var w WantedAccount + if err := json.Unmarshal(raw, &w); err != nil { + return WantedAccount{}, fmt.Errorf("%s %s is not a wanted account: %w", GrantsAccounts, user, err) + } + if w.User != user { + return WantedAccount{}, fmt.Errorf("%s %s names the user %q", GrantsAccounts, user, w.User) + } + return w, w.Valid() +} + +// AccountIssued is the vault's account-issued: which account it minted, and its generation. Never a value or a hash. +type AccountIssued struct { + User string `json:"user"` + Kind Kind `json:"kind"` + Node string `json:"node"` + Module string `json:"module"` + Process string `json:"process,omitempty"` + Generation int `json:"generation"` + At time.Time `json:"at"` +} + +// HeldCredential is the value the account's holder reads, exactly as the controller builds it today (issueWith, +// sealProcess and sealProvisionerClient): a module's `{url, fingerprint, node, module, user, password, claims}`, a +// process's without claims, a provisioner client's without module or claims; an empty fingerprint or claims left out. +func HeldCredential(w WantedAccount, password string) ([]byte, error) { + if err := w.Valid(); err != nil { + return nil, err + } + if password == "" { + return nil, fmt.Errorf("%s: an account is held with its password", w.User) + } + switch w.Kind { + case KindProcess: + return json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + Module string `json:"module"` + User string `json:"user"` + Password string `json:"password"` + }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password}) + case KindProvisioner: + return json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + User string `json:"user"` + Password string `json:"password"` + }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, User: w.User, Password: password}) + } + return json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + Module string `json:"module"` + User string `json:"user"` + Password string `json:"password"` + Claims []ClaimedSeat `json:"claims,omitempty"` + }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password, + Claims: w.Claims}) +} + +// **One issuer at every moment: minting turns on at the lease write** (novox/hq ADR 0561 §3, §6). The controller's lease +// is the one key `holder` of its bucket (LeaseBucket). The operator's credential hands it away by writing a value that +// names its new holder in `handed-to`; while the key holds that, the vault mints and the grants part creates the durable +// consumers an account needs. A controller's value, a key deleted or expired, or one that cannot be read or parsed, turn +// both off: when in doubt nothing is minted, so the controller and the vault never both mint one account. No clock and +// no setting turns it. + +// ControllerLease is the lease's bucket as a reader names it; the vault and the grants part alone read it +// (NamedReaders), the controller's announcement marking it readable by nobody. +const ControllerLease = ControllerSeat + ".lease" + +// ControllerLeaseKey is the lease's one key. +const ControllerLeaseKey = leaseKey + +// LeaseHolder is the lease key's value: a controller instance's (mesh-controller internal/lease Holder), or, once handed +// away, one naming its holder in HandedTo. +type LeaseHolder struct { + Instance string `json:"instance,omitempty"` + Host string `json:"host,omitempty"` + Epoch uint64 `json:"epoch,omitempty"` + Taken time.Time `json:"taken,omitzero"` + Renewed time.Time `json:"renewed,omitzero"` + // HandedTo is who holds the lease once the controller handed it away: never a controller instance. + HandedTo string `json:"handed-to,omitempty"` + // HandedBy is the credential that wrote it (the operator's), and Why its reason. + HandedBy string `json:"handed-by,omitempty"` + Why string `json:"why,omitempty"` +} + +// LeaseHandedAway reads the lease key as a getter answered it (its value, whether it was there, the read's error) and +// says whether it is handed to a holder that is no controller, with why either way. +func LeaseHandedAway(raw []byte, found bool, readErr error) (bool, string) { + switch { + case readErr != nil: + return false, "the controller's lease cannot be read (" + readErr.Error() + "), so nothing is minted" + case !found || len(raw) == 0 || string(raw) == "null": + return false, "no lease is written (expired, deleted, or never taken): a controller may take it, so nothing is minted" + } + var h LeaseHolder + if err := json.Unmarshal(raw, &h); err != nil { + return false, "the controller's lease is not readable as a holder (" + err.Error() + "), so nothing is minted" + } + if strings.TrimSpace(h.HandedTo) == "" { + who := h.Instance + if who == "" { + who = "an instance that names itself not" + } + return false, "the controller holds the lease (" + who + "): it mints, so the vault mints nothing (ADR 0561 §3)" + } + if h.Instance != "" { + return false, "the lease names both a controller instance (" + h.Instance + ") and a holder it is handed to (" + + h.HandedTo + "), so nothing is minted" + } + return true, "the lease is handed to " + h.HandedTo + ": the vault mints every new account (ADR 0561 §1, §3)" +} diff --git a/go/buscompose/announced.go b/go/buscompose/announced.go index c03e705..09637ba 100644 --- a/go/buscompose/announced.go +++ b/go/buscompose/announced.go @@ -1,6 +1,7 @@ package buscompose import ( + "slices" "sort" "strings" @@ -58,6 +59,10 @@ func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announ switch { case reader != "" && !ReaderMay(reader, r): refused = append(refused, r) + case reader != "" && namedReader(reader, r): + // A state the composer names its readers for is theirs whatever its owner announced (ADR 0561 §3: the + // controller's lease, which its announcement marks readable by nobody). + granted = append(granted, r) case reader != "" && ok && said && s.Keeps(name): if s.ReadableBy(name, reader) { granted = append(granted, r) @@ -95,3 +100,8 @@ func NotAnnounced(r Records) []string { sort.Strings(out) return out } + +// namedReader says the composer names module among the readers of read (NamedReaders). +func namedReader(module, read string) bool { + return slices.Contains(NamedReaders[strings.TrimSpace(read)], module) +} diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 52a7d8a..dea1de5 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -2,6 +2,7 @@ package buscompose import ( "encoding/json" + "errors" "os" "path/filepath" "reflect" @@ -9,8 +10,11 @@ import ( "slices" "strings" "testing" + "time" + "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go/jetstream" ) // Defends issue 1377 and novox/hq ADR 0547 §5: this is the controller's bus composer copied, and it composes byte for @@ -51,6 +55,26 @@ func mayBeyondTheControllers(user string) map[string]bool { VaultSecretChanged: true} } +// engineBeyond is a node's engine holding its node seat and asking the operator in its own name (novox/hq ADR 0563 §1, +// §3), beyond the controller's on purpose: its own node's verbs, events and discovery, the bus's discovery asked, and +// the router's seat traffic under node-engine-. +func engineBeyond(user, g string) bool { + node, ok := strings.CutPrefix(user, "node.") + if !ok { + return false + } + return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) || + strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) || + strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") +} + +// createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream +// and name (novox/hq ADR 0561 §6): beyond the controller's on purpose, since the controller made them alone. +func createsAnAccountsConsumer(user, g string) bool { + return strings.HasSuffix(user, "."+GrantsModule) && + (strings.HasPrefix(g, "$JS.API.CONSUMER.CREATE.") || strings.HasPrefix(g, "$JS.API.CONSUMER.INFO.")) +} + // quotedToken is a quoted token of a composed list. var quotedToken = regexp.MustCompile(`"[^"]*"`) @@ -102,7 +126,8 @@ func grantsAsTheControllers(t *testing.T, got, want string) { } } for g := range now { - if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) { + if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) && + !createsAnAccountsConsumer(user, g) && !engineBeyond(user, g) { t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g) } } @@ -119,6 +144,8 @@ func grantsAsTheControllers(t *testing.T, got, want string) { } } +const SignersBucketName = "mesh-vault_signers" + func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { // ADR 0567 §3, §9: a node reads every key of the seat's placement and its candidates (it composes with the whole // proposal laid over), and none of its open proposals; it writes neither. @@ -221,6 +248,55 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS") } }) + // ADR 0563 §1, §3, §6: each node's engine alone answers its own node's node-engine verbs and says its events, any + // runtime may call them, and it asks the operator under node-engine- alone. + t.Run("a node's engine holds its node seat and asks in its own name", func(t *testing.T) { + router := Seat{Name: AsksSeat, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, + ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}} + users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{ + "one": {{Module: "messenger", Holds: []Seat{router}, Invokes: []string{"seat:" + EngineSeat + ".root-free"}}}, + "two": {{Module: RuntimeModule}, {Module: "shop", Invokes: []string{"*"}}}}}) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + answers := MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") || + MaySubscribe(perms, "mesh.seat."+EngineSeat+".call.root-free.one.messenger~one") + if engineOne := u.Kind == KindNode && u.Node == "one"; answers != engineOne { + t.Errorf("%s answers one's root-free: %v", u.Username(), answers) + } + switch { + case u.Kind == KindNode && u.Node == "one": + for subject, want := range map[string]bool{ + "mesh.seat.operator-channel.accept.ask.node-engine-one": true, + "mesh.seat.operator-channel.accept.cancel.node-engine-one": true, + "mesh.seat.operator-channel.accept.ask.node-engine-two": false, + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-one.hand-over-1": true, + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-two.hand-over-1": false, + "mesh.seat." + EngineSeat + ".event.done.one": true, + "mesh.seat." + EngineSeat + ".event.done.two": false, + "$SRV.INFO": true, + } { + if got := MayPublish(perms, subject); got != want { + t.Errorf("node one may publish %s: %v, want %v", subject, got, want) + } + } + if !MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-one") || + MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-two") || + !MaySubscribe(perms, "$SRV.INFO."+EngineSeat) || MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.hand-over.two") { + t.Errorf("node one hears %v", perms.Subscribe) + } + case u.Kind == KindNodeTools || u.Module == "messenger": + if !MayPublish(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") { + t.Errorf("%s cannot call one's root-free", u.Username()) + } + } + } + }) // Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no // other node's. t.Run("a node asks the vault its own secrets", func(t *testing.T) { @@ -239,6 +315,21 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { if !slices.Contains(perms.Subscribe, VaultSecretChanged) { t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe) } + // L3 s6 (ADR 0550 §2): while the vault serves `sign`, a node asks it in its own name alone, and reads the vault's + // `signers` and the lease key, never writing either. + signs, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", AsksTheVaultToSign: true}) + signersGet := "$JS.API.DIRECT.GET.KV_" + SignersBucketName + ".$KV." + SignersBucketName + ".key" + leaseGet := "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder" + for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true, leaseGet: true, + VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false, + "$KV." + LeaseBucket + ".holder": false} { + if MayPublish(signs, subject) != want { + t.Errorf("a node asking sign may publish %s: %v, want %v", subject, !want, want) + } + } + if MayPublish(perms, VaultSignCall(one)) || MayPublish(perms, signersGet) { + t.Error("a node asks sign or reads signers while the vault does not serve sign") + } // And what a module on another node serves from its settings, that key alone (mailu's smtp domain, issue 1126). users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{ "two": {{Module: "mailer", Served: []string{"domain", "not.a.token"}}}}}) @@ -293,4 +384,174 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { } grantsAsTheControllers(t, got, string(want)) }) + // 774/AC2 (novox/hq ADR 0561 §2): a wanted account round-trips through the grants part's state `accounts`, keyed by + // its user, at the subject `$KV.mesh-grants_accounts.`, read by the vault alone; and the vault's account-issued + // names the account, never a value. + t.Run("a wanted account is kept by its user and read by the vault alone", func(t *testing.T) { + for _, w := range []WantedAccount{ + {User: "ace.mesh-build-go", Kind: KindModule, Node: "ace", Module: "mesh-build-go", URL: "nats://novox.mesh:4222", + Fingerprint: "ab", Claims: []ClaimedSeat{{Seat: "node-build-go", Scope: "node", Serves: []string{"build"}}}}, + {User: "novox.mesh-mail.send", Kind: KindProcess, Node: "novox", Module: "mesh-mail", Process: "send", + URL: "nats://novox.mesh:4222"}, + {User: "provisioner.g14", Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner-client", + URL: "nats://novox.mesh:4222", Fingerprint: "ab"}, + } { + if err := w.Valid(); err != nil { + t.Fatalf("%s: %v", w.User, err) + } + raw, err := json.Marshal(w) + if err != nil { + t.Fatal(err) + } + back, err := ReadWantedAccount(w.User, raw) + if err != nil || !back.Same(w) { + t.Errorf("%s does not round-trip: %+v, %v", w.User, back, err) + } + if got := AccountsSubject(w.User); got != "$KV.mesh-grants_accounts."+w.User { + t.Errorf("%s is kept at %s", w.User, got) + } + if _, err := ReadWantedAccount("someone.else", raw); err == nil { + t.Errorf("%s was read under another user's key", w.User) + } + } + for _, bad := range []WantedAccount{ + {User: "ace.x", Kind: KindModule, Node: "ace", Module: "y", URL: "nats://a:1"}, + {User: "ace.x.p", Kind: KindProcess, Node: "ace", Module: "x", URL: "nats://a:1"}, + {User: "node.ace", Kind: KindNode, Node: "ace", URL: "nats://a:1"}, + {User: "controller", Kind: KindController, URL: "nats://a:1"}, + {User: "ace.x", Kind: KindModule, Node: "ace", Module: "x"}, + } { + if bad.Valid() == nil { + t.Errorf("%+v is taken as a wanted account", bad) + } + } + if ReaderMay("mesh-delivery-assigner", GrantsAccounts) || !ReaderMay(VaultSeat, GrantsAccounts) { + t.Errorf("%s is read by %v alone, want the vault's", GrantsAccounts, NamedReaders[GrantsAccounts]) + } + e := AccountIssued{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x", Generation: 1} + raw, _ := json.Marshal(e) + if strings.Contains(string(raw), "password") || strings.Contains(string(raw), "hash") || + AccountIssuedSubject != "mesh.mod.mesh-vault.event.account-issued" { + t.Errorf("account-issued says %s at %s", raw, AccountIssuedSubject) + } + }) + // 774/AC3 (novox/hq ADR 0561 §2): the account the vault seals is, byte for byte, what the controller hands for each of + // its three kinds: testdata/controller-credentials.json was written by the controller's own code (its "how"). + t.Run("a held account is the controller's shape byte for byte", func(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "controller-credentials.json")) + if err != nil { + t.Fatal(err) + } + var captured struct { + Rows []struct{ Name, Held string } + } + if err := json.Unmarshal(raw, &captured); err != nil { + t.Fatal(err) + } + vault := ClaimedSeat{Seat: "mesh-vault", Scope: "mesh", Serves: []string{"get", "accept", "secret-ask", "issued", + "rotate", "move-in", "node-key", "own", "move-asks"}} + controller := ClaimedSeat{Seat: "mesh-controller", Scope: "mesh", Serves: strings.Split("tools,calls,status,nodes,"+ + "node,placement-export,modules,seats,builds,plans,delivery-plan,delivery-order,delivery-check,deliver,"+ + "delivery-stop,delivery-backups-again,delivery-walks,plan,assign,unassign,pin,unpin,push,rotate,give,issued,"+ + "secret-move,secret-ask,issue,token,settings,command,queue,cancel,clear,replay,try,kill,pause,resume,hand-act,"+ + "drill,warranted,hand-acts,durations,conditions,healers,doctor,upgrade,bus,retire,cleanup,judge-directories,"+ + "root-free,dead-letters,data,build,artifacts,held,collect,move-placement,move-config,mirrors,images", ",")} + at := "nats://10.0.0.1:4222" + wanted := map[string]struct { + w WantedAccount + password string + }{ + "module": {WantedAccount{User: "laptop.cap-bot", Kind: KindModule, Node: "laptop", Module: "cap-bot", URL: at, + Fingerprint: "ab:cd", Claims: []ClaimedSeat{vault, {Seat: "node-service-manager", Scope: "node", + Serves: []string{"status"}}, controller, {Seat: "no-such-seat", Scope: "node"}}}, "pw-module"}, + "module-no-claims-no-fingerprint": {WantedAccount{User: "laptop.cap-plain", Kind: KindModule, Node: "laptop", + Module: "cap-plain", URL: at}, "pw-plain"}, + "provisioner": {WantedAccount{User: "provisioner.laptop", Kind: KindProvisioner, Node: "laptop", Module: "nats", + URL: at, Fingerprint: "ab:cd"}, "pw-prov"}, + "process": {WantedAccount{User: "laptop.mesh-mail.send", Kind: KindProcess, Node: "laptop", Module: "mesh-mail", + Process: "send", URL: at, Fingerprint: "ab:cd"}, "pw-proc"}, + } + if len(captured.Rows) != len(wanted) { + t.Fatalf("%d rows captured, %d wanted", len(captured.Rows), len(wanted)) + } + for _, row := range captured.Rows { + c, ok := wanted[row.Name] + if !ok { + t.Fatalf("no wanted account for the captured %s", row.Name) + } + got, err := HeldCredential(c.w, c.password) + if err != nil || string(got) != row.Held { + t.Errorf("%s:\n got %s (%v)\nwant %s", row.Name, got, err, row.Held) + } + } + if _, err := HeldCredential(wanted["module"].w, ""); err == nil { + t.Error("an account was held with no password") + } + }) + // 774 subtasks 6 and 8 (novox/hq ADR 0561 §3, §6): minting and the grants part's consumers turn on only while the + // lease is handed to a holder that is no controller; the vault and the grants part alone read the lease, whatever the + // controller announced; a module's consumer is created as the controller's EnsureConsumer creates it. + t.Run("the lease turns minting on only when handed away; a consumer is created as the controller's", func(t *testing.T) { + for _, row := range []struct { + name string + raw string + found bool + err error + on bool + }{ + {"a controller holds it", `{"instance":"novox/1234/2026-10-27T20:00:00Z","epoch":57}`, true, nil, false}, + {"handed away", `{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`, true, nil, true}, + {"expired or deleted", ``, false, nil, false}, + {"unreadable", ``, false, errors.New("refused"), false}, + {"not a holder", `[1]`, true, nil, false}, + {"both a controller and a handed holder", `{"instance":"novox/1","handed-to":"x"}`, true, nil, false}, + } { + if on, why := LeaseHandedAway([]byte(row.raw), row.found, row.err); on != row.on || why == "" { + t.Errorf("%s: on %v (%s), want %v", row.name, on, why, row.on) + } + // The assigner's grants source follows it: the grants part's bucket only for a handed-away lease (issue 774). + on, _ := LeaseHandedAway([]byte(row.raw), row.found, row.err) + if bucket, _ := delivery.GrantSource(on); (bucket == delivery.PartGrantsBucket) != row.on { + t.Errorf("%s: the grants source is %s", row.name, bucket) + } + } + announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox")} + for reader, want := range map[string]bool{VaultSeat: true, GrantsModule: true, "mesh-delivery-assigner": true, "mesh-delivery-view": false} { + granted, _ := ReadsAnnouncedBy(reader, []string{ControllerLease}, announced) + if (len(granted) == 1) != want { + t.Errorf("%s reads the lease: %v, want %v", reader, granted, want) + } + } + c, _ := ConsumerFor(Principal{Kind: KindModule, Node: "a", Module: "m", Consumes: []string{"x.happened"}}) + cfg := ConsumerConfig(c) + if cfg.Durable != "a_m" || cfg.AckPolicy != jetstream.AckExplicitPolicy || cfg.AckWait != 30*time.Second || + cfg.MaxDeliver != 5 || cfg.DeliverPolicy != jetstream.DeliverNewPolicy || len(cfg.FilterSubjects) != 2 || + cfg.Description == "" { + t.Errorf("a module's consumer is created as %+v", cfg) + } + // The grants part is granted the create of each account's consumer by its stream and name, and no other. + users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": { + {Module: GrantsModule}, {Module: "m", Consumes: []string{"x.happened"}}, {Module: "quiet"}}}}) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + if u.Module != GrantsModule || u.Kind != KindModule { + continue + } + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + if !MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_quiet") || + MayPublish(perms, "$JS.API.CONSUMER.DELETE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m.mesh.mod.x.event.happened") { + t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers) + } + } + q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"}, + QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3})) + if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy { + t.Errorf("a process's queue consumer is created as %+v", q) + } + }) } diff --git a/go/buscompose/create.go b/go/buscompose/create.go index 17adb72..0147716 100644 --- a/go/buscompose/create.go +++ b/go/buscompose/create.go @@ -86,3 +86,30 @@ func ModuleStreamConfig(m ModuleStream) jetstream.StreamConfig { } return cfg } + +// ConsumerConfig is a pull consumer as the controller's EnsureConsumer creates it (mesh-controller internal/broker +// JetStream.EnsureConsumer, a consumer not there yet): its durable name, explicit acks, its ack wait, max deliver and max +// ack pending, its description, one filter or several, and from the stream's end when it is made FromNow. The grants part +// creates a module's and a process's consumers with it from the lease write (novox/hq ADR 0561 §6): a create alone, +// never an update of one the bus holds. A push consumer or a queue group is the controller's own and never made here. +func ConsumerConfig(c Consumer) jetstream.ConsumerConfig { + cfg := jetstream.ConsumerConfig{ + Durable: c.Name, + AckPolicy: jetstream.AckExplicitPolicy, + AckWait: time.Duration(c.AckWaitSeconds) * time.Second, + MaxDeliver: c.MaxDeliver, + MaxAckPending: c.MaxAckPending, + Description: c.Why, + } + switch len(c.Filters) { + case 0: + case 1: + cfg.FilterSubject = c.Filters[0] + default: + cfg.FilterSubjects = c.Filters + } + if c.FromNow { + cfg.DeliverPolicy = jetstream.DeliverNewPolicy + } + return cfg +} diff --git a/go/buscompose/creates_streams_test.go b/go/buscompose/creates_streams_test.go index 6ea8184..98892d1 100644 --- a/go/buscompose/creates_streams_test.go +++ b/go/buscompose/creates_streams_test.go @@ -23,6 +23,8 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) { {Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}}, {Module: RuntimeModule}, }}, + // The node-engine's own manifest, placed on every node though no node is assigned it (novox/hq ADR 0563 §2, §3). + Engine: &Declared{Module: EngineModule, State: []Bucket{{Module: EngineModule, Name: EngineActsState}}}, } users, err := Users(records) if err != nil { @@ -64,6 +66,15 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) { {GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false}, {GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false}, {"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false}, + // The node-engine's acts: created by the grants part, written by each node's engine under its own keys alone. + {GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-host_acts", true}, + {"node.ace", "$JS.API.STREAM.CREATE.KV_mesh-host_acts", false}, + {"node.ace", "$KV.mesh-host_acts.ace.act.hand-over-1", true}, + {"node.ace", "$KV.mesh-host_acts.ace.open.hand-over.abc", true}, + {"node.ace", "$KV.mesh-host_acts.novox.act.hand-over-1", false}, + {"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.ask.hand-over-1", true}, + {"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.ask.hand-over-1", false}, + {"novox.node-tools", "$KV.mesh-host_acts.novox.act.hand-over-1", false}, // The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other. {GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true}, {GrantsModule, "$O.mesh-grants-user-list.C.abc", true}, diff --git a/go/buscompose/derived.go b/go/buscompose/derived.go index 04e0e87..a727e84 100644 --- a/go/buscompose/derived.go +++ b/go/buscompose/derived.go @@ -247,6 +247,30 @@ func ConsumersOf(users []Principal) []ModuleConsumer { return out } +// AccountConsumers are the durable consumers an account needs (novox/hq ADR 0561 §6): of ConsumersOf, each module's on +// EVENTS and each process's on its own work queue, never one on a module stream it hears (those the controller makes with +// the stream's grant). The grants part creates them from the lease write, with these configurations. +func AccountConsumers(users []Principal) []ModuleConsumer { + queues := map[string]bool{} + for _, p := range users { + for _, q := range p.Queues { + queues[q.Stream] = true + } + for _, d := range p.Carries { + for _, q := range d.Queues { + queues[q.Stream] = true + } + } + } + var out []ModuleConsumer + for _, mc := range ConsumersOf(users) { + if mc.Consumer.Stream == consumerStream(Principal{Kind: KindModule}) || queues[mc.Consumer.Stream] { + out = append(out, mc) + } + } + return out +} + // HolderConsumerFor is the worker a seat's holders share on that seat's work queue. // // **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR diff --git a/go/buscompose/engine.go b/go/buscompose/engine.go new file mode 100644 index 0000000..501084b --- /dev/null +++ b/go/buscompose/engine.go @@ -0,0 +1,45 @@ +package buscompose + +import ( + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module +// claims it. Its verbs and events are the catalogue's (catalogue.EngineVerbs), read here rather than copied. + +// EngineSeat is the seat. +const EngineSeat = catalogue.EngineSeat + +// EngineModule is the node-engine's manifest's module name, and EngineActsState its state the acts asked through the +// node seat and its own asks are kept in (ADR 0563 §2, §3: "the engine's own state"): the bucket mesh-host_acts, keys +// `.act.`, `.open..` and `.ask.`, each written by that node's engine alone. +const ( + EngineModule = "mesh-host" + EngineActsState = "acts" +) + +// EngineActsBucket is that state's bucket. +var EngineActsBucket = BucketName(EngineModule, EngineActsState) + +// engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it +// answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the +// seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR +// 0563 §6), and asks the operator under asks.EngineAsker(node) on the router's seat as its holder declares it, as any +// user of that seat is granted. +func engineGrants(p Principal) (pub, sub []string) { + for _, v := range catalogue.EngineVerbs() { + sub = append(sub, "mesh.seat."+EngineSeat+".tool."+v.Name+"."+p.Node) + } + for _, e := range catalogue.EngineEvents { + pub = append(pub, "mesh.seat."+EngineSeat+".event."+e+"."+p.Node) + } + sub = append(sub, announcing(EngineSeat)...) + pub = append(pub, discovering()...) + tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil)) + // And its acts and asks, under its own node's keys alone; never the bucket's creation, which is the grants part's. + if own, err := NodeKeysGrants(EngineActsBucket, p.Node); err == nil { + pub = append(pub, own...) + } + return append(pub, tp...), append(sub, ts...) +} diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 70d7cc9..74981b0 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -217,6 +217,11 @@ type Principal struct { // ADR 0547 §2, ADR 0550 §5, issue 1126): its modules' own secrets sealed to its key, for the compositions it makes // itself. Set while the vault's holder serves it. AsksItsOwnSecrets bool + // AsksTheVaultToSign is a machine principal whose node-engine may ask the vault's `sign` in its own name, read the + // vault's public signing halves (`signers`) and the controller's lease key (novox/hq ADR 0550 §2): its own node's + // certificate, the keys it trusts, and the lease write that drops the controller's key. Set while the vault's holder + // serves `sign`. + AsksTheVaultToSign bool // ServedReads are, for a machine principal, the keys of mesh-config's resolved values that other nodes' modules serve // from (`..`, catalogue.ServedSettings): what every consumer is told, and so what a node composing // its own modules reads of a provider elsewhere (issue 1126), each key alone. Sorted, none twice. @@ -232,6 +237,10 @@ type Principal struct { // by name. Created only, never updated or deleted: a new module state is on the bus when the grants part hears its // manifest, not at the controller's next start (issue 1790). CreatesStreams []string + // CreatesConsumers are the durable consumers the grants part's principal alone may create beside the controller + // (novox/hq ADR 0561 §6): each placed module's on EVENTS and each process's on its work queue (AccountConsumers), by + // stream and name. Created only, never updated or deleted. + CreatesConsumers []ModuleConsumer // 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 @@ -356,6 +365,10 @@ var VaultNodeKey = SeatVerb{Seat: "mesh-vault", Verb: "node-key"} // key (issue 1126, ADR 0550 §5); granted as Principal.AsksItsOwnSecrets says. var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"} +// VaultSign is the vault's verb a machine's node-engine asks in its own name for its own node's certificate (novox/hq +// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says. +var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb} + // 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 @@ -952,6 +965,18 @@ func PermissionsFor(p Principal) (Permissions, error) { // on it rather than at the next unrelated change (ADR 0547 §2). sub = append(sub, VaultSecretChanged) } + // And, while the vault serves `sign`, its own node's certificate in its own name, the vault's public signing + // halves, read and watched, and the controller's lease key, read and watched, which says when the controller's key + // is no longer trusted (ADR 0550 §2). Read only. + if p.AsksTheVaultToSign { + pub = append(pub, VaultSignCall(p)) + pub = append(pub, readGrants(delivery.SignersBucket, "")...) + pub = append(pub, readGrants(LeaseBucket, leaseKey)...) + } + // 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...) + sub = append(sub, es...) // And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR // 0547 clause 7): one key, and an object by its name. if p.FollowsGrantsUserList { @@ -972,6 +997,8 @@ func PermissionsFor(p Principal) (Permissions, error) { // And creating each stream a manifest declares, by its own name (the operator, 2026-10-26 08:54; issue // 1790): the create and the read of that one stream, never an update, a delete or a stream not declared. pub = append(pub, CreateStreamGrants(p.CreatesStreams)...) + // And creating each durable consumer an account needs, by its stream and name (ADR 0561 §6). + pub = append(pub, CreateConsumerGrants(p.CreatesConsumers)...) // And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7). pub = append(pub, GrantsUserListWrites()...) } @@ -1659,6 +1686,21 @@ func CreateStreamGrants(streams []string) []string { return out } +// CreateConsumerGrants are the subjects that create each consumer by its stream and name, and read its info back: the +// create of the client library's CreateConsumer for a consumer with no filter or several, never one filter's form (a +// wildcard in a filter would widen the grant), an update or a delete. +func CreateConsumerGrants(consumers []ModuleConsumer) []string { + var out []string + for _, mc := range consumers { + c := mc.Consumer + if !safeSubject.MatchString(c.Stream) || !safeSubject.MatchString(c.Name) || len(c.Filters) == 1 { + continue + } + out = append(out, "$JS.API.CONSUMER.CREATE."+c.Stream+"."+c.Name, "$JS.API.CONSUMER.INFO."+c.Stream+"."+c.Name) + } + return out +} + // GrantsModule is the one module whose principal publishes the modules' memberships (novox/hq ADR 0547 §5): the grants // part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here. const GrantsModule = "mesh-grants" @@ -1827,6 +1869,11 @@ func VaultOwnCall(p Principal) string { return "mesh.seat." + VaultOwn.Seat + "." + CallKind + "." + VaultOwn.Verb + "." + CallerToken(p.Username()) } +// VaultSignCall is the subject a machine principal's node-engine asks the vault's `sign` on, in its own name. +func VaultSignCall(p Principal) string { + return "mesh.seat." + VaultSign.Seat + "." + CallKind + "." + VaultSign.Verb + "." + CallerToken(p.Username()) +} + // VaultSecretChanged is the vault's event that a secret changed (a name and its generation, never a value), which a // node composing its own hears to compose again (issue 1126, ADR 0547 §2). var VaultSecretChanged = "mesh.mod." + VaultOwn.Seat + ".event.secret-changed" diff --git a/go/buscompose/testdata/controller-credentials.json b/go/buscompose/testdata/controller-credentials.json new file mode 100644 index 0000000..ab11cf1 --- /dev/null +++ b/go/buscompose/testdata/controller-credentials.json @@ -0,0 +1,21 @@ +{ + "how": "Written by mesh-controller 0fbb5ef's own issueWith, sealProvisionerClient and heldProcessAccount (cmd/mesh-controller), run unchanged in a scratch integration test against a local store with the default seats seeded, on 2026-10-27 (issue 774): each row is the exact value the controller handed the vault. Never edit a row by hand: capture it again.", + "rows": [ + { + "name": "module", + "held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"module\":\"cap-bot\",\"user\":\"laptop.cap-bot\",\"password\":\"pw-module\",\"claims\":[{\"seat\":\"mesh-vault\",\"scope\":\"mesh\",\"serves\":[\"get\",\"accept\",\"secret-ask\",\"issued\",\"rotate\",\"move-in\",\"node-key\",\"own\",\"move-asks\"]},{\"seat\":\"node-service-manager\",\"scope\":\"node\",\"serves\":[\"status\"]},{\"seat\":\"mesh-controller\",\"scope\":\"mesh\",\"serves\":[\"tools\",\"calls\",\"status\",\"nodes\",\"node\",\"placement-export\",\"modules\",\"seats\",\"builds\",\"plans\",\"delivery-plan\",\"delivery-order\",\"delivery-check\",\"deliver\",\"delivery-stop\",\"delivery-backups-again\",\"delivery-walks\",\"plan\",\"assign\",\"unassign\",\"pin\",\"unpin\",\"push\",\"rotate\",\"give\",\"issued\",\"secret-move\",\"secret-ask\",\"issue\",\"token\",\"settings\",\"command\",\"queue\",\"cancel\",\"clear\",\"replay\",\"try\",\"kill\",\"pause\",\"resume\",\"hand-act\",\"drill\",\"warranted\",\"hand-acts\",\"durations\",\"conditions\",\"healers\",\"doctor\",\"upgrade\",\"bus\",\"retire\",\"cleanup\",\"judge-directories\",\"root-free\",\"dead-letters\",\"data\",\"build\",\"artifacts\",\"held\",\"collect\",\"move-placement\",\"move-config\",\"mirrors\",\"images\"]},{\"seat\":\"no-such-seat\",\"scope\":\"node\"}]}" + }, + { + "name": "module-no-claims-no-fingerprint", + "held": "{\"url\":\"nats://10.0.0.1:4222\",\"node\":\"laptop\",\"module\":\"cap-plain\",\"user\":\"laptop.cap-plain\",\"password\":\"pw-plain\"}" + }, + { + "name": "provisioner", + "held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"user\":\"provisioner.laptop\",\"password\":\"pw-prov\"}" + }, + { + "name": "process", + "held": "{\"url\":\"nats://10.0.0.1:4222\",\"fingerprint\":\"ab:cd\",\"node\":\"laptop\",\"module\":\"mesh-mail\",\"user\":\"laptop.mesh-mail.send\",\"password\":\"pw-proc\"}" + } + ] +} diff --git a/go/buscompose/userlist_switch.go b/go/buscompose/userlist_switch.go index a827741..6e2cb07 100644 --- a/go/buscompose/userlist_switch.go +++ b/go/buscompose/userlist_switch.go @@ -77,8 +77,12 @@ func grantsOwnObjects(p Principal, pattern string) bool { // readers too (announce.State.Readers); both are honoured. const VaultBusAccounts = "mesh-vault.bus-accounts" -// NamedReaders are the states only the modules named may read, by `.`. -var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}} +// NamedReaders are the states only the modules named may read, by `.`: the grants part's wanted accounts +// are read by the vault alone (novox/hq ADR 0561 §2). +// The controller's lease is read by the parts that turn on at its write (ADR 0561 §3, §6), and by the assigner, which +// reads the catalogue's placement alone from then (ADR 0549 §5, issue 774). +var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat}, + ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule}} // 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 71be224..5629510 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -134,6 +134,11 @@ type Records struct { // account agents run as, judged unable to become root by its node-engine, and serves no login shell // execute. A machine absent is not free: no record of a pass is no pass. RootFree map[string]bool + // Engine is the node-engine's own declaration, from its manifest (EngineModule): placed on every node though no node is + // assigned it, since every node runs it (novox/hq ADR 0563 §2, §3). Its state, logs and streams are created by the + // 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 // 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 @@ -149,6 +154,7 @@ func Users(r Records) ([]Principal, error) { AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}} served := servedReadsOf(r) + grants := -1 for _, node := range sortedCopy(r.Nodes) { witness := false var checks []string @@ -164,7 +170,7 @@ func Users(r Records) ([]Principal, error) { } out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks, MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), - AsksItsOwnSecrets: servesVerb(r, VaultOwn), + AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)}) // **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. @@ -225,6 +231,7 @@ func Users(r Records) ([]Principal, error) { // 08:54): every placed module's buckets, logs and streams, each by its stream's name. if d.Module == GrantsModule { out[len(out)-1].CreatesStreams = declaredStreamsOf(r) + grants = len(out) - 1 } // **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the // account its own runtime runs on (ADR 0259 §8's runs-as): the one module granted it. @@ -258,6 +265,11 @@ func Users(r Records) ([]Principal, error) { out = append(out, p) } + // 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) + } + // Refused here rather than discovered by the server. Two users with one name is a file the // server reads as one of them, and which one depends on the order — so a module assigned to a // node twice, or a person named after nothing, is a composition that must not be written. @@ -444,7 +456,15 @@ func runnerServes(d Declared) bool { // name. What the grants part may create, and nothing beside. func declaredStreamsOf(r Records) []string { seen := map[string]bool{} + placed := [][]Declared{} for _, mods := range r.Assigned { + placed = append(placed, mods) + } + // The node-engine's manifest is placed on every node (ADR 0563): its declared state is created as a placed module's. + if r.Engine != nil { + placed = append(placed, []Declared{*r.Engine}) + } + for _, mods := range placed { for _, d := range mods { for _, b := range d.State { seen["KV_"+b.Bucket()] = true diff --git a/go/buscompose/writers.go b/go/buscompose/writers.go index fff55d1..7649cb2 100644 --- a/go/buscompose/writers.go +++ b/go/buscompose/writers.go @@ -245,6 +245,11 @@ var WritersTable = []WriterRow{ {State: "a machine's have", Writer: "the node-engine of the machine, under its own keys `.>`", KeptIn: "key-value " + delivery.HaveBucket + ", twenty revisions of each key", Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys}, + // The acts a node's engine was asked through its node seat, and its own asks (novox/hq ADR 0563 §2, §3): the + // node-engine's declared state, created by the grants part, written by each node's engine under its own keys. + {State: "a machine's hand acts", Writer: "the node-engine of the machine, under its own keys `.>`, compare-and-set", + KeptIn: "key-value " + EngineActsBucket + ", the node-engine's declared state acts", + Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket), Writes: ownNodeKeys}, // The contents of a declaration's files over 64 KiB (novox/hq ADR 0486 §2): the controller alone writes the bucket, each // object under the digest of its content; a node-engine reads one by its name, with no list and no write. {State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content; the " + diff --git a/go/caller_test.go b/go/caller_test.go index 7fae75c..8f87cb1 100644 --- a/go/caller_test.go +++ b/go/caller_test.go @@ -75,6 +75,21 @@ func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) { if got != "shanks.node-tools" { t.Fatalf("with a caller in the arguments the tool read %q, want the runtime's", got) } + // A module's trusted list of callers (issue 774, ADR 0563 §8) grants the callers it names as the runtime proved them: + // a module on any machine, . exactly, person. exactly; no wildcard, never one unproven. + callers, err := ParseCallers([]byte(`{"callers":["node-tools","ace.claude-code","person.jo","*"]}`)) + if err != nil { + t.Fatal(err) + } + for caller, want := range map[string]bool{"shanks.node-tools": true, "ace.claude-code": true, "g14.claude-code": false, + "person.jo": true, "person.node-tools": false, Unproven: false, "": false, "novox.mesh-vault": false} { + if callers.Allows(caller) != want { + t.Errorf("%q allowed: %v, want %v", caller, !want, want) + } + } + if _, err := ParseCallers([]byte(`{"callers":["x"],"more":1}`)); err == nil { + t.Error("a list with a field it does not know was read") + } } // A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is diff --git a/go/callers.go b/go/callers.go new file mode 100644 index 0000000..74a7ce3 --- /dev/null +++ b/go/callers.go @@ -0,0 +1,48 @@ +package stdio + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +// Callers is a module's trusted list of who may call a verb of its (novox/hq ADR 0459 §2, ADR 0563 §8), as the mesh +// writes a list setting into a file the module reads: `{"callers": [...]}`. An entry is a module's name (that module on +// any machine), exactly ., or exactly person.. Each caller is the runtime's proof (CallerOf), never +// an argument; nobody beyond the list is granted, a wildcard is no entry, and an unproven caller never matches. +type Callers []string + +// ParseCallers reads a callers file: the list under `callers`, and nothing else. +func ParseCallers(raw []byte) (Callers, error) { + var f struct { + Callers Callers `json:"callers"` + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&f); err != nil { + return nil, fmt.Errorf("the callers setting is not a list of callers: %v", err) + } + return f.Callers, nil +} + +// Allows says whether caller, as the runtime proved it, is one the list names. +func (c Callers) Allows(caller string) bool { + if caller == "" || caller == Unproven { + return false + } + for _, entry := range c { + if entry == "" || strings.ContainsAny(entry, "*> ") { + continue + } + if entry == caller { + return true + } + if !strings.Contains(entry, ".") { + if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") { + return true + } + } + } + return false +} diff --git a/go/catalogue/checker_seat.go b/go/catalogue/checker_seat.go new file mode 100644 index 0000000..e9a5821 --- /dev/null +++ b/go/catalogue/checker_seat.go @@ -0,0 +1,37 @@ +package catalogue + +// The delivery's checker (novox/hq ADR 0547 §10, issue 774): one holder for the mesh, the module +// mesh-delivery-checker, which judges a pull request's head and a pushed head the same way. The try the +// controller forwarded to it is a verb of this seat, answered by the checker with the verdict it gives a pull +// request, so the try outlives the controller. + +// CheckerSeat is the seat mesh-delivery-checker holds. +const CheckerSeat = "mesh-delivery-checker" + +// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them +// everywhere. +func checkerVerbs() []Verb { + return []Verb{ + {Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " + + "judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " + + "levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " + + "reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " + + "4 wait or run at once, mesh-wide.", + Input: schema(map[string]string{"repository": "/", + "head": "the full commit to try, 40 or 64 hex digits", + "trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", + "tests": "a go test -run pattern: only those tests run, and nothing is kept", + "level": "unit or integration: only this level; both when absent"}, + []string{"repository", "head"}), + Replaces: []string{"sh merge-check.sh", "docker run postgres"}}, + {Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " + + "(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " + + "it was merged onto, the time it took and what failed first. Read-only.", + Input: schema(map[string]string{"id": "the try's id, try-"}, []string{"id"})}, + {Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " + + "what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " + + "id. A try that ended already is refused.", + Input: schema(map[string]string{"id": "the try's id, try-", "why": "why it is ended, in words"}, + []string{"id", "why"})}, + } +} diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go new file mode 100644 index 0000000..b3b695b --- /dev/null +++ b/go/catalogue/engine_seat.go @@ -0,0 +1,57 @@ +package catalogue + +// The node seat node-engine (novox/hq ADR 0563 §1): held on every node by that node's engine (mesh-host), which serves +// its verbs on its own bus account. No module claims it and no assignment holds it: what the mesh knows of its holder +// is the node's record and the engine's haves (ADR 0563 §1 changes ADR 0110 §6 and §7 for this one seat). +// +// Its verbs carry no signature (§7): the runtime proves the caller, which matters only for the record. The read-only +// ones change nothing or are bounded in the engine's own state (§2); hand-over and access-act perform nothing on the +// call, and act only on the operator's warrant to the engine's own ask (§3). + +// EngineSeat is the node seat every node's engine holds. +const EngineSeat = "node-engine" + +// EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them. +func EngineVerbs() []Verb { + return []Verb{ + {Name: "hand-over", Description: "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, " + + "§5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, " + + "reached through no link) and refuses in words, or asks the operator on their channel at the level approve, " + + "binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands " + + "it over. One ask is open per path; acts shows where it stands.", + Input: schema(map[string]string{"path": "the directory's absolute path, as the engine states it", + "why": "why it is asked, in words the operator reads"}, []string{"path"})}, + {Name: "access-act", Description: "Ask the operator for the take act on a library whose root holds data outside its " + + "group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its " + + "root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes " + + "sets the library's group, group write and setgid under its root. One ask is open per access.", + Input: schema(map[string]string{"act": "the act: take", "access": "the access, library:", + "why": "why it is asked, in words the operator reads"}, []string{"act", "access"})}, + {Name: "judge-directories", Description: "Which of this machine's declared directories the engine would refuse to " + + "make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.", + Input: schema(map[string]string{}, nil)}, + {Name: "setuid-search", Description: "Throw away the engine's last search for setuid programs and start a full one " + + "(novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while " + + "one runs, and when one was started within the hour, naming when the next may start.", + Input: schema(map[string]string{"why": "why it is asked, in words"}, nil)}, + {Name: "root-free", Description: "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only " + + "on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root " + + "without a person, and the login shell's execute not served here. Anything else is no pass, and says why. " + + "Answered by this machine's engine alone.", + Input: schema(map[string]string{}, nil)}, + {Name: "acts", Description: "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): " + + "each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the " + + "operator's warrant. Read-only.", + Input: schema(map[string]string{"limit": "at most this many, newest", "id": "one act, by its id"}, nil)}, + } +} + +// EngineEvents are what the seat says of an act, each naming the act, the path or access, the caller as the runtime +// proved it and the ask (ADR 0563 §1). +var EngineEvents = []string{"asked", "done", "refused"} + +// engineSeat is the seat's row in the closed set. +func engineSeat() Seat { + return Seat{Name: EngineSeat, Scope: ScopeNode, Serves: EngineVerbs(), Emits: append([]string(nil), EngineEvents...), + HeldByTheEngine: true, Decision: "novox/hq ADR 0563"} +} diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 3f7690a..56637b8 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -67,6 +67,10 @@ type Seat struct { // names paths. Compiled, never stored, like Receives: adding or taking away a path is a decision, // recorded, and the store's rows carry no column for it. Paths []SeatPath + // HeldByTheEngine says the seat is held on every node by that node's engine, which serves its verbs on its own bus + // account (novox/hq ADR 0563 §1): no module claims it and no assignment holds it. Compiled, never stored, like + // Receives. + HeldByTheEngine bool // Decision is the record that made it a seat. Decision string } @@ -171,7 +175,8 @@ var defaultSeats = append([]Seat{ "origin": "issued for a bus account the controller minted for a core or moved module and hands " + "at once (ADR 0374, issue 787); left out for a value given at the terminal"}), nil)}, {Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " + - "from the controller alone: the ask is recorded in the vault's store and bounded before the prompt " + + "from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR " + + "0563 §8): the ask is recorded in the vault's store and bounded before the prompt " + "opens, and what is typed is sealed to a key the vault made for the call and opens there alone.", Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for", "module": "the module", "name": "the secret's name in the module", @@ -224,6 +229,18 @@ var defaultSeats = append([]Seat{ Input: map[string]any{"type": "object", "properties": map[string]any{"asks": map[string]any{ "type": "array", "items": map[string]any{"type": "object"}, "description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome"}}}}, + {Name: "issue-account", Optional: true, Description: "Mint one bus account the grants part wants, now (novox/hq " + + "ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the " + + "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.", + 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"})}, }}, // 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). @@ -261,6 +278,8 @@ var defaultSeats = append([]Seat{ // Placement (novox/hq ADR 0567): which module runs on which node, state on the bus held by the assigner, changed only by // a proposal the nodes answer and the operator approves. Its states and events are the holder module's. {Name: PlacementSeat, Scope: ScopeMesh, Serves: placementVerbs(), Decision: "novox/hq ADR 0567"}, + // The delivery's checker (novox/hq ADR 0547 §10, issue 774): the try is its verb, not the controller's. + {Name: CheckerSeat, Scope: ScopeMesh, Serves: checkerVerbs(), Decision: "novox/hq ADR 0547"}, // Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a // delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight. // They keep their names until that migration is done deliberately, apart from the node-* pass. @@ -442,6 +461,8 @@ var defaultSeats = append([]Seat{ // A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned // on every node, then node-reads goes (ADR 0394 §9). {Name: FilesSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()}, + // The node's engine's own verbs and asks (novox/hq ADR 0563 §1): held by the engine on every node, by no module. + engineSeat(), }, // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. graphicalSessionSeats()...) @@ -515,7 +536,8 @@ func UseSeats(s []Seat) { row.Paths = d.Paths row.Replicated = d.Replicated row.PulledByKind = d.PulledByKind - row.Yields = d.Yields // never stored (ADR 0553) + row.Yields = d.Yields // never stored (ADR 0553) + row.HeldByTheEngine = d.HeldByTheEngine // never stored (ADR 0563) row.Serves = optionalAsCompiled(row.Serves, d.Serves) } merged = append(merged, row) @@ -628,6 +650,13 @@ func claimProblems(m Manifest) []string { } for _, c := range m.Claims { + if s, known := SeatNamed(c.Name); known && s.HeldByTheEngine { + // The node's engine holds it on every node, and no module may (novox/hq ADR 0563 §1): a module answering + // root-free or a hand-over would be an agent answering it. + problems = append(problems, fmt.Sprintf("%s claims %s, which every node's engine holds itself and no "+ + "module may (novox/hq ADR 0563 §1)", m.Module, c.Name)) + continue + } if _, known := SeatNamed(c.Name); known { // **A seat's scope and what it delivers are not judged here** (novox/hq ADR 0122). // This function runs wherever a manifest is parsed, and one of those places is the diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index 6f58a11..9113bd7 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -49,6 +49,43 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { []string{"placement", "proposals", "propose", "withdraw", "again", "adopt", "hand-back"}) { t.Errorf("the placement seat is %v, %v", ok, s) } + // The checker's seat serves the try, which replaces the shell's merge check (ADR 0547 §10, ADR 0245). + var checkerVerbs []string + for _, s := range Seats() { + if s.Name == CheckerSeat { + for _, v := range s.Serves { + checkerVerbs = append(checkerVerbs, v.Name) + if v.Name == "try" && strings.Join(v.Replaces, "|") != "sh merge-check.sh|docker run postgres" { + t.Errorf("try replaces %v", v.Replaces) + } + } + } + } + if strings.Join(checkerVerbs, ",") != "try,try-read,try-end" { + t.Errorf("the checker seat serves %v", checkerVerbs) + } + // The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its six + // verbs and three events, held by no assignment, and a module's claim of it refused. + if engine, ok := SeatNamed(EngineSeat); !ok || engine.Scope != ScopeNode || engine.Delivers != "" || !engine.HeldByTheEngine { + t.Errorf("node-engine is %+v, not a node seat delivering nothing that the engine holds", engine) + } else { + var verbs []string + for _, v := range engine.Serves { + verbs = append(verbs, v.Name) + } + // acts reads one act by its id too (ADR 0563 §9: the seat's tools are its command line). + if props, _ := engine.Serves[len(engine.Serves)-1].Input["properties"].(map[string]any); props["id"] == nil { + t.Errorf("acts takes no id: %v", engine.Serves[len(engine.Serves)-1].Input) + } + if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts" || + strings.Join(engine.Emits, ",") != "asked,done,refused" { + t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits) + } + } + if _, err := ParseManifest([]byte(`{"module":"thing","version":"1","claims":[{"name":"node-engine","scope":"node"}]}`)); err == nil || + !strings.Contains(err.Error(), "node-engine") { + t.Errorf("a module's claim of node-engine was not refused: %v", err) + } // Forty-six with the three build seats replacing node-build-agent and the retired mesh-build-machine (novox/hq // ADR 0525 §2, §10); forty-five with node-files (novox/hq ADR 0394); forty-four again once node-reads goes. // Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired @@ -62,8 +99,9 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired // mesh-build-machine row goes, when no registered manifest claims it. - if len(Seats()) != 47 { - t.Errorf("the mesh defines %d seats rather than 47; the set is closed, so a change here is "+ + // Forty-eight with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-nine with node-engine (novox/hq ADR 0563 §1). + if len(Seats()) != 49 { + t.Errorf("the mesh defines %d seats rather than 49; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 979502c..f0f095e 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -83,6 +83,16 @@ type Reader interface { Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) } +// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (delivery.SignersBucket, +// key delivery.SignersAuthorityKey; ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the +// controller's and the vault's; one that has not, or that answers none, composes the controller's alone, as before. +type SignersReader interface { + // VaultAuthority is the vault's authority certificate as PEM, checked (delivery.DecodeSignerAuthority); empty when the + // vault has published none. A read that fails is said by the reader and answered empty: a node that may not read the + // state yet composes as before rather than not at all. + VaultAuthority(ctx context.Context) string +} + // Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the // internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take // compose's defaults. @@ -219,6 +229,13 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module } } } + // And the vault's authority beside it, once the vault publishes one (ADR 0550 §2): a certificate either signed checks + // against the bundle. The controller's stays while a certificate it issued is in a node's record. + if sr, ok := r.(SignersReader); ok { + if vault := sr.VaultAuthority(ctx); vault != "" { + in.Authority = delivery.AuthorityBundle(in.Authority, vault) + } + } parse := func(raw []byte) (catalogue.Manifest, error) { if mesh.Predefined != nil { return catalogue.ParseManifestWith(raw, mesh.Predefined) diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index 32a9d72..122349e 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -25,14 +25,15 @@ import ( // bus is a Reader over values held in memory, counting whose config values were read. type bus struct { - placements map[string]delivery.Placement - records map[string]delivery.NodeRecord - modules map[string]delivery.Module - manifests map[string][]byte - values map[string]map[string]any // by node.module - valuesRead []string - own []compose.OwnSecretRow - ownErr error + vaultAuthority string + placements map[string]delivery.Placement + records map[string]delivery.NodeRecord + modules map[string]delivery.Module + manifests map[string][]byte + values map[string]map[string]any // by node.module + valuesRead []string + own []compose.OwnSecretRow + ownErr error // asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming // module its placement does not put on the node. asked []compose.CredentialKey @@ -74,6 +75,7 @@ func (b *bus) Served(_ context.Context, node, module string, keys []string) (map func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { return b.placements, nil } +func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority } func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil } func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil } func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) { @@ -216,6 +218,14 @@ func TestInput(t *testing.T) { wj, _ := json.MarshalIndent(want, "", " ") t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj) } + // L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's + // and the vault's; with none, the controller's alone, as above. + signing := mk() + signing.vaultAuthority = "the vault's certificate" + if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil || + bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" { + t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err) + } if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead) } diff --git a/go/delivery/assigner_test.go b/go/delivery/assigner_test.go index 6b54e1d..1adb030 100644 --- a/go/delivery/assigner_test.go +++ b/go/delivery/assigner_test.go @@ -298,6 +298,25 @@ func TestControllerGrant(t *testing.T) { if _, err := EncodeControllerGrant(ControllerGrant{Accounts: []string{""}}); err == nil { t.Errorf("a grant naming an empty account was accepted") } + // The grants part's keys are the controller's: the same key round-trips; the source is the part's bucket only for a + // handed-away lease (issue 774). + if pk, err := PartGrantKey("mesh-delivery-assigner", fp); err != nil || pk != key { + t.Errorf("PartGrantKey = %q, %v, want the controller's %q", pk, err, key) + } + if m, f, err := ParsePartGrantKey(key); err != nil || m != "mesh-delivery-assigner" || f != fp { + t.Errorf("ParsePartGrantKey = %q, %q, %v", m, f, err) + } + for _, c := range []struct { + away bool + bucket, read string + }{ + {false, "mesh-controller_grants", "mesh-controller.grants"}, + {true, "mesh-grants_grants", "mesh-grants.grants"}, + } { + if b, r := GrantSource(c.away); b != c.bucket || r != c.read { + t.Errorf("GrantSource(%v) = %q, %q, want %q, %q", c.away, b, r, c.bucket, c.read) + } + } g := ControllerGrant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}} for _, c := range []struct { g ControllerGrant diff --git a/go/delivery/buildrun/standing.go b/go/delivery/buildrun/standing.go new file mode 100644 index 0000000..c0b92e9 --- /dev/null +++ b/go/delivery/buildrun/standing.go @@ -0,0 +1,225 @@ +package buildrun + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "sync" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/process" +) + +// A build module writes . of its toolchains state on every node it runs on, and nothing deletes the key +// when the module leaves a node: no part writes another module's state, and the module is gone. A report of a node the +// build module no longer runs on is therefore no builder's (issue 1940: mesh-build-node taken off ace on 2026-10-23 left +// ace.typescript at v26.12.0, and every Node fingerprint said "builders disagree"). +// +// ToolchainGate stands between the toolchains buckets and what takes their entries in (the indexer's PutToolchain, a +// ToolchainSet): an entry is passed on only while the placement (the seat mesh-placement's state `placement`, held by +// the assigner, delivery.PlacementBucket, read by delivery.Placed; ADR 0567, ADR 0549 §3) places its build module on +// its node, and only once it was written since that placement; it is passed on as deleted otherwise, said once, and +// passed on when its node writes it again. A report written before the placement is one an earlier run of the module +// left (issue 1940, 2026-10-25 20:56: mesh-build-node put on ace again brought ace.typescript v26.12.0 of 2026-10-23 +// back into the agreement before the module ran there). Until the placement is settled, and while no node's placement +// is held (before the assigner's adopt writes the first one, or where the state cannot be read), every entry is passed +// on. Builders that run and report different versions still disagree: that refusal is the guard of novox/hq ADR 0465, +// and it stays. +// +// This replaces the copies of the gate the indexer, the builder and the checker each kept over the controller's +// assignments (issue 1940's subtask, issue 774: those go with the controller's lease, ADR 0549 §6). +type ToolchainGate struct { + mu sync.Mutex + modules map[string]bool // the build modules whose toolchains pass the gate + forward func(module, key string, value []byte, deleted bool) bool // answers whether it changed what it holds + logf func(format string, a ...any) + placed *delivery.Placed + known bool // the placement is settled + raw map[string]map[string][]byte // module -> toolchains key -> value, as the bucket holds it + left map[string]string // / -> why it was said left out, so each reason is said once +} + +// NewToolchainGate is a gate over the toolchains of modules, passing what stands on to forward and saying with logf what +// it leaves out. +func NewToolchainGate(modules []string, forward func(module, key string, value []byte, deleted bool) bool, logf func(string, ...any)) *ToolchainGate { + g := &ToolchainGate{modules: map[string]bool{}, forward: forward, logf: logf, placed: delivery.NewPlaced(), + raw: map[string]map[string][]byte{}, left: map[string]string{}} + for _, m := range modules { + g.modules[m] = true + } + return g +} + +// Toolchain takes one change of a build module's toolchains bucket in and answers whether what it passed on changed. +func (g *ToolchainGate) Toolchain(module, key string, value []byte, deleted bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.raw[module] == nil { + g.raw[module] = map[string][]byte{} + } + if deleted { + delete(g.raw[module], key) + } else { + g.raw[module][key] = value + } + return g.pass(module, key) +} + +// Placement takes one revision of the seat mesh-placement's placement in (at is when the bus wrote it) and answers whether what it +// passed on changed. A value that does not read is said, and places nothing on its node. +func (g *ToolchainGate) Placement(key string, value []byte, deleted bool, at time.Time) bool { + g.mu.Lock() + defer g.mu.Unlock() + was := g.placed.Seeded() + node, changed, err := g.placed.Put(key, value, deleted, at) + if err != nil { + g.logf("%s: %v (issue 1940)", delivery.PlacementBucket, err) + } + if !g.known { + return false + } + if was != g.placed.Seeded() { + // The first placement came, or the last went: every report's standing changed. + return g.all() + } + return g.again(node, changed) +} + +// Settled says the placement's history is read: from now on only the reports of placed nodes pass, once a node's +// placement is held. It answers whether what it passed on changed. +func (g *ToolchainGate) Settled() bool { + g.mu.Lock() + defer g.mu.Unlock() + g.known = true + return g.all() +} + +// all passes on every report again. The caller holds mu. +func (g *ToolchainGate) all() bool { + changed := false + for _, module := range slices.Sorted(maps.Keys(g.raw)) { + for _, k := range slices.Sorted(maps.Keys(g.raw[module])) { + changed = g.pass(module, k) || changed + } + } + return changed +} + +// again passes on every report of node by a build module among modules. The caller holds mu. +func (g *ToolchainGate) again(node string, modules []string) bool { + changed := false + for _, module := range modules { + if !g.modules[module] { + continue + } + for _, k := range slices.Sorted(maps.Keys(g.raw[module])) { + if n, _, err := delivery.ParseToolchainKey(k); err == nil && n == node { + changed = g.pass(module, k) || changed + } + } + } + return changed +} + +// pass hands one key on as it stands: its value when its node is placed and wrote it since (or the placement is not +// known), deleted otherwise. The caller holds mu. +func (g *ToolchainGate) pass(module, key string) bool { + value, held := g.raw[module][key] + if !held { + return g.forward(module, key, nil, true) + } + node, _, err := delivery.ParseToolchainKey(key) + why := "" + if err == nil && g.known && g.placed.Seeded() { + why = g.leftOut(module, node, value) + } + if why == "" { + delete(g.left, module+"/"+key) + return g.forward(module, key, value, false) + } + if g.left[module+"/"+key] != why { + g.left[module+"/"+key] = why + g.logf("%s.%s is left out of the agreed toolchain: %s (issue 1940)", delivery.ToolchainsBucket(module), key, why) + } + return g.forward(module, key, nil, true) +} + +// leftOut is why a node's report is no builder's, or empty when it counts: the catalogue does not place the module on its +// node, or the report was written before it did. A value that is no toolchain is passed on for its reader to refuse. +// The caller holds mu. +func (g *ToolchainGate) leftOut(module, node string, value []byte) string { + since, placed := g.placed.Since(node, module) + if !placed { + return fmt.Sprintf("the catalogue does not place %s on %s, so it is no builder", module, node) + } + tc, err := delivery.DecodeToolchain(value) + if err == nil && tc.At.Before(since) { + return fmt.Sprintf("it was written at %s, before %s was placed on %s at %s: it counts once %s writes it again", + tc.At.UTC().Format(time.RFC3339), module, node, since.UTC().Format(time.RFC3339), node) + } + return "" +} + +// FollowPlacement reads the seat mesh-placement's placement with its history into gate, says it settled, and follows it; changed +// is told whenever what the gate passes on changed. Where the state cannot be read or watched (not there, or this part +// may not read it) it says so and the gate passes every report on: not fatal, since an indexer, builder or checker that +// stops for it stops every delivery (issue 1945). An error of the watch after that ends on the channel. +func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *ToolchainGate, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) { + errs := make(chan error, 1) + kv, err := js.KeyValue(ctx, delivery.PlacementBucket) + if err != nil { + logf("the state %s cannot be read, so every node's toolchain report counts, a build node's the catalogue does not place too (issue 1940): %v", delivery.PlacementBucket, err) + return errs, nil + } + w, err := kv.WatchAll(ctx, jetstream.IncludeHistory()) + if err != nil { + logf("the state %s cannot be watched, so every node's toolchain report counts, a build node's the catalogue does not place too (issue 1940): %v", delivery.PlacementBucket, err) + return errs, nil + } + put := func(e jetstream.KeyValueEntry) bool { + return gate.Placement(e.Key(), e.Value(), e.Operation() != jetstream.KeyValuePut, e.Created()) + } + for settled := false; !settled; { + select { + case <-ctx.Done(): + _ = w.Stop() + return errs, ctx.Err() + case e, ok := <-w.Updates(): + if !ok { + return errs, errors.New("the watch of " + delivery.PlacementBucket + " ended before it settled") + } + if e == nil { + settled = true + continue + } + put(e) + } + } + if gate.Settled() { + changed(ctx) + } + go func() { + defer func() { _ = w.Stop() }() + for { + select { + case <-ctx.Done(): + errs <- ctx.Err() + return + case e, ok := <-w.Updates(): + if !ok { + errs <- errors.New("the watch of " + delivery.PlacementBucket + " ended") + return + } + if e != nil && put(e) { + changed(ctx) + } + } + } + }() + return errs, nil +} diff --git a/go/delivery/buildrun/standing_test.go b/go/delivery/buildrun/standing_test.go new file mode 100644 index 0000000..79845c0 --- /dev/null +++ b/go/delivery/buildrun/standing_test.go @@ -0,0 +1,238 @@ +package buildrun + +import ( + "bytes" + "context" + "errors" + "fmt" + "log" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +func gateReport(t *testing.T, node, version string, at time.Time) (string, []byte) { + t.Helper() + key, err := delivery.ToolchainKey(node, "typescript") + if err != nil { + t.Fatal(err) + } + value, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "typescript", Version: version, Recipe: "f59571cf4be75adf", At: at}) + if err != nil { + t.Fatal(err) + } + return key, value +} + +// placing is a value of the state `placement` placing modules on node. +func placing(t *testing.T, node string, modules ...string) (string, []byte) { + t.Helper() + p := delivery.Placement{Node: node} + for _, m := range modules { + p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) + } + key, err := delivery.PlacementKey(node) + if err != nil { + t.Fatal(err) + } + value, err := delivery.EncodePlacement(p) + if err != nil { + t.Fatal(err) + } + return key, value +} + +func agreedOf(set *ToolchainSet) (string, string) { + tc, none := delivery.AgreedToolchain(set.Entries("typescript")) + return tc.Version, none +} + +// Issue 1940, the gate over the catalogue's placement (issue 774, ADR 0549 §3): a build node's report counts only while +// the placement places its build module on its node, and only once written since; before the placement is settled, and +// while no node's placement is held, every report counts. The live cases: mesh-build-node taken off ace left +// ace.typescript at v26.12.0 against v26.12.1 on the three others; put on ace again, its report of 2026-10-23 06:29 came +// back before the module ran there. +func TestToolchainGate(t *testing.T) { + old := time.Date(2026, 10, 23, 6, 29, 18, 0, time.UTC) // ace's report from its earlier run + placed := time.Date(2026, 10, 25, 19, 56, 45, 0, time.UTC) // mesh-build-node placed on ace again + three := []string{"g14", "novox", "shanks"} + start := func(t *testing.T, seeded bool) (*ToolchainGate, *ToolchainSet, *[]string) { + set := &ToolchainSet{} + said := &[]string{} + gate := NewToolchainGate([]string{GoBuildModule, NodeBuildModule}, set.Put, func(f string, a ...any) { *said = append(*said, fmt.Sprintf(f, a...)) }) + for _, node := range three { + if seeded { + key, value := placing(t, node, NodeBuildModule) + gate.Placement(key, value, false, old.Add(-time.Hour)) + } + key, value := gateReport(t, node, "v26.12.1", placed.Add(time.Minute)) + gate.Toolchain(NodeBuildModule, key, value, false) + } + return gate, set, said + } + + t.Run("not placed on ace: its report is no builder's", func(t *testing.T) { + gate, set, said := start(t, true) + key, value := gateReport(t, "ace", "v26.12.0", old) + gate.Toolchain(NodeBuildModule, key, value, false) + if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree") { + t.Fatalf("before the placement is settled every report counts; got %q", none) + } + k, v := placing(t, "ace", GoBuildModule) // another build module on ace: no matter + gate.Placement(k, v, false, old) + if !gate.Settled() { + t.Fatal("settling left ace's report out, so what was passed on changed") + } + if v, none := agreedOf(set); v != "v26.12.1" || none != "" { + t.Fatalf("the placed nodes agree on v26.12.1; got %q, %q", v, none) + } + if len(*said) != 1 || !strings.Contains((*said)[0], "does not place mesh-build-node on ace") { + t.Fatalf("ace's report is said left out once; said %q", *said) + } + if gate.Toolchain(NodeBuildModule, key, value, false) { + t.Fatal("a report of a node not placed changed what was passed on") + } + // Placed on ace with a report written since: it counts, and the builders really disagree. + k, v = placing(t, "ace", GoBuildModule, NodeBuildModule) + gate.Placement(k, v, false, old.Add(-time.Minute)) + if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree: v26.12.0 recipe f59571cf4be75adf on ace") { + t.Fatalf("builders that run different versions still disagree; got %q", none) + } + // Taken off ace again (its key deleted): its report goes again. + if !gate.Placement(k, nil, true, placed) { + t.Fatal("taking ace's placement away passed nothing on") + } + if v, none := agreedOf(set); v != "v26.12.1" || none != "" { + t.Fatalf("got %q, %q", v, none) + } + // A report deleted from the bucket goes whether its node is placed or not. + key, _ = gateReport(t, "g14", "v26.12.1", placed) + if !gate.Toolchain(NodeBuildModule, key, nil, true) || len(set.Entries("typescript")) != 2 { + t.Fatalf("deleting g14's report: %d entries left, want novox and shanks", len(set.Entries("typescript"))) + } + }) + + for _, c := range []struct { + name string + at time.Time // when ace wrote its report + counts bool + }{ + {"a report left before the placement", old, false}, + {"a report written since the placement", placed.Add(30 * time.Second), true}, + } { + t.Run(c.name, func(t *testing.T) { + gate, set, said := start(t, true) + k, v := placing(t, "ace") // ace's key, first heard placing nothing + gate.Placement(k, v, false, old.Add(-time.Hour)) + gate.Settled() + k, v = placing(t, "ace", NodeBuildModule) + if gate.Placement(k, v, false, placed) { + t.Fatal("a placement with no report passed something on") + } + key, value := gateReport(t, "ace", "v26.12.0", c.at) + if got := gate.Toolchain(NodeBuildModule, key, value, false); got != c.counts { + t.Fatalf("ace's report passed on: %v, want %v", got, c.counts) + } + // ace's placement written again with another module beside: its standing does not move, nothing is said again. + k, v = placing(t, "ace", GoBuildModule, NodeBuildModule) + if gate.Placement(k, v, false, placed.Add(time.Minute)) { + t.Fatal("the same placement of mesh-build-node changed what was passed on") + } + if c.counts { + return + } + if v, none := agreedOf(set); v != "v26.12.1" || none != "" { + t.Fatalf("ace's left report is no builder's; got %q, %q", v, none) + } + if len(*said) != 1 || !strings.Contains((*said)[0], "before mesh-build-node was placed on ace at 2026-10-25T19:56:45Z") { + t.Fatalf("said %q, want once why ace's report is left out", *said) + } + key, value = gateReport(t, "ace", "v26.12.1", placed.Add(time.Minute)) + if !gate.Toolchain(NodeBuildModule, key, value, false) || len(set.Entries("typescript")) != 4 { + t.Fatal("ace's new report was not passed on") + } + }) + } + + t.Run("no placement held: every report counts", func(t *testing.T) { + gate, set, _ := start(t, false) + key, value := gateReport(t, "ace", "v26.12.0", old) + gate.Toolchain(NodeBuildModule, key, value, false) + gate.Settled() + if n := len(set.Entries("typescript")); n != 4 { + t.Fatalf("before the first placement file every report counts; got %d", n) + } + k, v := placing(t, "g14", NodeBuildModule) + if !gate.Placement(k, v, false, old.Add(-time.Hour)) || len(set.Entries("typescript")) != 1 { + t.Fatalf("the first placement leaves every node it does not place out; got %d", len(set.Entries("typescript"))) + } + if !gate.Placement(k, nil, true, placed) || len(set.Entries("typescript")) != 4 { + t.Fatal("the last placement gone: every report counts again") + } + }) + + t.Run("the history: a module in the first revision heard of a key stands since before it", func(t *testing.T) { + p := delivery.NewPlaced() + k, v := placing(t, "ace", NodeBuildModule) + p.Put(k, v, false, old) + k2, v2 := placing(t, "g14") + p.Put(k2, v2, false, old) + k2, v2 = placing(t, "g14", NodeBuildModule) + if _, changed, _ := p.Put(k2, v2, false, placed); strings.Join(changed, ",") != NodeBuildModule { + t.Errorf("a module placed says changed %v", changed) + } + if _, _, err := p.Put("placement.novox", []byte("not json"), false, old); err == nil { + t.Error("a value that does not read was not said") + } + if s, ok := p.Since("ace", NodeBuildModule); !ok || !s.IsZero() { + t.Errorf("ace's module stands since %v, %v; want the zero time", s, ok) + } + if s, ok := p.Since("g14", NodeBuildModule); !ok || !s.Equal(placed) { + t.Errorf("g14's module stands since %v, %v; want the revision that placed it", s, ok) + } + if got := p.PlacedNodes(NodeBuildModule); strings.Join(got, ",") != "ace,g14" || len(p.ByNode()) != 2 || !p.Seeded() { + t.Errorf("PlacedNodes = %v, ByNode = %v", got, p.ByNode()) + } + }) + + t.Run("a placement the bus refuses is said, and the part goes on", func(t *testing.T) { + var logs bytes.Buffer + logger := log.New(&logs, "", 0) + refused := errors.New("nats: permissions violation for publish to $JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement") + gate, _, _ := start(t, false) + for _, bus := range []refusingBus{{err: refused}, {refuseOpen: true, err: refused}} { + if _, err := FollowPlacement(context.Background(), bus, gate, func(context.Context) {}, logger.Printf); err != nil { + t.Errorf("a refused placement stopped the start: %v", err) + } + } + if !strings.Contains(logs.String(), "cannot be watched") || !strings.Contains(logs.String(), "cannot be read") { + t.Errorf("the refusals were not said: %q", logs.String()) + } + }) +} + +// refusingBus is a bus that refuses: every open (refuseOpen), or every watch of a bucket it opens, with err. +type refusingBus struct { + refuseOpen bool + err error +} + +func (r refusingBus) KeyValue(context.Context, string) (jetstream.KeyValue, error) { + if r.refuseOpen { + return nil, r.err + } + return refusedWatch{err: r.err}, nil +} + +type refusedWatch struct { + jetstream.KeyValue + err error +} + +func (r refusedWatch) WatchAll(context.Context, ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) { + return nil, r.err +} diff --git a/go/delivery/buildrun/toolchains_test.go b/go/delivery/buildrun/toolchains_test.go index e434b8a..4df0d38 100644 --- a/go/delivery/buildrun/toolchains_test.go +++ b/go/delivery/buildrun/toolchains_test.go @@ -35,10 +35,8 @@ func TestToolchainSetKeepsEachNodesEntryByLanguageAndSaysWhenOneChanged(t *testi if !s.Put(GoBuildModule, "ace.go", nil, true) || len(s.Entries("go")) != 1 { t.Error("a delete did not remove the node's entry") } -} - -func TestToolchainSetLeavesOutWhatIsNotAToolchainOfTheModulesLanguage(t *testing.T) { // 1607/AC6 - var s ToolchainSet + // What is not a toolchain of the module's language is left out. + s = ToolchainSet{} if s.Put(GoBuildModule, "novox.typescript", tcValue(t, "typescript", "x"), false) { t.Error("a typescript toolchain in the go build module's bucket counted") } diff --git a/go/delivery/composed_signature_test.go b/go/delivery/composed_signature_test.go index 816e8a8..3098e27 100644 --- a/go/delivery/composed_signature_test.go +++ b/go/delivery/composed_signature_test.go @@ -3,9 +3,12 @@ package delivery import ( "crypto/ed25519" "crypto/rand" + "crypto/x509" "encoding/json" + "encoding/pem" "strings" "testing" + "time" ) func aSigningKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { @@ -100,4 +103,88 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) { if _, err := SignComposedBy(c, pub, func(m []byte) ([]byte, error) { return ed25519.Sign(other, m), nil }); err == nil { t.Errorf("a signature by another key than the one named was accepted") } + + // L3 s1 (ADR 0550 §2): a message the vault or the controller signed under a kind's context verifies against the + // signers a node trusts and names which; an unknown key and another kind's context are refused. + vaultPub, vault := aSigningKey(t) + controllerPub, controller := aSigningKey(t) + trusted := []Signer{{Name: SignerController, Key: controllerPub}, {Name: SignerVault, Key: vaultPub}} + enrol, _ := SignContext(KindEnrolment) + certCtx, _ := SignContext(KindNodeCertificate) + msg := []byte("the reply") + for _, row := range []struct { + name string + key ed25519.PrivateKey + signed string + checked string + want string + }{ + {"the vault's key verifies", vault, enrol, enrol, SignerVault}, + {"the controller's key verifies", controller, enrol, enrol, SignerController}, + {"an unknown key is refused", other, enrol, enrol, ""}, + {"the wrong context is refused", vault, certCtx, enrol, ""}, + } { + got, err := VerifyBy(trusted, row.checked, msg, SignMessage(row.key, row.signed, msg)) + if row.want == "" && err == nil { + t.Errorf("%s: verified as %s", row.name, got.Name) + } + if row.want != "" && (err != nil || got.Name != row.want) { + t.Errorf("%s: %+v, %v", row.name, got.Name, err) + } + } + if _, err := SignContext("hand-over"); err == nil { + t.Error("a kind the vault does not sign has a context") + } + // The signers' key reads back only when its fingerprint is its key's, and what a node trusts names both holders. + k := NewSignerKey(vaultPub, time.Unix(0, 0)) + raw, _ := json.Marshal(k) + if back, err := DecodeSignerKey(raw); err != nil || back.Fingerprint != SignerFingerprint(vaultPub) { + t.Errorf("the signers' key: %+v, %v", back, err) + } + k.Fingerprint = SignerFingerprint(controllerPub) + if raw, _ := json.Marshal(k); true { + if _, err := DecodeSignerKey(raw); err == nil { + t.Error("a signers' key naming another fingerprint was taken") + } + } + if got := Trusts(trusted, nil); len(got) != 2 || !TrustsKey(got, SignerVault, SignerFingerprint(vaultPub)) || + !TrustsKey(got, SignerController, SignerFingerprint(controllerPub)) { + t.Errorf("trusts: %v", got) + } + // A certificate the vault's authority signs for a node's name and serving key checks against a bundle holding that + // authority, and not for another name, another key, or against the controller's alone; a bundle given again adds no + // certificate twice. + authority := func(pub ed25519.PublicKey, priv ed25519.PrivateKey) string { + tpl := AuthorityTemplate(pub, time.Now().Add(-time.Hour)) + der, err := x509.CreateCertificate(nil, tpl, tpl, pub, priv) + if err != nil { + t.Fatal(err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + } + vaultCA, controllerCA := authority(vaultPub, vault), authority(controllerPub, controller) + parent, _ := ParseCertificate(vaultCA) + serving, _ := aSigningKey(t) + der, err := x509.CreateCertificate(nil, NodeCertificateTemplate("ace.internal", serving, time.Now().Add(-time.Hour)), parent, serving, vault) + if err != nil { + t.Fatal(err) + } + leaf := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + bundle := AuthorityBundle(controllerCA, AuthorityBundle(controllerCA, vaultCA)) + if n := strings.Count(bundle, "BEGIN CERTIFICATE"); n != 2 { + t.Errorf("the bundle holds %d certificates, want 2", n) + } + now := time.Now() + if err := CheckNodeCertificate(leaf, []string{bundle}, "ace.internal", serving, now); err != nil { + t.Errorf("the vault's certificate does not check: %v", err) + } + for name, err := range map[string]error{ + "another name": CheckNodeCertificate(leaf, []string{vaultCA}, "g14.internal", serving, now), + "another key": CheckNodeCertificate(leaf, []string{vaultCA}, "ace.internal", controllerPub, now), + "the controller's alone": CheckNodeCertificate(leaf, []string{controllerCA}, "ace.internal", serving, now), + } { + if err == nil { + t.Errorf("%s: checked", name) + } + } } diff --git a/go/delivery/grants.go b/go/delivery/grants.go index 884e4f1..9da49a0 100644 --- a/go/delivery/grants.go +++ b/go/delivery/grants.go @@ -90,3 +90,39 @@ func (g ControllerGrant) NamesNode(node, module string) bool { } return false } + +// **The grants part's grants** (novox/hq ADR 0547 §4, §5, ADR 0561 §2, §6; issue 774). The grants part writes the same +// keys, with the same value, into its own state `grants` (the bucket PartGrantsBucket), beside the controller's: first in +// shadow, which nobody reads, so a tool can compare the two before anything switches. The assigner reads the part's keys +// only once the controller's lease is handed away (GrantSource), and the controller's until then. +const ( + // PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it. + PartGrantsState = "grants" + // PartGrantsBucket is that state's bucket, `_`. + PartGrantsBucket = "mesh-grants_" + PartGrantsState + // PartGrantsRead is that state as a reader names it in `reads`. + PartGrantsRead = "mesh-grants." + PartGrantsState + // PartGrantsHistory is the revisions the bucket keeps per key. + PartGrantsHistory = ControllerGrantsHistory +) + +// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: the layout of the +// controller's key (ControllerGrantKey), so a key of one is the key of the other and a compare is by key. +func PartGrantKey(module, fingerprint string) (string, error) { + return ControllerGrantKey(module, fingerprint) +} + +// ParsePartGrantKey is the inverse of PartGrantKey. +func ParsePartGrantKey(key string) (module, fingerprint string, err error) { + return ParseControllerGrantKey(key) +} + +// GrantSource names the bucket the assigner reads grants from, and the `reads` name that grants it: the grants part's +// once the controller's lease is handed away (buscompose.LeaseHandedAway, which refuses every other value of the lease, +// an unreadable one included), the controller's in every other case. +func GrantSource(leaseHandedAway bool) (bucket, read string) { + if leaseHandedAway { + return PartGrantsBucket, PartGrantsRead + } + return ControllerGrantsBucket, "mesh-controller.grants" +} diff --git a/go/delivery/noderecord.go b/go/delivery/noderecord.go index c22f8b2..423cf6b 100644 --- a/go/delivery/noderecord.go +++ b/go/delivery/noderecord.go @@ -55,6 +55,10 @@ type NodeRecord struct { // Authority is the mesh's own certificate as this machine holds it (public): the one its declaration last wrote where // a module asked for it. A node composing its own composes a certificate with it (compose.Input.Authority). Authority string `json:"authority,omitempty"` + // Trusts is what this machine trusts a mesh signature from now (Trusts in signers.go, ADR 0550 §2): the controller's + // key until the lease is handed away, and the vault's key and authority once it reads them from `signers`. The gate + // before the lease write reads every node's from the bus. + Trusts []string `json:"trusts,omitempty"` } func (r NodeRecord) check() error { diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index e49b327..1e985e9 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -18,7 +18,7 @@ var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|privat func TestNodeRecord(t *testing.T) { hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub", Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, - NetworksSaid: true, Contract: 3} + NetworksSaid: true, Contract: 3, Trusts: []string{"controller key sha256:aa", "vault key sha256:bb"}} raw, err := EncodeNodeRecord(hub) if err != nil { t.Fatal(err) diff --git a/go/delivery/placed.go b/go/delivery/placed.go new file mode 100644 index 0000000..60f2b14 --- /dev/null +++ b/go/delivery/placed.go @@ -0,0 +1,126 @@ +package delivery + +import ( + "fmt" + "maps" + "slices" + "sync" + "time" +) + +// Placed is where the placement (the seat mesh-placement's state `placement`, held by the assigner; ADR 0567, ADR 0549 §3) places each module, and since when: +// the one reader of that state for every part that asks "does module stand on node" (issue 774, design 47's third +// milestone), in place of each part reading the controller's assignments (`mesh-controller_assignments`, which goes +// with the controller's lease, ADR 0549 §6). +// +// Its input is the state's revisions in the order the bus wrote them, as a watch with the history (jetstream's +// IncludeHistory) hands them: Put takes each. A module stands since the revision of its node's key that first named it +// in an unbroken run. A module in the first revision heard of a key stands since the zero time: that revision is the +// key's first (the seed of ADR 0549 §4 placed what already ran there) or the oldest the bus still keeps +// (PlacementHistory), and either way the module may have run there before it. A key's value that does not read places +// nothing on its node, and Put says why. +// +// Safe for use from a watch and from a reader at once. +type Placed struct { + mu sync.Mutex + since map[string]map[string]time.Time // node -> module -> since when it stands + heard map[string]bool // the nodes whose key was heard at all +} + +// NewPlaced is a Placed that holds no placement yet. +func NewPlaced() *Placed { + return &Placed{since: map[string]map[string]time.Time{}, heard: map[string]bool{}} +} + +// Put takes one revision of a key of the state `placement` (its value, whether it is a delete, and when the bus wrote +// it). It answers the node the key is of and the modules whose standing on it changed (placed or removed), in name +// order, and an error for a key or a value that does not read (the node then places nothing). +func (p *Placed) Put(key string, value []byte, deleted bool, at time.Time) (node string, changed []string, err error) { + node, err = ParsePlacementKey(key) + if err != nil { + return "", nil, err + } + now := map[string]bool{} + if !deleted { + pl, derr := DecodePlacement(value) + switch { + case derr != nil: + err = fmt.Errorf("the placement of %s does not read, so it places nothing there: %w", node, derr) + case pl.Node != node: + err = fmt.Errorf("the placement under %s names the node %q, so it places nothing there", key, pl.Node) + default: + for _, m := range pl.Assigned() { + now[m] = true + } + } + } + p.mu.Lock() + defer p.mu.Unlock() + if !p.heard[node] { + p.heard[node] = true + at = time.Time{} + } + was := p.since[node] + next := map[string]time.Time{} + for m := range now { + if s, ok := was[m]; ok { + next[m] = s + } else { + next[m] = at + changed = append(changed, m) + } + } + for m := range was { + if !now[m] { + changed = append(changed, m) + } + } + if len(next) == 0 { + delete(p.since, node) + } else { + p.since[node] = next + } + slices.Sort(changed) + return node, changed, err +} + +// Seeded says some node's placement is held: before the assigner's adopt writes the first placement nothing is placed, +// and a reader keeps what it did before (ADR 0549 §5). +func (p *Placed) Seeded() bool { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.since) > 0 +} + +// Since says whether module stands on node, and since when (the zero time: since before the revisions kept). +func (p *Placed) Since(node, module string) (time.Time, bool) { + p.mu.Lock() + defer p.mu.Unlock() + s, ok := p.since[node][module] + return s, ok +} + +// PlacedNodes is every node module is placed on, in name order. +func (p *Placed) PlacedNodes(module string) []string { + p.mu.Lock() + defer p.mu.Unlock() + var out []string + for node, mods := range p.since { + if _, ok := mods[module]; ok { + out = append(out, node) + } + } + slices.Sort(out) + return out +} + +// ByNode is, by node, the modules placed on it in name order. +func (p *Placed) ByNode() map[string][]string { + p.mu.Lock() + defer p.mu.Unlock() + out := make(map[string][]string, len(p.since)) + for node, mods := range p.since { + out[node] = slices.Sorted(maps.Keys(mods)) + } + return out +} diff --git a/go/delivery/signers.go b/go/delivery/signers.go new file mode 100644 index 0000000..811ea13 --- /dev/null +++ b/go/delivery/signers.go @@ -0,0 +1,350 @@ +package delivery + +import ( + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "slices" + "strings" + "time" +) + +// **The vault signs** (novox/hq ADR 0550 §2; design 47's L3 item d). The vault makes an ed25519 signing key and an x509 +// authority once, keeps both as its own secrets with their operator copies, and publishes their public halves in its +// state `signers`, which only the vault writes and every node principal and composing part reads. Its verb `sign` signs a +// node's certificate for that node's own node-engine, from bus state alone (the name from the node's placement, the +// serving key from its record), and says `signed` naming the caller, the kind, the node and the digest, never the key. +// +// A node trusts the vault's key through the bus grants alone (the lead's design limit, 2026-10-28): only the vault may +// write `signers`, the same trust the wants and the placement rest on. Until the controller's lease is handed away the +// node trusts the controller's key beside it; from then on the vault's alone (LeaseHandedAway). What a node trusts now +// it states in its record, NodeRecord.Trusts, so the gate before the lease write reads it from the bus. + +const ( + // VaultModule is the module holding the seat mesh-vault, whose state `signers` is. + VaultModule = "mesh-vault" + // SignersState is the vault's state of its public signing halves, and SignersBucket its bucket. + SignersState = "signers" + SignersBucket = VaultModule + "_" + SignersState + // SignersRead is the state as a manifest's `reads` names it. + SignersRead = VaultModule + "." + SignersState + // SignersKeyKey holds the signing key's public half (SignerKey); SignersAuthorityKey the authority (SignerAuthority). + SignersKeyKey = "key" + SignersAuthorityKey = "authority" + + // SignVerb is the vault's verb that signs. + SignVerb = "sign" + // SignedEvent is what the vault says when it signed (Signed), on SignedSubject. + SignedEvent = "signed" + SignedSubject = "mesh.mod." + VaultModule + ".event." + SignedEvent + + // 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 = "enrolment" + // KindAuthority is the vault's own authority certificate, which the vault signs for itself. + KindAuthority = "authority" + + // SignerController and SignerVault name the two signers a node may trust. + SignerController = "controller" + SignerVault = "vault" + + // CertificateLife is how long a certificate the vault signs for a node lasts: the controller's ten years + // (identity.forever), renewed by asking again, never by a clock running out unseen. + CertificateLife = 10 * 365 * 24 * time.Hour +) + +// SignKinds are the kinds `sign` knows. +var SignKinds = []string{KindNodeCertificate, KindEnrolment, KindAuthority} + +// SignContext is the line a message of kind is signed after, so a signature made for one kind never verifies as +// another's (as the composition's and the asks' contexts are kept apart). +func SignContext(kind string) (string, error) { + if !slices.Contains(SignKinds, kind) { + return "", fmt.Errorf("delivery: %q is not a kind the vault signs (%s)", kind, strings.Join(SignKinds, ", ")) + } + return "novox " + kind + " v1\n", nil +} + +// SignerKey is the key `key` of `signers`: the vault's signing key's public half in standard base64, its fingerprint, +// and when it was made. +type SignerKey struct { + Key string `json:"key"` + Fingerprint string `json:"fingerprint"` + Made time.Time `json:"made"` +} + +// SignerAuthority is the key `authority` of `signers`: the vault's authority certificate as PEM, its fingerprint, and +// when it was made. +type SignerAuthority struct { + Certificate string `json:"certificate"` + Fingerprint string `json:"fingerprint"` + Made time.Time `json:"made"` +} + +// SignerFingerprint is an ed25519 public key's fingerprint, `sha256:` of its 32 bytes. +func SignerFingerprint(key ed25519.PublicKey) string { + sum := sha256.Sum256(key) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// AuthorityFingerprint is an authority certificate's fingerprint, `sha256:` of its DER; refused when certificate is +// not one PEM certificate. +func AuthorityFingerprint(certificate string) (string, error) { + c, err := ParseCertificate(certificate) + if err != nil { + return "", err + } + sum := sha256.Sum256(c.Raw) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// ParseCertificate is the first certificate of a PEM text. +func ParseCertificate(text string) (*x509.Certificate, error) { + block, _ := pem.Decode([]byte(strings.TrimSpace(text))) + if block == nil || block.Type != "CERTIFICATE" { + return nil, errors.New("delivery: not a PEM certificate") + } + return x509.ParseCertificate(block.Bytes) +} + +// NewSignerKey is the value of `key` for pub. +func NewSignerKey(pub ed25519.PublicKey, made time.Time) SignerKey { + return SignerKey{Key: base64.StdEncoding.EncodeToString(pub), Fingerprint: SignerFingerprint(pub), Made: made.UTC()} +} + +// PublicKey is k's key, checked: an ed25519 public key whose fingerprint is the one k names. +func (k SignerKey) PublicKey() (ed25519.PublicKey, error) { + raw, err := base64.StdEncoding.DecodeString(k.Key) + if err != nil || len(raw) != ed25519.PublicKeySize { + return nil, errors.New("delivery: the signers' key is not an ed25519 public key in base64") + } + if fp := SignerFingerprint(raw); fp != k.Fingerprint { + return nil, fmt.Errorf("delivery: the signers' key has the fingerprint %s, and names %s", fp, k.Fingerprint) + } + return ed25519.PublicKey(raw), nil +} + +// DecodeSignerKey reads the key `key` of `signers`, refused when it does not check. +func DecodeSignerKey(raw []byte) (SignerKey, error) { + var k SignerKey + if err := json.Unmarshal(raw, &k); err != nil { + return SignerKey{}, fmt.Errorf("delivery: %s %s: %w", SignersRead, SignersKeyKey, err) + } + if _, err := k.PublicKey(); err != nil { + return SignerKey{}, err + } + return k, nil +} + +// DecodeSignerAuthority reads the key `authority` of `signers`: one authority certificate (a CA that signs leaves +// alone) whose fingerprint is the one named. +func DecodeSignerAuthority(raw []byte) (SignerAuthority, error) { + var a SignerAuthority + if err := json.Unmarshal(raw, &a); err != nil { + return SignerAuthority{}, fmt.Errorf("delivery: %s %s: %w", SignersRead, SignersAuthorityKey, err) + } + c, err := ParseCertificate(a.Certificate) + if err != nil { + return SignerAuthority{}, err + } + if !c.IsCA { + return SignerAuthority{}, errors.New("delivery: the signers' authority is not an authority certificate") + } + if fp, _ := AuthorityFingerprint(a.Certificate); fp != a.Fingerprint { + return SignerAuthority{}, fmt.Errorf("delivery: the signers' authority has the fingerprint %s, and names %s", fp, a.Fingerprint) + } + return a, nil +} + +// Signer is one key a node trusts, by who holds it (SignerController, SignerVault, or the operator's). +type Signer struct { + Name string + Key ed25519.PublicKey +} + +// Fingerprint is the signer's key's fingerprint. +func (s Signer) Fingerprint() string { return SignerFingerprint(s.Key) } + +// VerifyBy is the trusted signer whose key verifies sig over context and msg: refused when no trusted key does, saying +// so, and when a key is not an ed25519 key. A signature made under another context never verifies. +func VerifyBy(trusted []Signer, context string, msg, sig []byte) (Signer, error) { + if len(sig) != ed25519.SignatureSize { + return Signer{}, errors.New("delivery: not an ed25519 signature") + } + message := append([]byte(context), msg...) + for _, s := range trusted { + if len(s.Key) != ed25519.PublicKeySize { + continue + } + if ed25519.Verify(s.Key, message, sig) { + return s, nil + } + } + names := make([]string, 0, len(trusted)) + for _, s := range trusted { + names = append(names, s.Name) + } + return Signer{}, fmt.Errorf("delivery: the signature verifies against no trusted key (%s)", strings.Join(names, ", ")) +} + +// SignMessage is sig over context and msg by key: what the vault's `sign` does for a kind whose bytes are given. +func SignMessage(key ed25519.PrivateKey, context string, msg []byte) []byte { + return ed25519.Sign(key, append([]byte(context), msg...)) +} + +// Signed is the vault's `signed`: who asked, the kind, the node, and the sha256 digest of what was signed. Never a key. +type Signed struct { + Caller string `json:"caller"` + Kind string `json:"kind"` + Node string `json:"node,omitempty"` + Digest string `json:"digest"` + Signer string `json:"signer"` + At time.Time `json:"at"` +} + +// SignAnswer is what `sign` answers: for a node certificate the certificate as PEM and the authority that signed it, +// for an enrolment the signature in base64; always the signer's fingerprint and the digest. Never a key's private half. +type SignAnswer struct { + Kind string `json:"kind"` + Node string `json:"node,omitempty"` + Certificate string `json:"certificate,omitempty"` + Authority string `json:"authority,omitempty"` + Signature string `json:"signature,omitempty"` + Signer string `json:"signer"` + Digest string `json:"digest"` +} + +// DigestOf is the digest a `signed` names: `sha256:` of the bytes signed. +func DigestOf(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// NodeCertificateTemplate is the certificate the vault signs for node's internal name, as the controller's authority +// shaped it (identity.Certify): the name in the subject and the subject alternative names, server and client use, the +// serial derived from the name and the key so the same ask gives the same certificate, and its life from notBefore. +func NodeCertificateTemplate(name string, servingKey ed25519.PublicKey, notBefore time.Time) *x509.Certificate { + sum := sha256.Sum256(append([]byte(name+"\n"), servingKey...)) + return &x509.Certificate{ + SerialNumber: new(big.Int).SetBytes(sum[:16]), + Subject: pkix.Name{CommonName: name}, + DNSNames: []string{name}, + NotBefore: notBefore.UTC().Truncate(time.Second), + NotAfter: notBefore.UTC().Truncate(time.Second).Add(CertificateLife), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } +} + +// AuthorityTemplate is the vault's authority certificate for its key, made once: an authority that signs leaves and no +// other authority, as the controller's. +func AuthorityTemplate(key ed25519.PublicKey, made time.Time) *x509.Certificate { + sum := sha256.Sum256(append([]byte("authority\n"), key...)) + return &x509.Certificate{ + SerialNumber: new(big.Int).SetBytes(sum[:16]), + Subject: pkix.Name{CommonName: "the mesh's vault"}, + NotBefore: made.UTC().Truncate(time.Second), + NotAfter: made.UTC().Truncate(time.Second).Add(2 * CertificateLife), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + MaxPathLen: 0, + MaxPathLenZero: true, + } +} + +// CheckNodeCertificate says why certificate is not a certificate of name for servingKey by one of the authorities, or +// nil: a node uses it to tell whether its record holds a certificate from an authority it trusts, and another node to +// check one the vault signed (ADR 0550 §2's live check). +func CheckNodeCertificate(certificate string, authorities []string, name string, servingKey ed25519.PublicKey, at time.Time) error { + c, err := ParseCertificate(certificate) + if err != nil { + return err + } + pool := x509.NewCertPool() + for _, a := range authorities { + pool.AppendCertsFromPEM([]byte(a)) // a bundle is every certificate it holds + } + if _, err := c.Verify(x509.VerifyOptions{Roots: pool, DNSName: name, CurrentTime: at, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil { + return fmt.Errorf("delivery: the certificate of %s is not one a trusted authority signed for it: %w", name, err) + } + held, ok := c.PublicKey.(ed25519.PublicKey) + if !ok || !held.Equal(servingKey) { + return fmt.Errorf("delivery: the certificate of %s binds another key than the node serves with", name) + } + return nil +} + +// AuthorityBundle is the authorities given as one PEM text, each certificate once, in the order given, blanks left out: +// what a module asking for the mesh's authority is given once the vault signs beside the controller (ADR 0550 §2). An +// authority given is split into its certificates, so a bundle given again (the one a node kept from its last +// composition) adds nothing twice; a text that holds no PEM block is taken whole. +func AuthorityBundle(authorities ...string) string { + var out []string + for _, a := range authorities { + for _, piece := range pemPieces(a) { + if !slices.Contains(out, piece) { + out = append(out, piece) + } + } + } + if len(out) == 0 { + return "" + } + return strings.Join(out, "\n") + "\n" +} + +// pemPieces is each PEM block of text re-encoded, or the trimmed text whole when it holds none. +func pemPieces(text string) []string { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + var out []string + rest := []byte(text) + for { + block, more := pem.Decode(rest) + if block == nil { + break + } + out = append(out, strings.TrimSpace(string(pem.EncodeToMemory(block)))) + rest = more + } + if len(out) == 0 { + return []string{text} + } + return out +} + +// Trusts is what a node states it trusts now (NodeRecord.Trusts): ` key ` for each signer and +// ` authority ` for each authority certificate, by who holds it, each once, sorted. The gate before the +// lease write reads `vault key ` in every node's record. +func Trusts(signers []Signer, authorities map[string]string) []string { + var out []string + for _, s := range signers { + if len(s.Key) == ed25519.PublicKeySize { + out = append(out, s.Name+" key "+s.Fingerprint()) + } + } + for who, a := range authorities { + if fp, err := AuthorityFingerprint(a); err == nil { + out = append(out, who+" authority "+fp) + } + } + slices.Sort(out) + return slices.Compact(out) +} + +// TrustsKey says whether trusts names the key with fingerprint fp held by who. +func TrustsKey(trusts []string, who, fp string) bool { return slices.Contains(trusts, who+" key "+fp) }