From 7e9c28fd9ef227be0903d8baf001e9fd166fe3cf Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 21:57:57 +0200 Subject: [PATCH] =?UTF-8?q?secret=20accept=20=E2=80=94=20carry=20a=20value?= =?UTF-8?q?=20the=20mesh=20did=20not=20make?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry point for adopting something already running, and the half that was missing. The store has carried the distinction since the beginning — a module secret records whether it was `made` or `accepted`, and refuses to invent a replacement for the second — and AcceptSecretForModule existed, with exactly one caller: the broker account issued to a build machine. Nothing else could write one. Without it every module secret is generated, which against a database that already exists puts 32 random bytes where a working credential was. The machine applies it, reports success, and whatever reads it fails to authenticate somewhere else entirely, with the mesh insisting the secret was delivered — which it was. The value is read from a file or from standard input, never from an argument: a value on the command line is in the shell's history and in the process list. Same path a model-access key already takes, and no new dependency — the first version reached for x/term and the existing one needed nothing. Sealed on the way in, plaintext discarded, and not printed back. The only difference from a generated secret is where the value came from. Two rules with a test each, and the second is the one that would have been got wrong: only the line ending is removed, never surrounding space. Trimming both ends is the obvious thing and would deliver a password chosen with a leading space as a different password, silently. Both were briefly untested for different reasons — the trimming lived where no test could reach it, and then a -run filter matched neither test. Extracted, and injected against the whole suite. --- cmd/mesh-control/main.go | 4 ++ cmd/mesh-control/secret.go | 119 ++++++++++++++++++++++++++++++++ cmd/mesh-control/secret_test.go | 69 ++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 cmd/mesh-control/secret.go create mode 100644 cmd/mesh-control/secret_test.go diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 94440aa..67aacec 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -94,6 +94,8 @@ func run() error { return assignCommand(ctx, args[0], args[1:]) case "settings": return settingsCommand(ctx, args[1:]) + case "secret": + return secretCommand(ctx, args[1:]) case "plan": return planCommand(ctx, args[1:]) case "push": @@ -138,6 +140,8 @@ func usage() { settings set what a module's config should say, for the whole mesh settings set --node ...or for one machine settings clear [--node ] take a layer away + secret accept carry a value the mesh did not make and cannot invent + secret accept ... --from ...read it from a file rather than being asked build [--ref R] have a build machine build it, and record what came out build --behind build every module the mesh holds older than its source builds [] what has been built lately, and what came of it diff --git a/cmd/mesh-control/secret.go b/cmd/mesh-control/secret.go new file mode 100644 index 0000000..8370f55 --- /dev/null +++ b/cmd/mesh-control/secret.go @@ -0,0 +1,119 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" +) + +// secretCommand gives the mesh a value it must carry and could not have invented. +// +// **Every other secret in this mesh is one the mesh made** — generated, sealed to the machine that +// will use it, and never readable again. That is right for something coming into existence, and +// wrong for something that already exists: a database created last year has the password it was +// created with, and generating a new one puts 32 random bytes where a working credential was. +// The machine applies it, reports success, and whatever reads it fails to authenticate somewhere +// else entirely — with the mesh insisting the secret was delivered, which it was. +// +// So this is the entry point for **adopting** something already running. The store has carried +// the distinction since the beginning: a module secret records whether it was `made` or +// `accepted`, and refuses to invent a replacement for the second. Nothing until now could write +// one, so the only accepted secret in the mesh was the broker account issued to a build machine. +// +// The value is sealed on the way in and the plaintext discarded, exactly as a generated one is. +// **The only difference between the two is where the value came from.** +func secretCommand(ctx context.Context, args []string) error { + if len(args) == 0 || args[0] != "accept" { + return errors.New("secret accept [--from ]") + } + set := flag.NewFlagSet("secret accept", flag.ContinueOnError) + from := set.String("from", "", + "read the value from this file instead of asking (use - for standard input)") + if err := set.Parse(args[1:]); err != nil { + return err + } + rest := set.Args() + if len(rest) != 3 { + return errors.New("secret accept [--from ]") + } + node, module, name := rest[0], rest[1], rest[2] + + value, err := valueFor(node, module, name, *from) + if err != nil { + return err + } + value = asSupplied(value) + if value == "" { + return errors.New("there is nothing to seal") + } + + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + + if err := open.inventory.AcceptSecretForModule(ctx, node, module, name, value); err != nil { + return err + } + // Not printed back, and there is nowhere it could be printed from: it is sealed to that + // machine and the mesh cannot read it again. + fmt.Printf("%s on %s now holds %q, sealed to that machine.\n", module, node, name) + fmt.Printf(" the mesh cannot read it back, and will not replace it with one of its own\n") + fmt.Printf(" run `push %s` to send it\n", node) + return nil +} + +// asSupplied is the value with its line ending removed and nothing else. +// +// **A file has a trailing newline and a password does not**, so the ending goes — a credential +// wrong by one byte fails in a way nobody connects to how it was supplied. +// +// **And only the ending.** Trimming both ends is the obvious thing and it is wrong: a password +// chosen with a leading space is one the mesh would then deliver as a different password, silently, +// with the operator certain they had supplied it correctly. +func asSupplied(raw string) string { + return strings.TrimRight(raw, "\r\n") +} + +// valueFor gets the secret without putting it somewhere it can be read afterwards. +// +// **Not an argument, and there is no flag that takes one.** A value on the command line is in the +// shell's history, in the process list for as long as it runs, and in whatever collects either. +// The paths here are a file the operator already has, or a prompt that does not echo — the same +// two ways a model-access key is supplied (novox/hq ADR 0024). +func valueFor(node, module, name, from string) (string, error) { + switch { + case from == "-": + body, err := io.ReadAll(os.Stdin) + if err != nil { + return "", err + } + return string(body), nil + + case from != "": + body, err := os.ReadFile(from) + if err != nil { + return "", err + } + return string(body), nil + + default: + // The same path a model-access key takes, and for the same reason: a value given as an + // argument is in the shell's history and in the process list. Read from standard input, + // echoed nowhere by this program. + fmt.Fprintf(os.Stderr, + "reading %s's %q for %s from standard input; it is not echoed anywhere\n", + module, name, node) + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return "", fmt.Errorf("nothing was given on standard input: %w", err) + } + return line, nil + } +} diff --git a/cmd/mesh-control/secret_test.go b/cmd/mesh-control/secret_test.go new file mode 100644 index 0000000..f6ba77c --- /dev/null +++ b/cmd/mesh-control/secret_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// A file has a trailing newline and a password does not. +// +// The failure this prevents is the worst kind to diagnose: the credential is delivered, the +// machine applies it, everything reports success, and authentication fails one byte from correct +// somewhere else entirely. +func TestATrailingNewlineIsNotPartOfTheSecret(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "password") + if err := os.WriteFile(path, []byte("the-database-password\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := valueFor("anchor", "umami", "database", path) + if err != nil { + t.Fatal(err) + } + if asSupplied(got) != "the-database-password" { + t.Fatalf("read %q", got) + } +} + +// A password may contain spaces, and they are the operator's. +// +// Trimming both ends is the obvious thing and it is wrong: a value chosen with a leading space is +// a value the mesh would silently deliver as a different one. +func TestOnlyLineEndingsAreRemoved(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "password") + if err := os.WriteFile(path, []byte(" spaces matter \n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := valueFor("anchor", "umami", "database", path) + if err != nil { + t.Fatal(err) + } + if asSupplied(got) != " spaces matter " { + t.Fatalf("the value was altered beyond its line ending: %q", got) + } +} + +// A file that is not there is said plainly, rather than becoming an empty secret. +func TestAMissingFileIsRefused(t *testing.T) { + if _, err := valueFor("anchor", "umami", "database", + filepath.Join(t.TempDir(), "absent")); err == nil { + t.Fatal("a missing file produced a value") + } +} + +// The command refuses what it cannot act on, rather than acting on part of it. +func TestTheArgumentsAreRequired(t *testing.T) { + for _, args := range [][]string{ + {}, + {"accept"}, + {"accept", "anchor"}, + {"accept", "anchor", "umami"}, + {"give", "anchor", "umami", "database"}, + } { + if err := secretCommand(t.Context(), args); err == nil { + t.Errorf("%v was accepted", args) + } + } +}