secret accept — carry a value the mesh did not make
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.
This commit is contained in:
@@ -94,6 +94,8 @@ func run() error {
|
|||||||
return assignCommand(ctx, args[0], args[1:])
|
return assignCommand(ctx, args[0], args[1:])
|
||||||
case "settings":
|
case "settings":
|
||||||
return settingsCommand(ctx, args[1:])
|
return settingsCommand(ctx, args[1:])
|
||||||
|
case "secret":
|
||||||
|
return secretCommand(ctx, args[1:])
|
||||||
case "plan":
|
case "plan":
|
||||||
return planCommand(ctx, args[1:])
|
return planCommand(ctx, args[1:])
|
||||||
case "push":
|
case "push":
|
||||||
@@ -138,6 +140,8 @@ func usage() {
|
|||||||
settings set <module> <file> what a module's config should say, for the whole mesh
|
settings set <module> <file> what a module's config should say, for the whole mesh
|
||||||
settings set <module> <file> --node <n> ...or for one machine
|
settings set <module> <file> --node <n> ...or for one machine
|
||||||
settings clear <module> [--node <n>] take a layer away
|
settings clear <module> [--node <n>] take a layer away
|
||||||
|
secret accept <node> <module> <name> carry a value the mesh did not make and cannot invent
|
||||||
|
secret accept ... --from <file> ...read it from a file rather than being asked
|
||||||
build <repository> [--ref R] have a build machine build it, and record what came out
|
build <repository> [--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
|
build --behind build every module the mesh holds older than its source
|
||||||
builds [<module>] what has been built lately, and what came of it
|
builds [<module>] what has been built lately, and what came of it
|
||||||
|
|||||||
@@ -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 <node> <module> <name> [--from <file>]")
|
||||||
|
}
|
||||||
|
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 <node> <module> <name> [--from <file>]")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user