Files
mesh-catalog/modules/mesh-vault/cmd/mesh-vault/rotation_integration_test.go
T
jschoubben 09824b4839 Vault: answer the enrolment verbs in mesh-sdk's wire shapes, certify the serving key presented, record each rotation's hand act
Review round 1 found that redeem could never succeed: the vault's answers were not the shapes mesh-enrolment decodes.
- enrolment-token reads `sealed` (not `show`), so asking for the sealed token no longer mints a new one; it answers
  delivery.EnrolmentTokenAnswer: at issue the bus address, its certificate fingerprint and the vault's signing key.
- Issue takes the machine's first tunnel (`tunnel`), keeps it beside the token and refuses a token whose Missing() is
  not empty, so the sealed token is one a machine can use (ADR 0587 §3, insight 2026-11-02).
- node-account answers delivery.NodeAccountAnswer: the login, and the password sealed anonymously to the presented
  raw X25519 key in base64, as the node-engine's SealingKey.Open reads it.
- A joining node's certificate is signed for the serving key presented at the redeem, only when the four presented keys
  have the digest node-account recorded; the name comes from placement (ADR 0587 §6).
- rotate-node refuses a hand act that is not rotate-node of this node, keeps the act id in the node's node-logins key
  (`acts`, beside mesh-sdk's fields) and refuses an id used before (ADR 0583 §2).

The enrolment test now calls the real tool handlers and decodes every answer into mesh-sdk's types, failing on an
empty field; it replaces the earlier enrolment test, so mesh-vault stays at 134 unit tests.

(issue 774, 774/S43, 774/S44, 774/S45, 774/S46, review round 1)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:42:19 +01:00

243 lines
9.3 KiB
Go

//go:build integration
package main
// The vault whole against a real bus, rotating a node's login with a stand-in engine (novox/hq ADR 0583 §1 to §7, issue
// 774): the built binary played over its stdin and stdout as the node's tool runtime plays it, as in the mint test. The
// test is the engine and the bus node: it states its sealing key, takes the new login through `own`, and "reloads the
// list" by handing the bus-accounts hashes in the bus module's have, as the bus node's node-engine does. At every step at
// least one of the engine's logins authenticates against the list loaded last, and with the list never reloaded the vault
// never says node-account-ready.
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/nacl/box"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/seal"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
// 774/S50/AC1.
func TestTheVaultWholeRotatesANodesLoginWithAStandInEngine(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
dir := t.TempDir()
bin := filepath.Join(dir, "mesh-vault")
build := exec.Command("go", "build", "-o", bin, ".")
build.Stderr = os.Stderr
if err := build.Run(); err != nil {
t.Fatalf("the vault does not build: %v", err)
}
nc, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
for _, b := range []string{"mesh-vault_bus-accounts", "mesh-vault_node-logins", "mesh-grants_accounts", "mesh-controller_lease",
"mesh-provisioner_plans", "node-engine_have", "mesh-delivery-assigner_placement"} {
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b, History: 2}); err != nil {
t.Fatal(err)
}
}
opPub, _, err := box.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
root := filepath.Join(dir, "root")
_ = os.MkdirAll(root, 0o700)
export, _ := json.Marshal(map[string]string{"operator-key": base64.StdEncoding.EncodeToString(opPub[:])})
if err := os.WriteFile(filepath.Join(root, "export.json"), export, 0o600); err != nil {
t.Fatal(err)
}
cmd := exec.CommandContext(ctx, bin)
cmd.Env = append(os.Environ(), "MESH_VAULT_STORE="+filepath.Join(dir, "store"), "MESH_VAULT_STATE="+filepath.Join(dir, "state"),
"MESH_VAULT_ROOT="+root, "MESH_VAULT_LEDGER="+filepath.Join(dir, "ledger"), "MESH_RECEIVES="+filepath.Join(dir, "mesh.json"),
"MESH_NODE=novox", "MESH_MODULE=mesh-vault")
stdin, _ := cmd.StdinPipe()
stdout, _ := cmd.StdoutPipe()
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = stdin.Close(); cancel(); _ = cmd.Wait() })
p := &player{t: t, ctx: ctx, nc: nc, js: js, in: stdin, pending: map[string]chan wire{}}
// mesh-hand-acts answers the one hand act the test recorded.
p.ask = func(key string, _ json.RawMessage) (any, error) {
if key != "mesh-hand-acts.hand-acts" {
return nil, errors.New("nats: no responders available for request")
}
return map[string]any{"acts": []HandActSeen{{ID: "act-1", By: "person.jochen", At: time.Now(), Verb: "rotate-node", Args: []string{"g14"}}}}, nil
}
go p.serve(stdout)
// The bus node (novox, running nats) and the node that rotates (g14); the lease handed away; g14's login adopted from
// the loaded list, its password known to the engine.
for node, modules := range map[string][]string{"novox": {"nats"}, "g14": {"web"}} {
pl := delivery.Placement{V: delivery.Version, Node: node}
for _, m := range modules {
pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: m})
}
values, err := delivery.PlacementValues(pl)
if err != nil {
t.Fatal(err)
}
for k, raw := range values {
kv, _ := p.kv("mesh-delivery-assigner.placement")
if _, err := kv.Put(ctx, k, raw); err != nil {
t.Fatal(err)
}
}
}
p.put(t, "mesh-controller.lease", LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
oldPassword := "the-old-password-of-g14"
oldHash, _ := bcrypt.GenerateFromPassword([]byte(oldPassword), bcrypt.MinCost)
p.put(t, "mesh-vault.bus-accounts", "node.g14", AccountHash{User: "node.g14", Hash: string(oldHash), Adopted: true})
// The vault's public key, which the bus node's engine seals its handing to.
var vaultKey seal.KeyPair
eventually(t, "the vault's key", func() bool {
raw, err := os.ReadFile(filepath.Join(dir, "state", OwnKeyFile))
return err == nil && json.Unmarshal(raw, &vaultKey) == nil && vaultKey.PublicKey != ""
})
// The bus node reloads the list from bus-accounts and hands the hashes of what it loaded in its have; the engine's logins
// are its passwords. An authentication is a bcrypt compare against the list loaded last.
passwords := map[string]string{"node.g14": oldPassword}
var loaded map[string]string
revision := uint64(0)
reload := func() {
kv, _ := p.kv("mesh-vault.bus-accounts")
keys, err := kv.Keys(ctx)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
t.Fatal(err)
}
sort.Strings(keys)
var accounts []delivery.HandedAccount
loaded = map[string]string{}
for _, k := range keys {
a := p.hashOf(t, k)
if a == nil || !delivery.IsBcrypt(a.Hash) {
continue
}
accounts = append(accounts, delivery.HandedAccount{User: k, Hash: a.Hash})
loaded[k] = a.Hash
}
revision++
list, _ := json.Marshal(accounts)
handed, err := delivery.HandAccounts(accounts, vaultKey.PublicKey, Fingerprint(string(list)), time.Now())
if err != nil {
t.Fatal(err)
}
raw, err := delivery.EncodeHave(delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK,
UserList: &delivery.UserListWritten{Source: "k", Revision: revision, Users: len(accounts), Handed: &handed}})
if err != nil {
t.Fatal(err)
}
hk, _ := p.kv("node-engine.have")
if _, err := hk.Put(ctx, "novox.nats", raw); err != nil {
t.Fatal(err)
}
}
authenticates := func(step string) {
t.Helper()
for user, password := range passwords {
if h, ok := loaded[user]; ok && bcrypt.CompareHashAndPassword([]byte(h), []byte(password)) == nil {
return
}
}
t.Fatalf("%s: none of the engine's logins %v authenticates against the loaded list %v", step, passwords, loaded)
}
said := func(event string) bool {
p.mu.Lock()
defer p.mu.Unlock()
return strings.Contains(strings.Join(p.said, "\n"), event+" ")
}
reload()
authenticates("before the rotation")
enginePub, enginePriv := aSealingKey(t)
if _, err := p.call(Seat+".node-key", "node.g14", map[string]any{"key": enginePub}); err != nil {
t.Fatal(err)
}
// The rotation begins once the vault has heard the loaded list's revision.
var begun RotateNodeAnswer
eventually(t, "the rotation begins", func() bool {
raw, err := p.call(Seat+".rotate-node", "person.jochen", map[string]any{"node": "g14", "why": "the weekly rotation", "act": "act-1"})
return err == nil && json.Unmarshal(raw, &begun) == nil && begun.Rotating == "node.g14--b"
})
authenticates("rotation begun")
// The list is never reloaded: node-account-ready is never said.
time.Sleep(time.Second)
if said(NodeAccountReadyEvent) {
t.Fatal("node-account-ready was said while the loaded list lacks the new login")
}
// The engine takes the new login through own, sealed to its key.
raw, err := p.call(Seat+".own", "node.g14", map[string]any{})
if err != nil {
t.Fatal(err)
}
var own OwnAnswer
if err := json.Unmarshal(raw, &own); err != nil || len(own.Own) != 1 || own.Own[0].Module != MeshHostModule || own.Own[0].Name != "node.g14--b" {
t.Fatalf("own answers %+v, %v", own.Own, err)
}
value, err := seal.Open(own.Own[0].Sealed, enginePriv)
if err != nil {
t.Fatal(err)
}
var account struct{ User, Password string }
if err := json.Unmarshal([]byte(value), &account); err != nil || account.User != "node.g14--b" {
t.Fatalf("the new login is %s, %v", value, err)
}
passwords["node.g14--b"] = account.Password
// The bus node reloads the list: the new login is in it, both authenticate, node-account-ready is said.
reload()
eventually(t, "node-account-ready", func() bool { return said(NodeAccountReadyEvent) })
authenticates("the list holds both logins")
if bcrypt.CompareHashAndPassword([]byte(loaded["node.g14--b"]), []byte(account.Password)) != nil {
t.Fatal("the loaded hash of the new login is not of the value the engine took")
}
// The engine, dialled as the new login, confirms; the vault removes the old hash, and the next reload drops it.
if _, err := p.call(Seat+".node-account-confirm", "node.g14--b", map[string]any{}); err != nil {
t.Fatal(err)
}
if h := p.hashOf(t, "node.g14"); h != nil {
t.Fatalf("the old login's hash remains after the confirm: %+v", h)
}
authenticates("the confirm, before the list reloads")
reload()
authenticates("the list without the old login")
eventually(t, "rotation-done", func() bool { return said(RotationDoneEvent) })
kv, _ := p.kv("mesh-vault.node-logins")
e, err := kv.Get(ctx, "g14")
if err != nil {
t.Fatal(err)
}
var nl NodeLogin
_ = json.Unmarshal(e.Value(), &nl)
if nl.Login != "node.g14--b" || nl.Rotating != "" || nl.Stage != "" {
t.Errorf("node-logins g14 is %+v", nl)
}
if h := p.hashOf(t, "node.g14--b"); h == nil || h.Rotating != nil {
t.Errorf("the new login's key is %+v", h)
}
}