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:
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user