diff --git a/cmd/mesh-controller/adoption.go b/cmd/mesh-controller/adoption.go index be79bfb7..eb93cdee 100644 --- a/cmd/mesh-controller/adoption.go +++ b/cmd/mesh-controller/adoption.go @@ -408,6 +408,9 @@ func comparisonOf(module string, c comparison, opts takeOptions) (preview string switch { case sec.Origin == inventory.OriginAccepted: fmt.Fprintf(&b, " %s %s: accepted from a person, carried in as it is\n", what, name) + case sec.Origin == inventory.OriginIssued: + // Taken from the provider's software (novox/hq ADR 0305): the value the running service uses. + fmt.Fprintf(&b, " %s %s: issued by the provider's software, carried in as it is\n", what, name) case opts.Mint[sec.Name]: fmt.Fprintf(&b, " %s %s: minted by the mesh; the service takes the new value, as --mint said\n", what, name) case foundData: diff --git a/cmd/mesh-controller/handacts.go b/cmd/mesh-controller/handacts.go index fdfa987e..147a6dca 100644 --- a/cmd/mesh-controller/handacts.go +++ b/cmd/mesh-controller/handacts.go @@ -84,6 +84,9 @@ var handActVerbs = []handActVerb{ "deliberate test, never a repair", DecidedFor: []string{causeDrill}}, // A drill: something broken on purpose to see the mesh raise and clear it. A person's test, never a // repair, however often it is run. + // A credential a provider's software issued, taken from the provider and kept for its consumers (novox/hq ADR + // 0305): the mesh working as decided, however often the software changes it. + {Verb: handActIssued, Decision: "a credential the provider's software issued, kept for its consumers (ADR 0305)"}, {Verb: "hand-act drill", Decision: "a drill is a person's deliberate test of the mesh, never a repair"}, // A person's decisions by design. {Verb: "retire approve", Decision: "nothing is retired past its bound without a person (ADR 0230)"}, diff --git a/cmd/mesh-controller/issued.go b/cmd/mesh-controller/issued.go new file mode 100644 index 00000000..1f7d6538 --- /dev/null +++ b/cmd/mesh-controller/issued.go @@ -0,0 +1,266 @@ +package main + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/secrets" +) + +// `secret issued `: the credential a provider's software issued, taken from the provider and kept as +// the provision's one credential for every consumer (novox/hq ADR 0305, issue 526). +// +// **The value comes from the provider, never from whoever asks.** A module served by its machine's runtime calls +// under the runtime's one bus account, which carries that machine's agents' calls too, so a value handed to a +// verb could be anybody's. This line carries none: it asks the provider's own tool, on the provider's machine, in +// the controller's own name — the subject that names its caller, which the bus lets the controller alone publish +// (issue 365) — with a sealing key made for this one call. The tool answers only the controller, the value sealed +// to that key; the controller opens it, seals it to every holder and forgets it, as the desk's value is (ADR 0277 +// §23). Asking again fetches the same value again, and a value every holder already holds is not sealed anew. + +// handActIssued is the verb a change of an issued value is recorded under: the mesh working as decided. +const handActIssued = "secret issued" + +// issuedAsk is how long the provider's tool is given to answer. +const issuedAsk = 20 * time.Second + +// issuedProvider is one assignment of a module whose offer says its software issues the provision's credential. +type issuedProvider struct { + Node, Module, Tool string +} + +// issuedTaker is the line's work, with what it reaches handed in: the store, the bus and the sends. +type issuedTaker struct { + // providers are the assignments to ask, and whether any module offers the provision so at all. + providers func(provision string) ([]issuedProvider, bool, error) + ask func(p issuedProvider, args map[string]any) (link.Answer, error) + keep func(provision, provider, value string) (inventory.IssuedKept, error) + record func(act link.HandAct) error + send func(machines []string) error +} + +// take asks every provider of the provision for its software's value and keeps each; it answers what it did, in +// words that never carry the value or its fingerprint. +func (t issuedTaker) take(provision string) (string, error) { + providers, offered, err := t.providers(provision) + if err != nil { + return "", err + } + if !offered { + return "", fmt.Errorf("no module offers %q with a credential its software issues (\"credential\": "+ + "{\"issued-by\": \"software\", \"tool\": …}, ADR 0305), so there is nothing to take from a provider; "+ + "nothing was asked", provision) + } + if len(providers) == 0 { + return fmt.Sprintf("no consumer holds %s from a provider whose software issues it yet, so there is nothing "+ + "to seal; a consumer that binds holds a value the mesh made until the provider next asks", provision), nil + } + var said []string + var failed []string + for _, p := range providers { + words, err := t.takeFrom(provision, p) + if err != nil { + failed = append(failed, fmt.Sprintf("%s on %s: %v", p.Module, p.Node, err)) + continue + } + said = append(said, words) + } + out := strings.Join(said, "\n") + if len(failed) > 0 { + return out, fmt.Errorf("%s was not taken from every provider:\n %s", provision, strings.Join(failed, "\n ")) + } + return out, nil +} + +// takeFrom is one provider: asked, opened, kept, recorded and sent when it changed. +func (t issuedTaker) takeFrom(provision string, p issuedProvider) (string, error) { + public, private, err := secrets.Keypair() + if err != nil { + return "", fmt.Errorf("no key could be made to take the value: %w", err) + } + answer, err := t.ask(p, map[string]any{"provision": provision, "seal_to": public}) + if err != nil { + return "", fmt.Errorf("its tool %s could not be asked: %w", p.Tool, err) + } + if answer.Error != "" { + return "", fmt.Errorf("its tool %s refused: %s", p.Tool, answer.Error) + } + var given struct { + Sealed string `json:"sealed"` + } + if err := unmarshalAnswer(answer, &given); err != nil || given.Sealed == "" { + return "", fmt.Errorf("its tool %s answered no sealed value", p.Tool) + } + opened, err := secrets.Open(private, given.Sealed) + if err != nil { + // Never the value, never what failed to open: only that it was not sealed to this call. + return "", fmt.Errorf("its tool %s answered a value not sealed to this call; nothing was taken", p.Tool) + } + value := strings.TrimSpace(string(opened)) + for i := range opened { + opened[i] = 0 + } + if value == "" { + return "", fmt.Errorf("its tool %s answered an empty value; nothing was taken", p.Tool) + } + kept, err := t.keep(provision, p.Node, value) + value = "" + if err != nil { + return "", err + } + holders := make([]string, 0, len(kept.Holders)) + for _, h := range kept.Holders { + holders = append(holders, fmt.Sprintf("%s on %s%s", h.ConsumerModule, h.Consumer, asLocal(h.Local))) + } + words := "" + if len(kept.Unsealable) > 0 { + var names []string + for _, h := range kept.Unsealable { + names = append(names, fmt.Sprintf("%s on %s", h.ConsumerModule, h.Consumer)) + } + words = fmt.Sprintf("\n not sealed to %s: no sealing key on that machine", strings.Join(names, ", ")) + } + if !kept.Changed { + return fmt.Sprintf("%s from %s on %s: every holder already holds the value its software issued (%s); "+ + "nothing was sent%s", provision, p.Module, p.Node, strings.Join(holders, ", "), words), nil + } + sealedTo := make([]string, 0, len(kept.Sealed)) + for _, h := range kept.Sealed { + sealedTo = append(sealedTo, fmt.Sprintf("%s on %s%s", h.ConsumerModule, h.Consumer, asLocal(h.Local))) + } + recorded := "" + if err := t.record(link.HandAct{Verb: handActIssued, Args: []string{provision}, + Why: fmt.Sprintf("%s on %s gave the credential its software issued for %s; sealed to %s", p.Module, p.Node, + provision, strings.Join(sealedTo, ", ")), + Cause: "issued-by-software"}); err != nil { + recorded = fmt.Sprintf("\n this was NOT recorded in the hand-act log, and is done anyway: %v", err) + } + if err := t.send(kept.Machines); err != nil { + return "", fmt.Errorf("%s from %s on %s is sealed to %s and could not be sent: %w; `push --behind` sends it", + provision, p.Module, p.Node, strings.Join(sealedTo, ", "), err) + } + return fmt.Sprintf("%s from %s on %s: the value its software issued is sealed to %s; sent %s, and each of "+ + "them starts again on it%s%s", provision, p.Module, p.Node, strings.Join(sealedTo, ", "), + strings.Join(kept.Machines, ", "), words, recorded), nil +} + +// issuedProvidersOf are the assignments to ask for a provision: every machine providing it to a pair the mesh +// holds, and on it the assigned module whose offer says its software issues the credential (ADR 0305 §1). +func issuedProvidersOf(ctx context.Context, inv *inventory.Inventory, provision string) ([]issuedProvider, bool, error) { + shelf, err := inv.Catalogue(ctx) + if err != nil { + return nil, false, err + } + if !offersIssued(shelf, provision) { + return nil, false, nil + } + holders, err := inv.HoldersOf(ctx, provision, "") + if err != nil { + return nil, true, err + } + nodes := map[string]bool{} + for _, h := range holders { + nodes[h.Provider] = true + } + names := make([]string, 0, len(nodes)) + for n := range nodes { + names = append(names, n) + } + sort.Strings(names) + var out []issuedProvider + for _, node := range names { + assigned, err := inv.Assigned(ctx, node) + if err != nil { + return nil, true, err + } + out = append(out, issuedOn(shelf, node, assigned, provision)...) + } + return out, true, nil +} + +// offersIssued is whether any module offers the provision with a credential its software issues. +func offersIssued(shelf map[string]catalogue.Manifest, provision string) bool { + for _, m := range shelf { + if _, issued := m.IssuedCredentialOf(provision); issued { + return true + } + } + return false +} + +// issuedOn are the modules assigned to one machine whose offer says their software issues the provision's +// credential, each with the tool that answers it. +func issuedOn(shelf map[string]catalogue.Manifest, node string, assigned []string, provision string) []issuedProvider { + var out []issuedProvider + for _, module := range assigned { + if tool, issued := shelf[module].IssuedCredentialOf(provision); issued { + out = append(out, issuedProvider{Node: node, Module: module, Tool: tool}) + } + } + return out +} + +// issuedRefusesRotation refuses `rotate` of a provision its provider's software issues (ADR 0305 §8): a value the +// mesh made would replace one the software holds, and every consumer would hold a dead credential until the +// provider next asks. +func issuedRefusesRotation(shelf map[string]catalogue.Manifest, provision string) error { + if !offersIssued(shelf, provision) { + return nil + } + return fmt.Errorf("%s's credential is issued by its provider's software, which the mesh cannot make or "+ + "rotate (ADR 0305): change it in that software, and `secret issued %s` takes the value it holds now. "+ + "Nothing was rotated", provision, provision) +} + +// secretIssued is `secret issued `, on this controller's stores and bus. +func secretIssued(ctx context.Context, args []string) error { + if len(args) != 1 || args[0] == "" || strings.HasPrefix(args[0], "-") { + return errors.New("secret issued ") + } + provision := args[0] + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + t := issuedTaker{ + providers: func(provision string) ([]issuedProvider, bool, error) { + return issuedProvidersOf(ctx, open.inventory, provision) + }, + ask: func(p issuedProvider, args map[string]any) (link.Answer, error) { + var answer link.Answer + err := onTheBus(func(conn *nats.Conn) error { + var err error + answer, err = link.AskModuleToolOnAs(ctx, conn, broker.ControllerName, p.Module, p.Tool, p.Node, + args, issuedAsk) + return err + }) + return answer, err + }, + keep: func(provision, provider, value string) (inventory.IssuedKept, error) { + return open.inventory.KeepIssuedSecret(ctx, provision, provider, value) + }, + record: func(act link.HandAct) error { + return onTheBus(func(conn *nats.Conn) error { + _, err := link.RecordHandAct(ctx, conn, act) + return err + }) + }, + send: func(machines []string) error { return sendTo(ctx, open, machines) }, + } + words, err := t.take(provision) + if words != "" { + fmt.Println(words) + } + return err +} diff --git a/cmd/mesh-controller/issued_test.go b/cmd/mesh-controller/issued_test.go new file mode 100644 index 00000000..36fc63ae --- /dev/null +++ b/cmd/mesh-controller/issued_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/secrets" +) + +// The `issued` verb runs `secret issued ` and nothing else, and that line is the one `secret` line +// beside rotate and the desk's that a verb may run (novox/hq ADR 0305 §3, §9, issue 526). +func TestTheIssuedVerbRunsTheOneLineThatCarriesNoValue(t *testing.T) { + argv, err := argvFor("issued", map[string]any{"provision": "plex-api"}) + if err != nil { + t.Fatal(err) + } + if strings.Join(argv, " ") != "secret issued plex-api" { + t.Fatalf("the verb composed %q", argv) + } + if _, err := argvFor("issued", map[string]any{}); err == nil { + t.Fatal("the verb ran with no provision") + } + if _, err := argvFor("issued", map[string]any{"provision": "plex-api", "secret": "a value"}); err == nil { + t.Fatal("the verb took a value beside the provision") + } + for _, line := range [][]string{ + {"secret", "issued", "plex-api", "--from", "/tmp/x"}, + {"secret", "issued", "--from"}, + {"secret", "issued"}, + {"secret", "accept", "ace", "kometa", "plex-api", "--provider", "ace"}, + {"secret", "recover", "ace", "kometa", "plex-api"}, + } { + if err := terminalOnly(line); err == nil { + t.Errorf("%q ran through a verb", line) + } + } + if err := terminalOnly([]string{"secret", "issued", "plex-api"}); err != nil { + t.Fatalf("the issued line was refused through its verb: %v", err) + } + found := false + for _, v := range catalogue.ControllerVerbs { + if v.Name == "issued" { + found = true + } + } + if !found { + t.Fatal("the controller's seat does not declare the issued verb") + } +} + +// A provider's tool answering the value sealed to the call's key: what a provider does (ADR 0305 §2). +func issuedSealed(t *testing.T, value string) func(issuedProvider, map[string]any) (link.Answer, error) { + return func(_ issuedProvider, args map[string]any) (link.Answer, error) { + key, _ := args["seal_to"].(string) + if key == "" { + t.Fatal("the provider was asked without a key to seal to") + } + if _, carried := args["value"]; carried { + t.Fatal("the ask carried a value") + } + sealed, err := secrets.Seal(key, []byte(value)) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(map[string]string{"sealed": sealed}) + return link.Answer{Result: raw}, nil + } +} + +type issuedRecorder struct { + kept []string + acts []link.HandAct + sent [][]string + again bool // the value is held by every holder already +} + +func (r *issuedRecorder) taker(t *testing.T, ask func(issuedProvider, map[string]any) (link.Answer, error)) issuedTaker { + return issuedTaker{ + providers: func(provision string) ([]issuedProvider, bool, error) { + return []issuedProvider{{Node: "ace", Module: "plex", Tool: "plex_issued_credential"}}, true, nil + }, + ask: ask, + keep: func(provision, provider, value string) (inventory.IssuedKept, error) { + r.kept = append(r.kept, value) + holders := []inventory.Holder{{Provision: provision, Consumer: "ace", ConsumerModule: "kometa", Provider: provider}, + {Provision: provision, Consumer: "ace", ConsumerModule: "tautulli", Provider: provider}} + if r.again { + return inventory.IssuedKept{Holders: holders}, nil + } + return inventory.IssuedKept{Changed: true, Holders: holders, Sealed: holders, Machines: []string{"ace"}}, nil + }, + record: func(act link.HandAct) error { r.acts = append(r.acts, act); return nil }, + send: func(machines []string) error { r.sent = append(r.sent, machines); return nil }, + } +} + +// The value the provider's tool sealed to the call is kept, recorded and sent, and the answer never says it. +func TestAnIssuedValueIsTakenSealedFromTheProviderAndSent(t *testing.T) { + var r issuedRecorder + words, err := r.taker(t, issuedSealed(t, "the-servers-own-token")).take("plex-api") + if err != nil { + t.Fatal(err) + } + if len(r.kept) != 1 || r.kept[0] != "the-servers-own-token" { + t.Fatalf("the provider's value was not kept: %d kept", len(r.kept)) + } + if len(r.sent) != 1 || strings.Join(r.sent[0], ",") != "ace" { + t.Fatalf("the holders' machines were not sent: %v", r.sent) + } + if len(r.acts) != 1 || r.acts[0].Verb != handActIssued { + t.Fatalf("the change was not recorded in the hand-act log: %+v", r.acts) + } + for _, said := range []string{words, r.acts[0].Why, strings.Join(r.acts[0].Args, " ")} { + if strings.Contains(said, "the-servers-own-token") || strings.Contains(said, inventory.IssuedStamp("the-servers-own-token")) { + t.Fatalf("the value or its fingerprint was said: %q", said) + } + } + if !strings.Contains(words, "kometa on ace") || !strings.Contains(words, "tautulli on ace") { + t.Fatalf("the answer does not name the holders: %q", words) + } +} + +// The same value again writes nothing, records nothing and sends nobody (ADR 0305 §5). +func TestAnIssuedValueEveryHolderHoldsSendsNobody(t *testing.T) { + r := issuedRecorder{again: true} + words, err := r.taker(t, issuedSealed(t, "the-servers-own-token")).take("plex-api") + if err != nil { + t.Fatal(err) + } + if len(r.sent) != 0 || len(r.acts) != 0 { + t.Fatalf("an unchanged value was sent or recorded: %v %v", r.sent, r.acts) + } + if !strings.Contains(words, "nothing was sent") { + t.Fatalf("the answer does not say nothing was sent: %q", words) + } +} + +// A value not sealed to the call's key is not taken, nor is a refusal, nor an empty answer (ADR 0305 §2). +func TestOnlyAValueSealedToTheCallIsTaken(t *testing.T) { + other, _, _ := secrets.Keypair() + elsewhere, _ := secrets.Seal(other, []byte("somebody's value")) + raw, _ := json.Marshal(map[string]string{"sealed": elsewhere}) + plain, _ := json.Marshal(map[string]string{"value": "the-servers-own-token"}) + for name, ask := range map[string]func(issuedProvider, map[string]any) (link.Answer, error){ + "sealed to another key": func(issuedProvider, map[string]any) (link.Answer, error) { return link.Answer{Result: raw}, nil }, + "answered in the clear": func(issuedProvider, map[string]any) (link.Answer, error) { return link.Answer{Result: plain}, nil }, + "refused": func(issuedProvider, map[string]any) (link.Answer, error) { + return link.Answer{Error: "the controller alone"}, nil + }, + "not reached": func(issuedProvider, map[string]any) (link.Answer, error) { + return link.Answer{}, errors.New("503 no responders") + }, + "sealed empty": issuedSealed(t, " "), + } { + var r issuedRecorder + if _, err := r.taker(t, ask).take("plex-api"); err == nil { + t.Errorf("%s: taken", name) + } + if len(r.kept) != 0 || len(r.sent) != 0 { + t.Errorf("%s: something was kept or sent", name) + } + } +} + +// A provision no offer says its software issues is refused before anything is asked. +func TestAProvisionNoOfferSaysIsIssuedIsRefused(t *testing.T) { + asked := false + taker := issuedTaker{ + providers: func(string) ([]issuedProvider, bool, error) { return nil, false, nil }, + ask: func(issuedProvider, map[string]any) (link.Answer, error) { + asked = true + return link.Answer{}, nil + }, + } + if _, err := taker.take("postgres-database"); err == nil || !strings.Contains(err.Error(), "ADR 0305") { + t.Fatalf("expected a refusal naming the record, got %v", err) + } + if asked { + t.Fatal("a provider was asked for a provision no offer says its software issues") + } +} + +// Which assignments are asked: the modules on the providing machine whose offer says so, with their tool. +func TestTheProvidersAskedAreTheOffersThatSaySo(t *testing.T) { + shelf := map[string]catalogue.Manifest{ + "plex": {Module: "plex", Provides: []catalogue.Offer{{Name: "plex-api", Credential: &catalogue.OfferCredential{ + IssuedBy: catalogue.IssuedBySoftware, Tool: "plex_issued_credential"}}}}, + "jellyfin": {Module: "jellyfin", Provides: []catalogue.Offer{{Name: "plex-api"}}}, + } + got := issuedOn(shelf, "ace", []string{"jellyfin", "plex", "kometa"}, "plex-api") + if len(got) != 1 || got[0] != (issuedProvider{Node: "ace", Module: "plex", Tool: "plex_issued_credential"}) { + t.Fatalf("the providers asked: %+v", got) + } + if err := issuedRefusesRotation(shelf, "plex-api"); err == nil || !strings.Contains(err.Error(), "secret issued plex-api") { + t.Fatalf("rotate of an issued provision was not refused with the way to take it: %v", err) + } + if err := issuedRefusesRotation(shelf, "postgres-database"); err != nil { + t.Fatalf("rotate of an ordinary provision was refused: %v", err) + } +} + +// A change of an issued value is the mesh working as decided, never a repair (ADR 0305 §7). +func TestAnIssuedValueIsADecisionInTheHandActLog(t *testing.T) { + for _, v := range handActVerbs { + if v.Verb == handActIssued { + if v.Decision == "" { + t.Fatal("secret issued counts as a repair") + } + return + } + } + t.Fatal("secret issued is not in the hand-act table") +} diff --git a/cmd/mesh-controller/rotate.go b/cmd/mesh-controller/rotate.go index 06d7a88a..0bb31a93 100644 --- a/cmd/mesh-controller/rotate.go +++ b/cmd/mesh-controller/rotate.go @@ -55,6 +55,15 @@ func rotateCommand(ctx context.Context, args []string) error { defer open.Close() inv := open.inventory + // A credential the provider's software issued is not the mesh's to remake (novox/hq ADR 0305 §8). + shelf, err := inv.Catalogue(ctx) + if err != nil { + return err + } + if err := issuedRefusesRotation(shelf, provision); err != nil { + return err + } + holders, err := inv.HoldersOf(ctx, provision, *only) if err != nil { return err diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index ac0030d0..5c6c30ab 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -777,6 +777,13 @@ func (a *verbArguments) commandLine() ([]string, error) { return nil, err } return []string{"secret", "ask", str("node"), str("module"), str("secret"), "--at", str("at")}, nil + case "issued": + // The credential a provider's software issued, taken from the provider (novox/hq ADR 0305): no value in + // the arguments, the provision alone. + if err := need("provision"); err != nil { + return nil, err + } + return []string{"secret", "issued", str("provision")}, nil case "secret-ask": // A module's own secret asked for, typed by the operator at the desk (novox/hq ADR 0277): never a value // in the arguments. The desk is the module's machine unless at names another. @@ -1583,6 +1590,14 @@ func givenAtTheDesk(argv []string) bool { return argv[5] == "--at" && argv[6] != "" && !strings.HasPrefix(argv[6], "-") } +// takenFromTheProvider is exactly the line the `issued` verb composes, and nothing beside it: `secret issued +// ` (novox/hq ADR 0305 §9). It carries no value — the value comes from the provider's own tool, sealed to +// the call — and answers none. +func takenFromTheProvider(argv []string) bool { + return len(argv) == 3 && argv[0] == "secret" && argv[1] == "issued" && argv[2] != "" && + !strings.HasPrefix(argv[2], "-") +} + // terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal // alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and // `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself @@ -1599,7 +1614,7 @@ func terminalOnly(argv []string) error { // Of a secret's commands only rotation, which seals the new value to the machine that uses it, and the // `give` verb's own line: an own secret typed by the operator into the desk's hidden prompt, sealed to this // call and then to the module's machine, so no value travels in the verb or its answer (hq ADR 0259 §10). - if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") && !givenAtTheDesk(argv) { + if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") && !givenAtTheDesk(argv) && !takenFromTheProvider(argv) { return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+ "recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+ "0266). Nothing was done", strings.Join(argv[1:], " ")) diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index 37c3cc3c..3b15167c 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -41,6 +41,9 @@ func secretCommand(ctx context.Context, args []string) error { case "accept": case "ask": return secretAsk(ctx, args[1:]) + case "issued": + // The credential a provider's software issued, taken from the provider (novox/hq ADR 0305). + return secretIssued(ctx, args[1:]) case "rotate": return secretRotate(ctx, args[1:]) case "recover": @@ -168,6 +171,7 @@ func secretAsk(ctx context.Context, args []string) error { const secretUsage = "secret rotate [--why [--cause ]]\n" + "secret ask [--at ]\n" + + "secret issued \n" + "secret accept [--from | --at-desk ] [--provider [--local ]]\n" + "secret recover --key [--out ] [--from-export ] [--provider ]\n" + "secret export [--out ]" diff --git a/internal/catalogue/issued_credential_test.go b/internal/catalogue/issued_credential_test.go new file mode 100644 index 00000000..221e67ae --- /dev/null +++ b/internal/catalogue/issued_credential_test.go @@ -0,0 +1,42 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer +// names the tool that answers it and no own secret, and every other shape is refused at parse. +func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"media","version":"1", + "provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}], + "serves":{"media-api":{"port":32400}}}`)) + if err != nil { + t.Fatal(err) + } + if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" { + t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued) + } + if _, shared := m.SharedCredentialOf("media-api"); shared { + t.Fatal("an issued credential was read as an own secret shared") + } + if _, issued := m.IssuedCredentialOf("other"); issued { + t.Fatal("a provision the module does not offer reads as issued") + } + + for want, raw := range map[string]string{ + "the one issuer an offer may name": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`, + "names no tool that answers it": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`, + "names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}}, + "provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`, + "the one issuer an offer may name ": `{"module":"d","version":"1", + "provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`, + } { + want = strings.TrimSpace(want) + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("expected a refusal saying %q, got %v", want, err) + } + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index e344cb94..999b5ebd 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -268,9 +268,29 @@ func KeepsConsumerData(catalogue map[string]Manifest, provision string) bool { // MachineReach is whether a provision is usable only on its provider's own machine. func (o Offer) MachineReach() bool { return o.Reach == ReachMachine } -// OfferCredential names which of the provider's own secrets a provision's consumers receive. +// OfferCredential names which of the provider's own secrets a provision's consumers receive — or, with +// IssuedBy, that the provider's software issues the provision's one credential itself (novox/hq ADR 0305, +// issue 526): a media server's sign-in token from its vendor, which the mesh can neither make nor rotate. +// Tool is then the provider's own tool that answers that value, sealed to a key made for the controller's +// one call, and to the controller alone. type OfferCredential struct { - Own string `json:"own"` + Own string `json:"own,omitempty"` + IssuedBy string `json:"issued-by,omitempty"` + Tool string `json:"tool,omitempty"` +} + +// IssuedBySoftware says a provision's credential is issued by its provider's software (novox/hq ADR 0305). +const IssuedBySoftware = "software" + +// IssuedCredentialOf is the tool through which this module answers the credential its software issued for a +// provision, and whether its offer says the software issues it (novox/hq ADR 0305). +func (m Manifest) IssuedCredentialOf(provision string) (string, bool) { + for _, o := range m.Provides { + if o.Name == provision && o.Credential != nil && o.Credential.IssuedBy == IssuedBySoftware && o.Credential.Tool != "" { + return o.Credential.Tool, true + } + } + return "", false } // SharedCredentialOf is the own secret an offer of this module names as the provision's credential, @@ -1550,7 +1570,24 @@ func ParseManifest(raw []byte) (Manifest, error) { "makes is %d", m.Module, p, id.Max, shortest)) } } - if offer.Credential != nil { + if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") { + // The software issues it (novox/hq ADR 0305): named by the tool that answers it, never by an own + // secret, which would be a second copy for the mesh to keep in step with the software's. + switch { + case c.IssuedBy != IssuedBySoftware: + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential issued by %q; the one issuer an offer may name is %q (ADR 0305)", + m.Module, p, c.IssuedBy, IssuedBySoftware)) + case c.Own != "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential its software issues and names its own secret %q too; the "+ + "software's value is the one, so the offer names no own secret (ADR 0305)", m.Module, p, c.Own)) + case strings.TrimSpace(c.Tool) == "": + problems = append(problems, fmt.Sprintf( + "%s provides %q with a credential its software issues and names no tool that answers it: "+ + "\"tool\": \"\" (ADR 0305)", m.Module, p)) + } + } else if offer.Credential != nil { own, declared := m.OwnSecrets[offer.Credential.Own] switch { case offer.Credential.Own == "": diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 40bba5f7..013f5c77 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -249,6 +249,15 @@ var ControllerVerbs = []Verb{ "secret": "the own secret's name in the module's definition", "at": "the machine the operator sits at, where the prompt opens", }, []string{"node", "module", "secret", "at"})}, + {Name: "issued", Description: "Take the credential a provider's software issued for a provision — a media " + + "server's sign-in token — and keep it as that provision's one credential for every consumer (novox/hq ADR " + + "0305): the controller asks the provider's own tool on its machine, in the controller's own name, for the " + + "value sealed to a key made for this call alone, seals it to every holder and forgets it, and sends their " + + "machines when it changed. Carries no value and answers none; a value every holder already holds is not " + + "sealed again, so asking again restarts nothing. Refused for a provision no offer says its software issues.", + Input: schema(map[string]string{ + "provision": "the provision whose provider's software issues its credential, e.g. plex-api", + }, []string{"provision"})}, {Name: "secret-ask", Description: "Ask the operator for a module's own secret (novox/hq ADR 0277): a prompt " + "that shows nothing of what is typed opens at the desk — the module's machine, or the one at names — " + "naming the module, the secret and who asked; the operator types the value there, never on a channel " + diff --git a/internal/inventory/issued.go b/internal/inventory/issued.go new file mode 100644 index 00000000..a4ec4cd1 --- /dev/null +++ b/internal/inventory/issued.go @@ -0,0 +1,155 @@ +package inventory + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/novox/mesh-controller/internal/secrets" +) + +// A credential a provider's software issued, kept as the provision's one credential (novox/hq ADR 0305, issue +// 526). +// +// **The software made it, so the mesh carries it.** A media server's sign-in token comes from its vendor; the mesh +// can neither make nor rotate it. The controller fetches it from the provider's own tool, sealed to a key made for +// that one call, and hands the value here, which seals it to every consumer that holds the provision from that +// provider — in the pair credential's shape, so a consumer's definition does not know where it came from (ADR 0158 +// §5) — and to the operator, and forgets it. +// +// **Asked often, written seldom.** The provider asks hourly and on every start. The mesh keeps no plaintext, so it +// cannot compare values; it stamps every row with the value's fingerprint instead, and a value whose stamp every +// row already carries is not sealed again — sealing anew would change every consumer's declaration and restart +// each of them for nothing. + +// OriginIssued is a pair credential the provider's software issued and the provider gave (novox/hq ADR 0305). +const OriginIssued = "issued" + +// IssuedStamp is the generation a value issued by a provider's software is kept under: a fingerprint of the value, +// so the same value fetched again is known as the same without being kept. +func IssuedStamp(value string) string { + sum := sha256.Sum256([]byte(value)) + return "issued:sha256:" + hex.EncodeToString(sum[:8]) +} + +// IssuedKept is what keeping an issued value did: whether it was sealed anew, the pairs that hold it, and the +// machines a send must reach — the provider's and every holder's — when it was. +type IssuedKept struct { + Changed bool + // Holders are every pair that holds the provision from the provider; Sealed those sealed anew by this act. + Holders []Holder + Sealed []Holder + Machines []string + // Unsealable are the holders whose machine has no sealing key: left as they are, said by name. + Unsealable []Holder +} + +// KeepIssuedSecret seals a value a provider's software issued to every pair of provision from provider that does +// not hold it yet, under one stamp; a pair that does is left as it is, and when every pair does nothing is written +// (novox/hq ADR 0305 §4, §5). +func (i *Inventory) KeepIssuedSecret(ctx context.Context, provision, provider, value string) (IssuedKept, error) { + if strings.TrimSpace(value) == "" { + return IssuedKept{}, fmt.Errorf("there is nothing to seal") + } + providerNode, err := i.NodeByName(ctx, provider) + if err != nil { + return IssuedKept{}, err + } + providerKey, err := i.SealingKeyOf(ctx, provider) + if err != nil { + return IssuedKept{}, err + } + if providerKey == "" { + return IssuedKept{}, fmt.Errorf("%s has no sealing key, so %s cannot be sealed to it", provider, provision) + } + stamp := IssuedStamp(value) + + type row struct { + holder Holder + consumerID any + generation, heldC, heldP string + consumerKey string + } + rows, err := i.store.Pool().Query(ctx, + `select s.consumer, c.name, s.consumer_module, s.local, coalesce(s.generation, ''), + coalesce(s.consumer_key, ''), coalesce(s.provider_key, ''), coalesce(c.sealing_key, '') + from secret s join node c on c.id = s.consumer + where s.provider = $1 and s.name = $2 + order by c.name, s.consumer_module, s.local`, providerNode.ID, provision) + if err != nil { + return IssuedKept{}, err + } + var held []row + for rows.Next() { + var r row + if err := rows.Scan(&r.consumerID, &r.holder.Consumer, &r.holder.ConsumerModule, &r.holder.Local, + &r.generation, &r.heldC, &r.heldP, &r.consumerKey); err != nil { + rows.Close() + return IssuedKept{}, err + } + r.holder.Provision, r.holder.Provider = provision, provider + held = append(held, r) + } + rows.Close() + if err := rows.Err(); err != nil { + return IssuedKept{}, err + } + + var kept IssuedKept + var stale []row + for _, r := range held { + if r.consumerKey == "" { + kept.Unsealable = append(kept.Unsealable, r.holder) + continue + } + kept.Holders = append(kept.Holders, r.holder) + // Only a pair that does not hold this value, sealed to the keys its ends have now, is sealed anew: a + // holder that already does is left byte for byte as it is, so it is not restarted for another's sake. + if r.generation != stamp || r.heldC != r.consumerKey || r.heldP != providerKey { + stale = append(stale, r) + } + } + if len(stale) == 0 { + return kept, nil + } + + forOperator, operatorKey, err := i.operatorSeal(ctx, value) + if err != nil { + return IssuedKept{}, err + } + tx, err := i.store.Pool().Begin(ctx) + if err != nil { + return IssuedKept{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + machines := map[string]bool{provider: true} + for _, r := range stale { + sealed, err := secrets.Accept(value, r.consumerKey, providerKey) + if err != nil { + return IssuedKept{}, err + } + if _, err := tx.Exec(ctx, + `update secret set for_consumer = $6, for_provider = $7, consumer_key = $8, provider_key = $9, + operator_sealed = $10, operator_key = $11, origin = $12, generation = $13, created_at = now() + where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`, + provision, r.consumerID, r.holder.ConsumerModule, providerNode.ID, r.holder.Local, + sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey, + forOperator, operatorKey, OriginIssued, stamp); err != nil { + return IssuedKept{}, err + } + machines[r.holder.Consumer] = true + kept.Sealed = append(kept.Sealed, r.holder) + } + if err := tx.Commit(ctx); err != nil { + return IssuedKept{}, err + } + kept.Changed = true + for m := range machines { + kept.Machines = append(kept.Machines, m) + } + sort.Strings(kept.Machines) + return kept, nil +} diff --git a/internal/inventory/issued_test.go b/internal/inventory/issued_test.go new file mode 100644 index 00000000..977eb188 --- /dev/null +++ b/internal/inventory/issued_test.go @@ -0,0 +1,124 @@ +package inventory + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// A value a provider's software issued is sealed to every pair that holds the provision from that provider, under +// one stamp of the value, and the same value given again writes nothing and sends nobody (novox/hq ADR 0305 §4, +// §5, issue 526). +func TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain(t *testing.T) { + inv, ctx := twoNodesWithKeys(t) + consumerKey, consumerOpens := aSealingKey(t) + cn, _ := inv.NodeByName(ctx, "consumer") + if err := inv.RecordSealingKey(ctx, cn.ID, consumerKey); err != nil { + t.Fatal(err) + } + for _, m := range []string{"kometa", "tautulli"} { + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"plex-api"}, + Secrets: map[string]string{"plex-api": "/run/plex-api.secret"}}, Source{}); err != nil { + t.Fatal(err) + } + // What the mesh made before the provider gave its value: a random value per pair (issue 164). + if _, err := inv.SecretFor(ctx, "plex-api", "consumer", m, "provider", ""); err != nil { + t.Fatal(err) + } + } + + kept, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token") + if err != nil { + t.Fatal(err) + } + if !kept.Changed || len(kept.Holders) != 2 { + t.Fatalf("the value was not sealed to both holders: %+v", kept) + } + if len(kept.Machines) != 2 || kept.Machines[0] != "consumer" || kept.Machines[1] != "provider" { + t.Fatalf("the machines to send are the provider's and every holder's: %v", kept.Machines) + } + for _, m := range []string{"kometa", "tautulli"} { + got, err := inv.SecretFor(ctx, "plex-api", "consumer", m, "provider", "") + if err != nil { + t.Fatal(err) + } + if got.Origin != OriginIssued { + t.Fatalf("%s's pair says it came from %q, not the provider's software", m, got.Origin) + } + opened, ok := consumerOpens(got.ForConsumer) + if !ok || string(opened) != "the-servers-own-token" { + t.Fatalf("%s's copy is not the provider's value", m) + } + var generation string + pn, _ := inv.NodeByName(ctx, "provider") + if err := inv.store.Pool().QueryRow(ctx, `select generation from secret where name = 'plex-api' and consumer = $1 + and consumer_module = $2 and provider = $3`, cn.ID, m, pn.ID).Scan(&generation); err != nil { + t.Fatal(err) + } + if generation != IssuedStamp("the-servers-own-token") { + t.Fatalf("%s's pair is not stamped with the value's fingerprint: %q", m, generation) + } + } + before, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", "") + + // A consumer that binds later gets a pair of its own, and no holder's value is remade for it: the offer names + // no own secret, so ADR 0158 §6's remaking on a bind does not reach it (ADR 0305 §6). + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "overseerr", Version: "1", Requires: []string{"plex-api"}, + Secrets: map[string]string{"plex-api": "/run/plex-api.secret"}}, Source{}); err != nil { + t.Fatal(err) + } + late, err := inv.SecretFor(ctx, "plex-api", "consumer", "overseerr", "provider", "") + if err != nil { + t.Fatal(err) + } + if late.Origin == OriginIssued { + t.Fatal("a consumer that bound after the ask holds the issued value without an ask") + } + if held, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", ""); held.ForConsumer != before.ForConsumer { + t.Fatal("a later bind remade a holder's issued value") + } + + // The next ask seals the provider's value to the late consumer alone; the holders that had it are not touched. + again, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token") + if err != nil { + t.Fatal(err) + } + if !again.Changed || len(again.Sealed) != 1 || again.Sealed[0].ConsumerModule != "overseerr" { + t.Fatalf("the late consumer was not sealed to, or not alone: %+v", again) + } + if held, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", ""); held.ForConsumer != before.ForConsumer { + t.Fatal("sealing the late consumer changed what a holder already held") + } + if got, _ := inv.SecretFor(ctx, "plex-api", "consumer", "overseerr", "provider", ""); got.Origin != OriginIssued { + t.Fatal("the late consumer does not hold the issued value after the ask") + } + + // And once every holder holds it, the same value writes nothing and sends nobody. + still, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token") + if err != nil { + t.Fatal(err) + } + if still.Changed || len(still.Machines) != 0 { + t.Fatalf("the same value was sealed again, which restarts every consumer for nothing: %+v", still) + } + after, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", "") + if after.ForConsumer != before.ForConsumer { + t.Fatal("the same value given again changed what the consumer holds") + } + + changed, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "a-token-plex-changed") + if err != nil { + t.Fatal(err) + } + if !changed.Changed { + t.Fatal("a changed value was not sealed") + } + got, _ := inv.SecretFor(ctx, "plex-api", "consumer", "tautulli", "provider", "") + if opened, ok := consumerOpens(got.ForConsumer); !ok || string(opened) != "a-token-plex-changed" { + t.Fatal("the changed value did not reach the holder") + } + + if _, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", " "); err == nil { + t.Fatal("an empty value was kept") + } +} diff --git a/internal/link/retirement.go b/internal/link/retirement.go index 2f580c1c..c0bd8330 100644 --- a/internal/link/retirement.go +++ b/internal/link/retirement.go @@ -173,6 +173,25 @@ func ModuleToolOn(module, tool, node string) string { return ToolSubject(module, // AskModuleToolOn asks one machine's instance of a module one of its tools and reads its answer. func AskModuleToolOn(ctx context.Context, conn *nats.Conn, module, tool, node string, args any, + timeout time.Duration) (Answer, error) { + return askModuleToolAt(ctx, conn, ModuleToolOn(module, tool, node), module, tool, node, args, timeout) +} + +// AskModuleToolOnAs is AskModuleToolOn in the caller's own name: on the subject that names its caller (novox/hq +// issue 365), which the bus lets only that user publish, so the tool knows who asks from the subject alone. The +// one way a tool can answer the controller and nobody else (novox/hq ADR 0305 §2). +func AskModuleToolOnAs(ctx context.Context, conn *nats.Conn, caller, module, tool, node string, args any, + timeout time.Duration) (Answer, error) { + subject := broker.CalledSubject(ModuleToolOn(module, tool, node), caller) + if subject == "" { + return Answer{}, fmt.Errorf("%q cannot ask %s.%s on %s in its own name: it is not a bus user's name", caller, + module, tool, node) + } + return askModuleToolAt(ctx, conn, subject, module, tool, node, args, timeout) +} + +// askModuleToolAt asks one machine's instance of a module one of its tools on subject and reads its answer. +func askModuleToolAt(ctx context.Context, conn *nats.Conn, subject, module, tool, node string, args any, timeout time.Duration) (Answer, error) { body, err := json.Marshal(args) if err != nil { @@ -180,7 +199,6 @@ func AskModuleToolOn(ctx context.Context, conn *nats.Conn, module, tool, node st } asking, cancel := context.WithTimeout(ctx, timeout) defer cancel() - subject := ModuleToolOn(module, tool, node) refused, stop := refusalsOf(conn, subject) defer stop() type replied struct { diff --git a/module.json b/module.json index ba7c0ad5..f3d79f41 100644 --- a/module.json +++ b/module.json @@ -77,6 +77,7 @@ "drill", "warranted", "secret-ask", + "issued", "hand-acts", "durations", "conditions", diff --git a/testdata/store-tests b/testdata/store-tests index fa3c81b5..05e02fb2 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -10,3 +10,4 @@ ./internal/inventory TestTheLastSendSaysWhatTheMachineAnswered ./internal/inventory TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend ./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore +./internal/inventory TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain