package main import ( "context" "encoding/json" "errors" "flag" "fmt" "os" "strings" "github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/secrets" ) // operatorCommand is the mesh's one holder of secrets that is not a machine. // // **Every secret a module holds for itself is sealed to the node that uses it, and a node whose key // is gone takes its secrets with it** — the store's superuser and the broker's administrator among // them. novox/hq ADR 0085 (amended) gives them a second recipient: a person, with a sealing key // whose private half is made where the operator is and never enters the mesh. Making the key and // telling the mesh about it are two commands, on purpose: the first needs no mesh at all and runs // wherever the operator keeps things; the second gives the mesh the public half and nothing else. // The controller's own container is a scratch image with no writable path, which is the right // shape for a program that must hold no key — so the private half could not be written there // even by mistake. // // operator key make [--out ] make a keypair: private half to the file, public half printed // operator key set tell the mesh which key to seal to // operator key show the public key, its fingerprint, and what it can recover const operatorUsage = "operator key make [--out ] | operator key set [--replace] | " + "operator key show | operator issue --invokes | operator revoke | " + "operator list" func operatorCommand(ctx context.Context, args []string) error { if len(args) == 0 { return errors.New(operatorUsage) } // The people who may reach the mesh's tools (design 25 §7). Beside the operator's key because // both answer "who, other than a machine, may do something here" — and a person reading this // command's usage is asking exactly that. switch args[0] { case "issue": return personIssue(ctx, args[1:]) case "revoke": return personRevoke(ctx, args[1:]) case "list": return personList(ctx) } if len(args) < 2 || args[0] != "key" { return errors.New(operatorUsage) } switch args[1] { case "make": return operatorKeyMake(args[2:]) case "set": return operatorKeySet(ctx, args[2:]) case "show": return operatorKeyShow(ctx) default: return errors.New(operatorUsage) } } // operatorKeyMake needs no mesh: it is the operator's, run wherever the key will live. func operatorKeyMake(args []string) error { set := flag.NewFlagSet("operator key make", flag.ContinueOnError) out := set.String("out", "operator.key", "where to write the private key (0600); keep it off the mesh, and keep it") if err := set.Parse(args); err != nil { return err } public, private, err := secrets.Keypair() if err != nil { return err } // Create-exclusive: a key somebody may still need is never overwritten, and there is no window // between checking and writing in which one could appear. if err := writeNew(*out, []byte(private+"\n")); err != nil { return err } fmt.Printf("operator key %s\n", secrets.Fingerprint(public)) fmt.Printf(" private half written to %s (0600) — keep it off the mesh, and keep it\n", *out) fmt.Printf(" public half, to give the mesh with `operator key set`:\n") fmt.Printf("public %s\n", public) return nil } func operatorKeySet(ctx context.Context, args []string) error { rest, flags := split(args) set := flag.NewFlagSet("operator key set", flag.ContinueOnError) replace := set.Bool("replace", false, "replace an existing operator key — secrets sealed to the old one stay sealed to it") if err := set.Parse(flags); err != nil { return err } if len(rest) != 1 { return errors.New(operatorUsage) } public := strings.TrimSpace(rest[0]) if _, err := secrets.Seal(public, []byte("probe")); err != nil { return fmt.Errorf("that is not a public sealing key: %w", err) } open, err := openStores(ctx) if err != nil { return err } defer open.Close() inv := open.inventory if current, err := inv.OperatorKey(ctx); err != nil { return err } else if current != "" && current != public && !*replace { return fmt.Errorf( "the mesh already has an operator key (%s). Pass --replace to change it — "+ "secrets sealed to the current key stay sealed to it until each is issued again", secrets.Fingerprint(current)) } orphaned, err := inv.SetOperatorKey(ctx, public) if err != nil { return err } fmt.Printf("operator key %s\n", secrets.Fingerprint(public)) fmt.Printf(" the mesh holds the public half only and cannot open what it seals to it;\n") fmt.Printf(" from now on every secret a module holds for itself is sealed to it as well.\n") fmt.Printf(" Secrets made before this cannot be — each is recoverable once issued again\n") if orphaned > 0 { fmt.Printf(" %d secret(s) are sealed to the previous key and stay so until issued again\n", orphaned) } return nil } func operatorKeyShow(ctx context.Context) error { open, err := openStores(ctx) if err != nil { return err } defer open.Close() inv := open.inventory key, err := inv.OperatorKey(ctx) if err != nil { return err } if key == "" { fmt.Println("the mesh has no operator key; `operator key make` then `operator key set` gives it one") return nil } kept, earlier, unrecoverable, err := inv.KeptForOperator(ctx) if err != nil { return err } fmt.Printf("operator key %s\n %s\n", secrets.Fingerprint(key), key) fmt.Printf(" %d secret(s) recoverable with it\n", len(kept)) if len(earlier) > 0 { fmt.Printf(" %d secret(s) sealed to an earlier operator key — recoverable with that key only, until issued again:\n", len(earlier)) for _, k := range earlier { fmt.Printf(" %s %s %s (%s)\n", k.Node, k.Module, k.Name, secrets.Fingerprint(k.Key)) } } if len(unrecoverable) > 0 { fmt.Printf(" %d secret(s) not recoverable — made before the mesh had an operator key:\n", len(unrecoverable)) for _, k := range unrecoverable { fmt.Printf(" %s %s %s\n", k.Node, k.Module, k.Name) } } return nil } // readPrivateKey is the operator's key from the file `operator key make` wrote. func readPrivateKey(path string) (string, error) { if path == "" { return "", errors.New("--key names the operator's private key, written by `operator key make`") } raw, err := os.ReadFile(path) if err != nil { return "", err } return strings.TrimSpace(string(raw)), nil } // personIssue gives somebody a credential for the mesh's tools, and prints it once. // // **Printed, not stored.** The mesh keeps a hash and nothing else, so this is the only moment the // credential exists anywhere but on the workstation that will use it — the same contract a token has, // and for the same reason: a credential recoverable from the mesh's store has the store's blast // radius. func personIssue(ctx context.Context, args []string) error { set := flag.NewFlagSet("operator issue", flag.ContinueOnError) invokes := set.String("invokes", "", "the tools this person may call, comma-separated, or * for every one") if err := set.Parse(args); err != nil { return err } if set.NArg() != 1 { return errors.New("operator issue --invokes ") } name := set.Arg(0) if *invokes == "" { return errors.New( "say what this person may call: --invokes mesh-catalog.catalog_tools,gitea.repo_create, " + "or --invokes '*' for an administrator") } var tools []string for _, t := range strings.Split(*invokes, ",") { if t = strings.TrimSpace(t); t != "" { tools = append(tools, t) } } open, err := openStores(ctx) if err != nil { return err } defer open.Close() inv := open.inventory if err := inv.RecordPerson(ctx, inventory.Person{Name: name, Invokes: tools}); err != nil { return err } // Refused here rather than at the next composition, where it would stop the whole file being // written for everybody. A name that cannot be part of a subject is one the server would read as // a wider permission than anybody granted. if _, err := broker.PermissionsFor(broker.Principal{ Kind: broker.KindPerson, Module: name, Invokes: tools, PasswordHash: "x", }); err != nil { return err } user := broker.Principal{Kind: broker.KindPerson, Module: name}.Username() password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: user, Kind: inventory.BusPerson}) if err != nil { return err } where, err := broker.FromEnvironment() if err != nil && !errors.Is(err, broker.ErrNotConfigured) { return err } held, err := json.Marshal(struct { URL string `json:"url"` Fingerprint string `json:"fingerprint,omitempty"` User string `json:"user"` Password string `json:"password"` Person string `json:"person"` Invokes []string `json:"invokes"` }{ URL: "nats://" + where.Address, Fingerprint: where.Fingerprint, User: user, Password: password, Person: name, Invokes: tools, }) if err != nil { return err } fmt.Printf("issued %s, who may call %s\n", name, strings.Join(tools, ", ")) fmt.Println(" this is the only time the credential is printed; the mesh keeps a hash") fmt.Println(" it works once the bus has been told, which is the next push to the machine holding mesh-broker") fmt.Println() fmt.Println(string(held)) return nil } func personRevoke(ctx context.Context, args []string) error { if len(args) != 1 { return errors.New("operator revoke ") } open, err := openStores(ctx) if err != nil { return err } defer open.Close() if err := open.inventory.ForgetPerson(ctx, args[0]); err != nil { return err } // **Revoked at the next composition, not now.** The bus's users are a file, so a credential stops // working when the file no longer names it. Said plainly, because "revoked" that still works for // another minute is worth knowing about. fmt.Printf("%s is forgotten, and their credential stops working at the next composition — "+ "push the machine holding mesh-broker to make it so\n", args[0]) return nil } func personList(ctx context.Context) error { open, err := openStores(ctx) if err != nil { return err } defer open.Close() people, err := open.inventory.People(ctx) if err != nil { return err } if len(people) == 0 { fmt.Println("nobody but machines reaches this mesh") return nil } for _, p := range people { fmt.Printf("%-20s %s\n", p.Name, strings.Join(p.Invokes, ", ")) } return nil }