diff --git a/go/compose/input.go b/go/compose/input.go index 42cf652..e351d8b 100644 --- a/go/compose/input.go +++ b/go/compose/input.go @@ -50,6 +50,11 @@ type Input struct { CarriedPeers []CarriedPeer // Joining is the nodes that hold a live enrolment token (inv.NodesWithALiveToken). Joining []string + + // Credentials is how each pair credential a node takes from elsewhere stands, never its value (what inv.SecretFor, + // SharedSecretFor and GivenPairSecret would answer): held, to be made by the next send, only a person's to give, or + // refused. A need with no entry here is one the next send would make. + Credentials []Credential } // Node is one node of the mesh and what the composition reads about it. @@ -76,6 +81,9 @@ type Node struct { Settings map[string][]catalogue.Layer // Ports is the machine port each module's software port was given on it (inv.PortsFor), by module then port. Ports map[string]map[int]int + // SealingKey is the public key its secrets are sealed to (inv.SealingKeyOf); empty when it has none. A public key, so + // nothing secret. + SealingKey string } // Place is where a node is, as its placement says (inv.Overlay). @@ -138,6 +146,11 @@ type Licence struct { Name string Serves map[string]any Holders []Holder + // Keys is, by holder, the licence's key sealed to that holder's machine (held.KeyFor), empty where the mesh holds none: + // the plaintext was discarded when it was supplied. Optional; the composition needs no value of it. + Keys map[Holder]string + // Manager is the holder of a refreshable-grant licence that manages it (held.ManagerOf); the zero Holder when none. + Manager Holder } // Holder is a module on a node that uses a licence. @@ -165,3 +178,36 @@ func (in Input) sorted() []Node { sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } + +// CredentialState is how one pair credential stands. +type CredentialState string + +const ( + // CredentialHeld is a credential held for both ends as they stand now. Sealed, when given, is the consumer's copy; a held + // credential with no value is composed with a stand-in, named foreseen. + CredentialHeld CredentialState = "held" + // CredentialWouldMake is a credential none is held for: the next send makes it, and the composition uses a stand-in, + // named foreseen. It is also what a need with no entry is. + CredentialWouldMake CredentialState = "would-make" + // CredentialNotGiven is a credential only a person gives (novox/hq ADR 0337 clause 4) that nobody has given, or that was given + // under a key one end no longer has: the consumer still resolves, without it. Why is the controller's words for it. + CredentialNotGiven CredentialState = "not-given" + // CredentialRefused is a credential the send would refuse to make, for the reason Why gives in the controller's words: a + // value a person gave sealed to a key the machine no longer has, a machine with no sealing key. + CredentialRefused CredentialState = "refused" +) + +// Credential is the state of one pair credential, by the ends it joins: the provision, the consumer's node and module, the +// provider's node, and the local name where a module keeps several (inv.Secret's key). +type Credential struct { + Provision string + Consumer string + ConsumerModule string + Provider string + Local string + State CredentialState + // Sealed is the consumer's copy as it is sealed to the consumer's key; optional, and only the controller's adapter passes it. + Sealed string + // Why is the words of a CredentialNotGiven or CredentialRefused. + Why string +} diff --git a/go/compose/network.go b/go/compose/network.go index 698055e..bcdc72e 100644 --- a/go/compose/network.go +++ b/go/compose/network.go @@ -124,7 +124,7 @@ func (c *composer) whoResolves(requirement string) (map[string]bool, map[string] // Why a node could not be resolved, kept rather than raised: one broken node must not stop the rest being described. refused := map[string]string{} for _, n := range c.in.sorted() { - plan, err := c.resolution(n.Name) + resolved, err := c.planFor(n.Name) switch { case unresolvable(err): refused[n.Name] = err.Error() @@ -133,7 +133,7 @@ func (c *composer) whoResolves(requirement string) (map[string]bool, map[string] // Not a node that does not resolve: a question that went unanswered. return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w", n.Name, requirement, err) } - for _, m := range plan.Modules { + for _, m := range resolved.Plan.Modules { for _, offered := range m.Offers() { if offered == requirement { on[n.Name] = true diff --git a/go/compose/resolve.go b/go/compose/resolve.go index f994cb9..344666b 100644 --- a/go/compose/resolve.go +++ b/go/compose/resolve.go @@ -1,16 +1,195 @@ package compose import ( + "fmt" + "slices" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // LicenceProvision is what a module requires in order to be given a licence (the controller's licences.Provision). const LicenceProvision = "model-access" +// Resolved is what one node should run, from what was assigned to it, with the settings of everything that resolved. +type Resolved struct { + Plan catalogue.Resolution + Settings catalogue.SettingsBy + // Foreseen are the needs composed with a stand-in for a credential the next send makes (or holds with no value given + // here), as " on needs from ". Nothing was made. + Foreseen []string + // NotGiven are the words of each credential only a person gives that nobody has: the consumer still resolves without it + // (novox/hq ADR 0337 clause 4). + NotGiven []string +} + +// Resolve works out everything a node should run, from what was assigned to it (the controller's planFor, plan.go): the +// resolution over the rest of the mesh, the credential of each thing the node takes from elsewhere, and the settings of +// every module that resolved. It makes and writes nothing: a credential not held is composed with a stand-in and named +// foreseen, one only a person gives and nobody has is left out and said, one the send would refuse is refused with the +// controller's words. A failure to compose the node's own modules is an error that satisfies IsUnresolvable, told apart +// from an unreadable Input (novox/hq 04-ISSUES/152). +func Resolve(in Input, node string) (Resolved, error) { + return newComposer(in).planFor(node) +} + +// IsUnresolvable reports whether err is a node's own set failing to compose, rather than the mesh being unable to answer. +func IsUnresolvable(err error) bool { return unresolvable(err) } + +// planFor is the controller's planFor. +func (c *composer) planFor(nodeName string) (Resolved, error) { + resolved, err := c.resolution(nodeName) + if err != nil { + return Resolved{}, err + } + node, err := c.in.node(nodeName) + if err != nil { + return Resolved{}, err + } + shelf := c.in.Catalogue + var out Resolved + + // The credential for each thing this node takes from elsewhere. + for i, n := range resolved.Needs { + // Issued for this holder alone and kept by the vault (issue 757). + resolved.Needs[i].IssuedToHolder = issuedToHolder(shelf, n) + resolved.Needs[i].ProviderMoved = providerMoved(shelf, n) + if n.ByRecord { + // Answered by something the mesh holds, so there is no pair-wise secret between two machines. + licence, _ := c.licence(n.From) + resolved.Needs[i].Sealed = licence.Keys[Holder{Node: nodeName, Module: n.For}] + // If this holder is the licence's manager, hand it the manager node's PUBLIC sealing key in its bound facts + // (novox/hq ADR 0050). + if pub := c.managerPublicKeyFor(licence, nodeName, n.For); pub != "" { + serves := map[string]any{} + for k, v := range resolved.Needs[i].Serves { + serves[k] = v + } + serves["manager_public_key"] = pub + resolved.Needs[i].Serves = serves + // The manager holder: its empty pre-adoption refresh token is a waiting state, not a missing consumer key. + resolved.Needs[i].Manager = true + } + continue + } + cred, held := c.credential(n, nodeName) + _, outside := issuedOutsideBy(shelf, n) + switch { + case cred.State == CredentialRefused: + // Said rather than skipped. A machine that resolves cleanly and receives no credential is one that will fail to + // authenticate at some later, less obvious moment. + return Resolved{}, fmt.Errorf("%s on %s needs %s from %s and no credential could be made for it: %s", + n.For, nodeName, n.Name, n.From, cred.Why) + case cred.State == CredentialNotGiven, outside && !(held && cred.State == CredentialHeld): + // Not given, and the rest of the consumer runs (ADR 0337 clause 4): no credential file, so the module can tell + // it was not given; nothing the mesh made stands in for it. + why := cred.Why + if why == "" { + why = "nobody gave it" + } + out.NotGiven = append(out.NotGiven, fmt.Sprintf("%s on %s holds no %s from %s: %s", n.For, nodeName, n.Name, n.From, why)) + continue + case cred.State == CredentialHeld && cred.Sealed != "": + resolved.Needs[i].Sealed = cred.Sealed + default: + // Held with no value here, or none held: the next send makes it (or reads it), and a stand-in is composed. + resolved.Needs[i].Sealed = catalogue.ForeseenSealed + out.Foreseen = append(out.Foreseen, fmt.Sprintf("%s on %s needs %s from %s", n.For, nodeName, n.Name, n.From)) + } + } + + // Settings for everything that resolved, including modules nobody assigned directly: a requirement pulled in by + // something else is still configurable. + settings := catalogue.SettingsBy{} + for _, m := range resolved.Modules { + layers := node.Settings[m.Module] + if len(layers) == 0 { + continue + } + settings[m.Module] = append([]catalogue.Layer(nil), layers...) + } + out.Plan, out.Settings = resolved, settings + return out, nil +} + +// credential is how the pair credential of one need stands in the Input, and whether the Input names it at all. +func (c *composer) credential(n catalogue.Needed, consumer string) (Credential, bool) { + for _, cr := range c.in.Credentials { + if cr.Provision == n.Name && cr.Consumer == consumer && cr.ConsumerModule == n.For && cr.Provider == n.From && cr.Local == n.Local { + return cr, true + } + } + return Credential{State: CredentialWouldMake}, false +} + +// licence is the licence of that name. +func (c *composer) licence(name string) (Licence, bool) { + for _, l := range c.in.Licences { + if l.Name == name { + return l, true + } + } + return Licence{}, false +} + +// managerPublicKeyFor is the manager node's public sealing key, but only when (node, module) is the licence's manager holder +// and empty otherwise (plan.go managerPublicKeyFor): delivered to the manager module in its bound facts so it can re-seal a +// rotated refresh token to this node (novox/hq ADR 0050), and to no consumer. +func (c *composer) managerPublicKeyFor(l Licence, node, module string) string { + if l.Manager == (Holder{}) || node != l.Manager.Node || module != l.Manager.Module { + return "" + } + n, err := c.in.node(node) + if err != nil { + return "" + } + return n.SealingKey +} + +// issuedOutsideBy is whether a need's provision is offered by its provider with a credential issued outside the mesh +// (novox/hq ADR 0337), and that provider module: the one answering it when known, else any that offers it so. +func issuedOutsideBy(shelf map[string]catalogue.Manifest, n catalogue.Needed) (string, bool) { + if n.Module != "" { + if m, known := shelf[n.Module]; known { + return n.Module, m.IssuedOutsideOf(n.Name) + } + } + return catalogue.OffersIssuedOutside(shelf, n.Name) +} + +// issuedToHolder is whether the vault keeps a need's credential for its holder alone (issues 749, 757 and 779). +func issuedToHolder(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool { + if n.ByRecord || n.Module == "" { + return false + } + provider, known := shelf[n.Module] + if !known || !movedProvider(provider) { + return false + } + if _, issued := provider.IssuedCredentialOf(n.Name); issued { + return true + } + return provider.CredentialFormOf(n.Name) == catalogue.CredentialFormPair +} + +// providerMoved is whether a need is answered by a module marked moved (issue 788), or by one whose offer of the provision +// says the provider helper answers it (issue 792). +func providerMoved(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool { + if n.ByRecord || n.Module == "" { + return false + } + provider, known := shelf[n.Module] + return known && (movedProvider(provider) || provider.AnsweredByHelperFor(n.Name)) +} + +// movedProvider is whether a provider's credentials are the vault's: the module is marked moved, or it holds the +// mesh-vault seat (issue 793). +func movedProvider(m catalogue.Manifest) bool { + return m.Moved || slices.ContainsFunc(m.Claims, func(c catalogue.Claim) bool { return c.Name == "mesh-vault" }) +} + // resolution works out what one node should run, from what was assigned to it: the controller's planFor up to and // including the resolution (plan.go planFor), and no further. A failure to compose the node's own modules is wrapped as -// notResolvable; every other failure is returned as it is. What it leaves to the issue after is the credentials of -// what the node takes from elsewhere and its settings. +// notResolvable; every other failure is returned as it is. func (c *composer) resolution(nodeName string) (catalogue.Resolution, error) { node, err := c.in.node(nodeName) if err != nil {