diff --git a/go/compose/credentials.go b/go/compose/credentials.go new file mode 100644 index 0000000..e6aef49 --- /dev/null +++ b/go/compose/credentials.go @@ -0,0 +1,440 @@ +package compose + +import ( + "crypto/ecdh" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they +// differ as the controller's Reading and Foreseeing modes do. +type Mode int + +const ( + // Reading composes what is held and leaves out an own secret the Input holds none for: that machine cannot be running it. + Reading Mode = iota + // Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would + // refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words. + Foreseeing +) + +// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send. +// Never sent. +const ( + ForeseenCertificate = "foreseen: issued by the next send" + ForeseenBusUsers = "foreseen: composed from the bus records by the next send" +) + +// grantsFor is every credential this node must create, because something elsewhere uses it (plan.go grantsFor): the mirror of +// what a consumer is given. A consumer whose identity overflows the provision's bound is left out of the grants and returned +// beside them (novox/hq ADR 0225); one whose resolution binds it to another provider is withdrawn and returned beside them +// (issue 274). Every other is granted. +func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overflow, []catalogue.Unbound, []string, error) { + var issued []Issued + for _, row := range c.in.Issued { + if row.Provider == node { + issued = append(issued, row) + } + } + onNetwork, err := c.whereEveryoneIs() + if err != nil { + return nil, nil, nil, nil, err + } + out := make([]catalogue.Grant, 0, len(issued)) + var withheld []catalogue.Overflow + var unbound []catalogue.Unbound + var foreseen []string + for _, s := range issued { + resolved, err := c.planFor(s.Consumer) + switch { + case unresolvable(err): + // Their set does not resolve. Skipped rather than fatal: this node is not the place to report another machine's problem. + continue + case err != nil: + // The mesh could not be asked what they wanted, which is not the same as their wanting nothing. + return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err) + } + plan := resolved.Plan + values, asks, err := plan.ContributionsFrom(s.Provision, s.ConsumerModule, resolved.Settings) + if err != nil { + return nil, nil, nil, nil, err + } + // A port in there is the consumer's software port until this: the assignment that moved it is that machine's. + consumer, err := c.in.node(s.Consumer) + if err != nil { + return nil, nil, nil, nil, err + } + values = catalogue.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule]) + from := s.ConsumerModule + if !asks { + // That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever. + from = "" + } + if bound := plan.BindsFrom(s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) { + // It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said. + from = "" + unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node, + Consumer: s.Consumer, Module: s.ConsumerModule, Local: s.Local, BoundTo: bound}) + } + // The consumer's identity slug, from its own manifest, carried on the grant so the provider derives the same login the + // consumer does (novox/hq ADR 0049). + slug := "" + for _, mm := range plan.Modules { + if mm.Module == s.ConsumerModule { + slug = mm.Slug + break + } + } + if from != "" { + bound := boundOfGrant(plan, s, node) + source := catalogue.IdentitySource(slug, s.ConsumerModule) + if catalogue.CheckIdentityWithin(s.Consumer, source, bound) != nil { + withheld = append(withheld, catalogue.Overflow{Provision: s.Provision, Provider: node, + Consumer: s.Consumer, Module: s.ConsumerModule, + Identity: catalogue.ConsumerIdentity(s.Consumer, source), Bound: bound}) + continue + } + } + sealed := s.ForProvider + if sealed == "" { + sealed = catalogue.ForeseenSealed + foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer)) + } + out = append(out, catalogue.Grant{ + Provision: s.Provision, Consumer: s.Consumer, At: onNetwork[s.Consumer], + From: from, Values: values, Slug: slug, Sealed: sealed, Local: s.Local}) + } + return out, withheld, unbound, foreseen, nil +} + +// boundOfGrant is the identity bound the consumer's own resolution states for the requirement this grant answers. A requirement +// not found there is held to the tightest bound the mesh knows rather than to none. +func boundOfGrant(consumer catalogue.Resolution, s Issued, provider string) catalogue.IdentityBound { + for _, n := range consumer.Needs { + if n.Name == s.Provision && n.For == s.ConsumerModule && n.From == provider { + return n.Identity + } + } + return catalogue.DefaultIdentityBound +} + +// ownSecretRow is the row for one own secret of a module on a node. +func (c *composer) ownSecretRow(node, module, name string) (OwnSecretRow, bool) { + for _, r := range c.in.OwnSecrets { + if r.Node == node && r.Module == module && r.Name == name { + return r, true + } + } + return OwnSecretRow{}, false +} + +// givenMember is one own secret a person gave on a machine and whether it is sealed to the key the machine holds now. +type givenMember struct { + Name string + Sealed string + Current bool +} + +// givenMembers is every member of a module's secret family given on a machine, by name (inv.GivenMembers): the mesh never +// makes a member, so only a value a person gave is one; a machine with no sealing key holds none. +func (c *composer) givenMembers(node Node, module, family string) []givenMember { + if node.SealingKey == "" { + return nil + } + prefix := catalogue.FamilyPrefix(family) + var out []givenMember + for _, r := range c.in.OwnSecrets { + if r.Node == node.Name && r.Module == module && r.Origin == "accepted" && strings.HasPrefix(r.Name, prefix) { + out = append(out, givenMember{Name: r.Name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// givenOwnSecret is an own secret issued outside the mesh as a person gave it on a machine (inv.GivenOwnSecret): not given when +// the value held was made by the mesh or the machine has no sealing key. +func (c *composer) givenOwnSecret(node Node, module, name string) (givenMember, bool) { + if node.SealingKey == "" { + return givenMember{}, false + } + r, ok := c.ownSecretRow(node.Name, module, name) + if !ok || r.Origin != "accepted" { + return givenMember{}, false + } + return givenMember{Name: name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}, true +} + +// moduleSecretIfIssued is the read half of the controller's SecretForModule (inv.ModuleSecretIfIssued): the secret when one is +// held and sealed to the key the machine holds now, and otherwise not held. A key it was sealed to that the machine no longer +// has is reported as absent rather than as an error. +func (c *composer) moduleSecretIfIssued(node Node, module, name string) (sealed string, held bool) { + if node.SealingKey == "" { + return "", false + } + r, ok := c.ownSecretRow(node.Name, module, name) + if !ok || r.SealedTo != node.SealingKey { + return "", false + } + return r.Sealed, true +} + +// wouldMakeSecretForModule says what the send would do about an own secret without doing it: nil when it would make one (or one +// is held), and otherwise the very refusal it would meet (inv.WouldMakeSecretForModule). +func (c *composer) wouldMakeSecretForModule(node Node, module, name string) error { + if node.SealingKey == "" { + return fmt.Errorf("%s needs a secret and %s has no sealing key, so nothing can be sealed to it", module, node.Name) + } + r, ok := c.ownSecretRow(node.Name, module, name) + switch { + case !ok: + return nil + case r.SealedTo != node.SealingKey && r.Origin == "accepted": + return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+ + "since generated a new sealing key. The mesh cannot make another; issue it again", module, node.Name, name, node.Name) + } + return nil +} + +// busCredentialIssued refuses an own secret called `broker` whose bus account nobody issued (novox/hq issue 203): the broker +// credential is never invented, an account on the bus minted by `module issue`. +func (c *composer) busCredentialIssued(node, module, name string) error { + var user string + switch name { + case "broker": + user = node + "." + module + case ProvisionerSecret: + user = "provisioner." + node + default: + return nil + } + if !slices.Contains(c.in.BusMinted, user) { + return fmt.Errorf( + "%s on %s has no bus credential: nothing was issued for %s, and a push "+ + "would seal a placeholder its process cannot read (novox/hq issue 203). "+ + "`module issue %s --node %s`, then push again", + module, node, user, module, node) + } + return nil +} + +// givenUnderAnOldKey is the refusal for a value a person gave sealed to a key the machine no longer holds. +func givenUnderAnOldKey(module, node, name string) error { + return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+ + "since generated a new sealing key. The mesh cannot make another; give it again", module, node, name, node) +} + +// ownSecretsFor is each module's own secrets as this node composes them (plan.go renderingFor): the needed ones sealed to the +// node, and, in the Foreseeing mode, the ones the next send would make named foreseen. A module handed to the provisioner has its +// secrets from the vault through the client, so none of them is composed here. +func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing catalogue.Rendering, mode Mode) ( + needed map[string]map[string]string, foreseen map[string]map[string]bool, err error) { + needed = map[string]map[string]string{} + foreseen = map[string]map[string]bool{} + mark := func(module, name string) { + if foreseen[module] == nil { + foreseen[module] = map[string]bool{} + } + foreseen[module][name] = true + } + for _, m := range plan.Modules { + if handing.Handing(m) { + continue + } + // A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not + // given is nothing -- the module says it waits for it. + for _, family := range m.OwnSecrets.Families() { + for _, g := range c.givenMembers(node, m.Module, family) { + if _, fam, ok := m.OwnSecrets.Lookup(g.Name); !ok || fam != family { + continue // a longer family's member, or a name no longer of this family + } + if !g.Current { + continue + } + if needed[m.Module] == nil { + needed[m.Module] = map[string]string{} + } + needed[m.Module][g.Name] = g.Sealed + } + } + names := make([]string, 0) + for name := range m.OwnSecrets.Plain() { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + own := m.OwnSecrets.Plain()[name] + // An own secret issued outside the mesh is never made (novox/hq issue 401, ADR 0228; ADR 0283 for a family): only a + // value a person gave is placed. One not given is nothing, and a value the mesh made for it before is not one either. + if own.IssuedBy == catalogue.IssuedOutside { + g, given := c.givenOwnSecret(node, m.Module, name) + if !given || !g.Current { + continue + } + if g.Sealed == "" { + mark(m.Module, name) + continue + } + if needed[m.Module] == nil { + needed[m.Module] = map[string]string{} + } + needed[m.Module][name] = g.Sealed + continue + } + sealed, held := c.moduleSecretIfIssued(node, m.Module, name) + if !held && mode == Foreseeing { + // Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in, + // named; refused by it -- refused here, the same words (novox/hq issue 275). + if err := c.busCredentialIssued(node.Name, m.Module, name); err != nil { + return nil, nil, err + } + if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil { + return nil, nil, err + } + mark(m.Module, name) + continue + } + if !held { + // Never issued, so this machine cannot be running it: left out rather than invented. + continue + } + if sealed == "" { + // Held, and its value is not in the Input: composed with a stand-in, named. + mark(m.Module, name) + continue + } + if needed[m.Module] == nil { + needed[m.Module] = map[string]string{} + } + needed[m.Module][name] = sealed + } + } + return needed, foreseen, nil +} + +// CoreBoxWanted is a core credential whose box the Input does not hold for a machine: the next send asks the vault for it and +// keeps the answer. It is returned for the caller to ask and keep; this package asks and keeps nothing. +type CoreBoxWanted struct { + Node string + Module string + Name string +} + +// coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, with their +// plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued +// outside the mesh is never the vault's to seal here. +func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string { + out := map[string][]string{} + for _, m := range modules { + if !catalogue.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) { + continue + } + var names []string + for name, own := range m.OwnSecrets.Plain() { + if own.IssuedBy != catalogue.IssuedOutside { + names = append(names, name) + } + } + if len(names) > 0 { + sort.Strings(names) + out[m.Module] = names + } + } + return out +} + +// sealingKeyValid says a machine's sealing key, recorded raw in base64, is a raw X25519 key. +func sealingKeyValid(raw string) error { + b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw)) + if err != nil || len(b) != 32 { + return errors.New("the machine's sealing key is not a raw X25519 key") + } + _, err = ecdh.X25519().NewPublicKey(b) + return err +} + +// coreBoxesFor decides each core credential of a machine in a composition that sends nothing and asks no vault (core_box.go +// coreBoxesOf, in the modes that are not a send): the kept box while it is sealed to the key the machine holds now, and otherwise a +// stand-in that the next send replaces by asking the vault -- returned in wanted for the caller to ask and keep. A machine with no +// sealing key, or one whose key is not a key, leaves its module out, said. +func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) ( + sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) { + core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return handing.Handing(m) }) + sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{} + mods := make([]string, 0, len(core)) + for m := range core { + mods = append(mods, m) + } + sort.Strings(mods) + for _, module := range mods { + if node.SealingKey == "" { + notPlaced[module] = fmt.Sprintf("%s has no sealing key, so the vault can seal %s's credentials to "+ + "nothing there (novox/hq ADR 0349 ยง16)", node.Name, module) + continue + } + if err := sealingKeyValid(node.SealingKey); err != nil { + notPlaced[module] = fmt.Sprintf("%s's credentials cannot be asked of the vault: %v", module, err) + continue + } + boxes := map[string]json.RawMessage{} + for _, name := range core[module] { + var kept *CoreBox + for i := range c.in.KeptCoreBoxes { + k := &c.in.KeptCoreBoxes[i] + if k.Node == node.Name && k.Module == module && k.Name == name { + kept = k + } + } + if kept != nil && kept.SealedTo == node.SealingKey && len(kept.Box) > 0 { + boxes[name] = kept.Box + continue + } + boxes[name] = catalogue.ForeseenVaultBox + wanted = append(wanted, CoreBoxWanted{Node: node.Name, Module: module, Name: name}) + } + sealed[module] = boxes + } + return sealed, notPlaced, wanted +} + +// certificateFor is what the mesh certifies about one machine's internal name (plan.go certificateFor): refused for a machine that +// never told the mesh what key it serves with, and for one not on the private network, which has no name to be certified for. +// The certificate and the authority are public values the Input may carry; without them stand-ins are composed, and named. +func (c *composer) certificateFor(node Node) (certificate, authority string, foreseen bool, err error) { + if node.ServingKey == "" { + return "", "", false, fmt.Errorf( + "%s wants a certificate and has never told the mesh what key it serves with; it "+ + "joins again to report one", node.Name) + } + where, err := c.whereEveryoneIs() + if err != nil { + return "", "", false, err + } + if where[node.Name] == "" { + return "", "", false, fmt.Errorf( + "%s wants a certificate and is not on the private network, so it has no name inside "+ + "the mesh to be certified for", node.Name) + } + certificate, authority = node.Certificate, c.in.Authority + if certificate == "" || authority == "" { + return ForeseenCertificate, ForeseenCertificate, true, nil + } + return certificate, authority, false, nil +} + +// holdsTheBus says whether a node's set includes the module that asks for the bus's user list. +func holdsTheBus(modules []catalogue.Manifest) bool { + for _, m := range modules { + if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") { + return true + } + } + return false +} diff --git a/go/compose/render.go b/go/compose/render.go new file mode 100644 index 0000000..5f05b84 --- /dev/null +++ b/go/compose/render.go @@ -0,0 +1,126 @@ +package compose + +import ( + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Rendered is everything a node's declaration is composed with (plan.go renderingFor), over an Input: the place half (Places) +// and the credential half. Nothing was allocated, made, kept or written; what a send would do is returned for the caller to do. +type Rendered struct { + Rendering catalogue.Rendering + // ForeseenPorts are the ports the mesh may assign that the Input holds none for (":"). + ForeseenPorts []string + // Foreseen are the stand-ins the composition used for what the Input does not hold: an own secret the next send makes + // ("/"), a grant's sealed copy, the certificate and the bus's user list. + Foreseen []string + // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep. + CoreBoxesWanted []CoreBoxWanted + // Withheld are the consumers the grants leave out because their identity overflows the provision's bound (novox/hq ADR 0225), + // and Unbound those whose credential from this node is on record and whose own resolution binds them elsewhere (issue + // 274). The controller says them; a check refuses a change that adds one (ADR 0446 clause 1). + Withheld []catalogue.Overflow + Unbound []catalogue.Unbound + // Said are the lines the controller says while composing (the bus users left out for want of a credential). + Said []string +} + +// Render composes the rendering of a node: the grants it must create, where it puts its ports, its own secrets, the vault's boxes, +// its certificate, the names of the mesh, the bus's user list and its membership. The refusals are the controller's, in its words; +// where more than one fault stands, the first reported may differ from the controller's, which interleaves the halves. +func Render(in Input, node string, mode Mode) (Rendered, error) { + return newComposer(in).render(node, mode) +} + +func (c *composer) render(nodeName string, mode Mode) (Rendered, error) { + resolved, err := c.planFor(nodeName) + if err != nil { + return Rendered{}, err + } + node, err := c.in.node(nodeName) + if err != nil { + return Rendered{}, err + } + plan := resolved.Plan + var out Rendered + foreseen := func(s string) { out.Foreseen = append(out.Foreseen, s) } + + grants, withheld, unbound, grantsForeseen, err := c.grantsFor(nodeName) + if err != nil { + return Rendered{}, err + } + placed, err := c.places(nodeName, resolved) + if err != nil { + return Rendered{}, err + } + r := placed.Rendering + out.ForeseenPorts = placed.ForeseenPorts + r.Grants, r.Withheld, r.Unbound = grants, withheld, unbound + out.Withheld, out.Unbound = withheld, unbound + for _, g := range grantsForeseen { + foreseen(g) + } + + // And each module's own secrets: a module handed to the provisioner has its secrets from the vault through the client. + handing := catalogue.Rendering{Hands: r.Hands, Provisioner: r.Provisioner} + needed, foreseenOwn, err := c.ownSecretsFor(node, plan, handing, mode) + if err != nil { + return Rendered{}, err + } + r.Needed = needed + if mode == Foreseeing || len(foreseenOwn) > 0 { + r.Foreseen = foreseenOwn + } + var names []string + for module, secrets := range foreseenOwn { + for name := range secrets { + names = append(names, module+"/"+name) + } + } + sort.Strings(names) + for _, n := range names { + foreseen(n) + } + + // The core's own credentials as the vault sealed them, for an engine that opens the vault's box. + if node.Stated && node.Contract >= VaultBoxContract { + sealed, notPlaced, wanted := c.coreBoxesFor(node, plan.Modules, handing) + r.VaultSealed, r.NotPlaced = sealed, notPlaced + out.CoreBoxesWanted = wanted + } + + // And a certificate for this machine's name inside the mesh, when anything on it asks. + for _, m := range plan.Modules { + if m.Certificate == nil { + continue + } + certificate, authority, stood, err := c.certificateFor(node) + if err != nil { + return Rendered{}, err + } + r.Certificate, r.Authority = certificate, authority + if stood { + foreseen("the certificate and the authority of " + nodeName) + } + break + } + + // The bus's user list, for the machine that runs the bus: names and hashes the Input carries, never passwords. + if holdsTheBus(plan.Modules) { + r.BusUsers = c.in.BusUsers + if r.BusUsers == "" { + r.BusUsers = ForeseenBusUsers + foreseen("the bus's user list") + } + if len(c.in.BusMissing) > 0 { + out.Said = append(out.Said, fmt.Sprintf("the bus's user list leaves out %d user(s) the mesh has minted no credential "+ + "for: %s. Each is a user that cannot connect until one is issued", len(c.in.BusMissing), strings.Join(c.in.BusMissing, ", "))) + } + } + r.BusMembership = c.in.BusMemberships[nodeName] + out.Rendering = r + return out, nil +}