From 9249ca7b5745af5d296f9561fcfc7e360bfac2b1 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:27:02 +0100 Subject: [PATCH] mesh-cli: nox enrol open opens a sealed enrolment token locally with the operator's private key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat mesh-enrolment's tokens answers the token sealed to the operator key; the operator opens it at their own terminal. The subcommand reads the sealed token on standard input and the private key from a file, prints the token text a joining machine takes, asks nothing of the node-engine's socket, and says the token is not sealed to a wrong key (ADR 0587 §6, §8). New: one table-style test function; mesh-cli had 6, so none was merged or deleted. (issue 774, 774/S51) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- modules/mesh-cli/cmd/mesh-cli/enrol.go | 97 +++++++++++++++++++++ modules/mesh-cli/cmd/mesh-cli/enrol_test.go | 80 +++++++++++++++++ modules/mesh-cli/cmd/mesh-cli/main.go | 8 ++ modules/mesh-cli/go.mod | 4 + modules/mesh-cli/go.sum | 4 + 5 files changed, 193 insertions(+) create mode 100644 modules/mesh-cli/cmd/mesh-cli/enrol.go create mode 100644 modules/mesh-cli/cmd/mesh-cli/enrol_test.go diff --git a/modules/mesh-cli/cmd/mesh-cli/enrol.go b/modules/mesh-cli/cmd/mesh-cli/enrol.go new file mode 100644 index 00000000..b18a81c8 --- /dev/null +++ b/modules/mesh-cli/cmd/mesh-cli/enrol.go @@ -0,0 +1,97 @@ +package main + +// `nox enrol open`: the operator opens an enrolment token at their own terminal (novox/hq ADR 0587 §6, §8). +// +// The seat mesh-enrolment's `tokens` answers the token sealed to the operator key, which only the operator's private key +// opens; the mesh never holds it. This subcommand is local: it asks nothing of the node-engine's socket and nothing of +// the controller or the bus. It reads the sealed token (the base64 of the sealed box) on standard input and the private +// key from a file (`--key`, the raw X25519 key in base64 that `operator key make` wrote), opens the box, and prints the +// token text a joining machine's `enrol` takes. The key is read from the file named and nowhere else, and never printed. + +import ( + "crypto/ecdh" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/crypto/nacl/box" +) + +const enrolUsage = `nox enrol open --key + +Opens an enrolment token sealed to the operator key (the answer of the seat mesh-enrolment's tokens) and prints the token +to type on the new machine. The sealed token is read on standard input; this runs here and asks nothing of the mesh. +` + +// maxSealedToken bounds what is read of standard input: a token is a few hundred bytes. +const maxSealedToken = 16 << 10 + +// enrolOpen runs `enrol open`: args are what follows `enrol open`. +func enrolOpen(args []string, stdin io.Reader, stdinIsTerminal bool, stdout, stderr io.Writer) int { + keyFile := "" + for i := 0; i < len(args); i++ { + switch { + case args[i] == "--key" && i+1 < len(args): + keyFile = args[i+1] + i++ + case strings.HasPrefix(args[i], "--key="): + keyFile = strings.TrimPrefix(args[i], "--key=") + default: + fmt.Fprint(stderr, enrolUsage) + return 2 + } + } + if keyFile == "" { + fmt.Fprint(stderr, enrolUsage) + return 2 + } + if stdinIsTerminal { + fmt.Fprintln(stderr, "mesh-cli: give the sealed token on standard input (a pipe or a file); nothing was opened") + return 2 + } + raw, err := os.ReadFile(keyFile) + if err != nil { + fmt.Fprintf(stderr, "mesh-cli: the operator key could not be read: %v\n", err) + return 1 + } + sealed, err := io.ReadAll(io.LimitReader(stdin, maxSealedToken+1)) + if err != nil || len(sealed) > maxSealedToken { + fmt.Fprintln(stderr, "mesh-cli: the sealed token could not be read from standard input, or is longer than a token is") + return 1 + } + text, err := openSealed(strings.TrimSpace(string(raw)), strings.TrimSpace(string(sealed))) + if err != nil { + fmt.Fprintf(stderr, "mesh-cli: %v\n", err) + return 1 + } + fmt.Fprintln(stdout, text) + return 0 +} + +// openSealed opens a value sealed to the operator key (the sealed box of mesh-vault's operator copies, box.SealAnonymous +// in base64) with the private key, the raw X25519 key in base64. +func openSealed(privateKey, sealed string) (string, error) { + private, err := base64.StdEncoding.DecodeString(privateKey) + if err != nil || len(private) != 32 { + return "", errors.New("that is not an operator private key (the raw X25519 key in base64)") + } + key, err := ecdh.X25519().NewPrivateKey(private) + if err != nil { + return "", fmt.Errorf("that is not a usable operator private key: %w", err) + } + blob, err := base64.StdEncoding.DecodeString(sealed) + if err != nil { + return "", fmt.Errorf("this is not a sealed token: %w", err) + } + var pub, priv [32]byte + copy(pub[:], key.PublicKey().Bytes()) + copy(priv[:], private) + out, ok := box.OpenAnonymous(nil, blob, &pub, &priv) + if !ok { + return "", errors.New("this token is not sealed to that key; nothing is printed") + } + return strings.TrimSpace(string(out)), nil +} diff --git a/modules/mesh-cli/cmd/mesh-cli/enrol_test.go b/modules/mesh-cli/cmd/mesh-cli/enrol_test.go new file mode 100644 index 00000000..61cd7ea2 --- /dev/null +++ b/modules/mesh-cli/cmd/mesh-cli/enrol_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/nacl/box" +) + +// 774/S51/AC1, AC2 (novox/hq ADR 0587 §6, §8): a token sealed to a test operator key (as mesh-vault seals it: the text +// of the token, anonymous box, base64) is opened with the private half and printed as a token the engine's parser reads +// (base64url of version 1 JSON with node, broker, fingerprint, signer and secret), and nothing is asked of the engine's +// socket, which here is a path nobody listens on; the wrong key prints nothing and says the token is not sealed to it. +func TestEnrolOpenOpensASealedTokenLocallyAndOnlyWithItsKey(t *testing.T) { + t.Setenv("MESH_VERB", "") + token := base64.RawURLEncoding.EncodeToString([]byte(`{"v":1,"node":"new1","broker":"tls://10.77.0.1:4222",` + + `"fingerprint":"sha256:bus","signer":"` + base64.StdEncoding.EncodeToString(make([]byte, 32)) + `","secret":"one-time"}`)) + pub, priv, _ := box.GenerateKey(rand.Reader) + _, otherPriv, _ := box.GenerateKey(rand.Reader) + sealed, err := box.SealAnonymous(nil, []byte(token), pub, rand.Reader) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + keyFile := func(name string, k *[32]byte) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(base64.StdEncoding.EncodeToString(k[:])+"\n"), 0o600); err != nil { + t.Fatal(err) + } + return p + } + before, beforeIn := socket, standardInput + socket = filepath.Join(dir, "nobody-listens.sock") + t.Cleanup(func() { socket, standardInput = before, beforeIn }) + for _, c := range []struct { + name string + args []string + stdin string + code int + out string + err string + }{ + {"the right key", []string{"enrol", "open", "--key", keyFile("op", priv)}, base64.StdEncoding.EncodeToString(sealed) + "\n", 0, token + "\n", ""}, + {"the key as --key=", []string{"enrol", "open", "--key=" + keyFile("op2", priv)}, base64.StdEncoding.EncodeToString(sealed), 0, token + "\n", ""}, + {"the wrong key", []string{"enrol", "open", "--key", keyFile("other", otherPriv)}, base64.StdEncoding.EncodeToString(sealed), 1, "", "not sealed to that key"}, + {"no key", []string{"enrol", "open"}, base64.StdEncoding.EncodeToString(sealed), 2, "", "nox enrol open --key"}, + {"not a sealed token", []string{"enrol", "open", "--key", keyFile("op3", priv)}, "@@@", 1, "", "not a sealed token"}, + } { + standardInput = func() (io.Reader, bool) { return strings.NewReader(c.stdin), false } + var out, errOut bytes.Buffer + code := run(context.Background(), c.args, &out, &errOut) + if code != c.code || out.String() != c.out || !strings.Contains(errOut.String(), c.err) { + t.Errorf("%s: exit %d, stdout %q, stderr %q", c.name, code, out.String(), errOut.String()) + } + } + // What it prints is a token the engine's parser reads. + var parsed struct { + V int `json:"v"` + Node string `json:"node"` + Secret string `json:"secret"` + } + raw, _ := base64.RawURLEncoding.DecodeString(token) + if json.Unmarshal(raw, &parsed) != nil || parsed.V != 1 || parsed.Node != "new1" || parsed.Secret != "one-time" { + t.Errorf("the token is %+v", parsed) + } + // Standard input a terminal is never read. + standardInput = func() (io.Reader, bool) { return strings.NewReader("x"), true } + var out, errOut bytes.Buffer + if code := run(context.Background(), []string{"enrol", "open", "--key", keyFile("op4", priv)}, &out, &errOut); code != 2 || out.Len() != 0 { + t.Errorf("a terminal on standard input: exit %d, stdout %q", code, out.String()) + } +} diff --git a/modules/mesh-cli/cmd/mesh-cli/main.go b/modules/mesh-cli/cmd/mesh-cli/main.go index f77d1933..d4b805e1 100644 --- a/modules/mesh-cli/cmd/mesh-cli/main.go +++ b/modules/mesh-cli/cmd/mesh-cli/main.go @@ -56,6 +56,8 @@ const usage = `mesh-cli — the controller's command lines, from this node (novo mesh-cli e.g. mesh-cli status, mesh-cli settings show zsh --node nox the same, in interactive zsh + nox enrol open --key opens a sealed enrolment token here, asking nothing of the mesh (ADR 0587) + Run it as yourself, never under sudo: the node-engine tells the controller which account asks. What a pipe or a file gives its standard input (at most 64 KiB) goes with the line to the controller's terminal alone, e.g. a secret: mesh-cli secret accept --from - < file @@ -82,6 +84,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { "Nothing was asked\n", verb) return 1 } + // `enrol open` is local: it opens a sealed enrolment token with the operator's private key and asks nothing of the + // node-engine or the mesh (novox/hq ADR 0587 §8). + if len(args) >= 2 && args[0] == "enrol" && args[1] == "open" { + in, terminal := standardInput() + return enrolOpen(args[2:], in, terminal, stdout, stderr) + } in, err := carried() if err != nil { fmt.Fprintf(stderr, "mesh-cli: %v\n", err) diff --git a/modules/mesh-cli/go.mod b/modules/mesh-cli/go.mod index c056d521..c9987eb5 100644 --- a/modules/mesh-cli/go.mod +++ b/modules/mesh-cli/go.mod @@ -3,3 +3,7 @@ module mesh-cli go 1.26.0 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 + +require golang.org/x/crypto v0.57.0 + +require golang.org/x/sys v0.48.0 // indirect diff --git a/modules/mesh-cli/go.sum b/modules/mesh-cli/go.sum index 2e60a9c1..3775e642 100644 --- a/modules/mesh-cli/go.sum +++ b/modules/mesh-cli/go.sum @@ -1,2 +1,6 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=