mesh-cli: nox enrol open opens a sealed enrolment token locally with the operator's private key

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
This commit is contained in:
2026-11-02 01:42:19 +01:00
parent 049bd176c3
commit 9249ca7b57
5 changed files with 193 additions and 0 deletions
+97
View File
@@ -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 <operator private key file>
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
}
@@ -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())
}
}
+8
View File
@@ -56,6 +56,8 @@ const usage = `mesh-cli — the controller's command lines, from this node (novo
mesh-cli <command line> e.g. mesh-cli status, mesh-cli settings show zsh --node <node>
nox <command line> the same, in interactive zsh
nox enrol open --key <file> 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 <node> <module> <name> --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)