diff --git a/modules/mesh-vault/README.md b/modules/mesh-vault/README.md new file mode 100644 index 00000000..6ba5d535 --- /dev/null +++ b/modules/mesh-vault/README.md @@ -0,0 +1,94 @@ +# mesh-vault + +Holds the `mesh-vault` seat: the mesh's shared secrets, each kept sealed, handed out only on request and +reply, sealed to whoever asked (novox/hq ADR 0085, ADR 0113, ADR 0349 §7 and §11). Go, one binary +(`cmd/mesh-vault`), launched by its node's runtime and speaking MCP to it through the Go SDK. + +## Where the secrets are kept, and why there + +In the vault's own store: one file per secret in `${dir:store}` on the vault's own disk, mode 0600 in a +0700 directory. Not a database, as most Go modules keep their data, because: + +- the vault is installed before any other module built on the runtime base (ADR 0113 §14), and a database + login is itself a shared secret the vault makes — the vault cannot need one to start; +- every value in the store is ciphertext anyway, so a database would add nothing a file does not; +- no secret is in a bucket, sealed or not (ADR 0201 §13), so the module declares no `state`. + +Each record holds the value twice, never in the clear: + +- **at rest**, sealed to the vault's own key (the sealed box of ADR 0206, `mesh-sdk/go/seal`). The key is + an X25519 pair the vault makes once in `${dir:state}/vault-key.json`; its private half never leaves it. +- **for the operator**, sealed to the operator key (ADR 0113 §22), so nothing exists only inside the + vault. A secret is refused, loudly, while the vault has no operator key. + +## Where the operator key comes from + +From the export the mesh writes to the vault's root directory (`${dir:root}/export.json`, the manifest's +`keeps`): its `operator-key` is the public half `operator key set` recorded in the controller. The vault +checks it every minute; a new key makes the vault seal every operator copy again to it — it can, because +it opens its own copy. The operator key's private half never enters the mesh, and the vault refuses one +(ADR 0113 §2). When the secrets move to the vault (design 57's V3), the operator key moves with them. + +## The boxes and the key formats — settled + +Two boxes on the mesh today, both over X25519: + +| | format | keys | who uses it | +|---|---|---|---| +| NaCl anonymous box | XSalsa20-Poly1305, `box.SealAnonymous` | raw 32 bytes, base64 | the controller sealing into declarations; the node-engine's `sealing.key` (mesh-host `internal/identity/sealing.go`); the operator key and `secret recover --from-export` | +| the sealed box (ADR 0206) | X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}` | PEM: SPKI public, PKCS #8 private | the licence manager and `claude-code`; `mesh-sdk/go/seal` | + +What the vault uses: + +- **Its answers to `get` are the ADR 0206 box, for every asker, a node included.** The asker sends its + public key as SPKI PEM. A node's provisioner client (V4) needs no second key: its node's sealing key is + X25519 already, so it sends that key's public half wrapped as SPKI and opens the answer with the private + half wrapped as PKCS #8 (`TestANodesSealingKeyOpensTheAnswer` does exactly this). A raw 32-byte key is + refused: a raw private key looks the same, and the vault takes no private key. +- **Its operator copies are the NaCl anonymous box**, to the operator key as it is made today, so the + operator's recovery opens them with the key and the command it already has. +- **The at-rest copy is the ADR 0206 box**, to the vault's own key. + +The NaCl box stays on the node-engine only for what the controller seals into declarations, until the last +module has moved (ADR 0349 §17). For the core's own credentials that travel in a declaration sealed by the +vault (ADR 0349 §16), V4 adds opening the ADR 0206 box with the same sealing key to the node-engine. + +## The seat's verb + +`mesh-vault.get` — `name`, `public_key` (SPKI PEM). It answers `{name, generation, changed-at, sealed}`, +`sealed` being the secret sealed to that key, and only to an asker allowed for that secret, the asker being +the bus user the runtime proved — never an argument. + +**Who may ask, today: nobody.** The rule (ADR 0349 §11) is a node's provisioner client under a bus account +of its own, for what its node's declaration names, and a module under its own account (issue 569). Neither +exists yet, so the running vault refuses every asker; the tests set a stand-in list. The real rule goes +behind the `Askers` interface in `askers.go` (design 57's V4 and P3). + +## Events + +- `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value + or a sealed copy. +- `provisioned`, `rotated`, `deprovisioned` — the custody ledger's, by fingerprint, as before. + +## The tools + +`secret_export`, `secret_holders`, `secret_verify` answer as the TypeScript vault's did, and its custody +ledger (`${dir:ledger}`, the same JSON files) is kept by a pass over the contributions file every minute. +None returns a value. + +What the TypeScript vault did and this one does not: it consumed its own three events only to print an +audit line; the pass now prints that line itself. + +## What comes later + +Taking values in (`secret accept`, `secret-ask`, `issued`, `rotate`) is V2; the controller's secrets move +in with V3; nodes ask with V4; pair credentials are minted here with V5. Until V3 the vault's store is +empty and the controller still serves every secret. + +## Tests + + go test ./... + +`vault_test.go` is ADR 0349's vault test list as far as this package goes: no bucket holds a secret; no +private key is accepted; every secret has an operator-sealed copy; `get` answers only an allowed asker, +sealed to its key; `secret-changed` carries no value. diff --git a/modules/mesh-vault/cmd/mesh-vault/ledger_test.go b/modules/mesh-vault/cmd/mesh-vault/ledger_test.go new file mode 100644 index 00000000..63b838da --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/ledger_test.go @@ -0,0 +1,172 @@ +package main + +// The custody ledger the TypeScript vault kept (its client.ts and test/ledger.test.ts), carried over whole: +// who holds a secret the controller delivered for this vault, since when, and its fingerprint — never the +// value. Its files are the same JSON, so the ledger on the vault's disk is read as it is. + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAFirstDeliveryIsAGrantTheSameAgainNothingANewValueARotation(t *testing.T) { + l, _ := NewLedger(t.TempDir()) + t0 := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC) + t1 := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC) + + held, outcome, err := l.Record("anchor-redis", "anchor", "first-value", t0) + if err != nil || outcome != Granted || held.Rotations != 0 || held.Since != t0.Format(isoMillis) || + held.Fingerprint != Fingerprint("first-value") { + t.Fatalf("%+v %s %v", held, outcome, err) + } + if _, outcome, _ := l.Record("anchor-redis", "anchor", "first-value", t1); outcome != Unchanged { + t.Errorf("the same value again is %s", outcome) + } + held, outcome, _ = l.Record("anchor-redis", "anchor", "second-value", t1) + if outcome != Rotated || held.Rotations != 1 || held.Since != t0.Format(isoMillis) || held.Changed != t1.Format(isoMillis) { + t.Errorf("%+v %s", held, outcome) + } + if len(held.History) != 1 || held.History[0].Fingerprint != Fingerprint("first-value") || held.History[0].Until != t1.Format(isoMillis) { + t.Errorf("history %+v", held.History) + } +} + +func TestTheLedgerHoldsFingerprintsNeverTheValue(t *testing.T) { + dir := t.TempDir() + l, _ := NewLedger(dir) + _, _, _ = l.Record("anchor-redis", "anchor", "the-actual-password", time.Now()) + _, _, _ = l.Record("anchor-redis", "anchor", "the-rotated-password", time.Now()) + entries, _ := os.ReadDir(dir) + for _, e := range entries { + raw, _ := os.ReadFile(filepath.Join(dir, e.Name())) + if strings.Contains(string(raw), "the-actual-password") || strings.Contains(string(raw), "the-rotated-password") { + t.Errorf("%s holds a value", e.Name()) + } + info, _ := e.Info() + if info.Mode().Perm() != 0o600 { + t.Errorf("%s is %v", e.Name(), info.Mode()) + } + } +} + +func TestWithdrawingForgetsAHolderAndListingIsByLogin(t *testing.T) { + l, _ := NewLedger(t.TempDir()) + _, _, _ = l.Record("b-app", "b", "x", time.Now()) + _, _, _ = l.Record("a-app", "a", "y", time.Now()) + if got := logins(l.List()); got != "a-app,b-app" { + t.Errorf("listed %s", got) + } + if !l.Withdraw("a-app") || l.Withdraw("a-app") { + t.Error("withdrawing found the wrong thing") + } + if got := logins(l.List()); got != "b-app" { + t.Errorf("listed %s", got) + } +} + +func TestALoginIsANameNotAPath(t *testing.T) { + l, _ := NewLedger(t.TempDir()) + if _, _, err := l.Record("../etc/passwd", "n", "v", time.Now()); err == nil { + t.Error("a path was taken for a login") + } +} + +// A ledger the TypeScript vault wrote is read as it is. +func TestALedgerTheTypeScriptVaultWroteIsRead(t *testing.T) { + dir := t.TempDir() + written := `{ + "as": "anchor-redis", + "consumer": "anchor", + "fingerprint": "sha256:abc", + "length": 40, + "since": "2026-09-20T10:00:00.000Z", + "changed": "2026-09-21T10:00:00.000Z", + "rotations": 1, + "history": [{"fingerprint": "sha256:old", "until": "2026-09-21T10:00:00.000Z"}] +} +` + _ = os.WriteFile(filepath.Join(dir, "anchor-redis.json"), []byte(written), 0o600) + l, _ := NewLedger(dir) + h, ok := l.Get("anchor-redis") + if !ok || h.Rotations != 1 || h.Length != 40 || h.History[0].Fingerprint != "sha256:old" { + t.Fatalf("%+v %v", h, ok) + } +} + +func TestWhatTheMeshDeliversIsFingerprintedNeverReturned(t *testing.T) { + dir := t.TempDir() + secret := filepath.Join(dir, "anchor.redis.secret") + _ = os.WriteFile(secret, []byte("minted-value\n"), 0o600) + receives := filepath.Join(dir, "mesh.json") + doc, _ := json.Marshal(map[string]any{"requirement": "secret", "given": []map[string]string{ + {"from": "redis", "node": "anchor", "as": "anchor-redis", "secret": secret}, + {"from": "offer-only", "node": "anchor"}, + }}) + _ = os.WriteFile(receives, doc, 0o600) + asked, err := Contributions(receives) + if err != nil || len(asked) != 1 || asked[0].As != "anchor-redis" { + t.Fatalf("%+v %v", asked, err) + } + fp, length, err := DeliveredFingerprint(asked[0]) + if err != nil || fp != Fingerprint("minted-value") || length != len("minted-value") { + t.Errorf("%s %d %v", fp, length, err) + } + if _, _, err := DeliveredFingerprint(Contribution{As: "x", Secret: filepath.Join(dir, "missing")}); err == nil || !strings.Contains(err.Error(), "not readable") { + t.Errorf("a missing file said %v", err) + } +} + +// The custody pass: a delivered value is recorded and announced by fingerprint, a changed one is a rotation, +// a login no longer asked for is withdrawn — and an unreadable contributions file withdraws nobody. +func TestTheCustodyPass(t *testing.T) { + dir := t.TempDir() + l, _ := NewLedger(filepath.Join(dir, "ledger")) + bus := &published{} + secret := filepath.Join(dir, "a.secret") + receives := filepath.Join(dir, "mesh.json") + write := func(given ...map[string]string) { + doc, _ := json.Marshal(map[string]any{"given": given}) + _ = os.WriteFile(receives, doc, 0o600) + } + c := Custody{Ledger: l, Receives: receives, Emit: bus.emit, Now: time.Now, Log: t.Logf} + + _ = os.WriteFile(secret, []byte("v1"), 0o600) + write(map[string]string{"node": "anchor", "as": "anchor-redis", "secret": secret}) + c.Pass() + _ = os.WriteFile(secret, []byte("v2"), 0o600) + c.Pass() + _ = os.WriteFile(receives, []byte("{not json"), 0o600) + c.Pass() + if _, ok := l.Get("anchor-redis"); !ok { + t.Fatal("an unreadable contributions file withdrew a holder") + } + write() + c.Pass() + if _, ok := l.Get("anchor-redis"); ok { + t.Fatal("a holder no longer asked for was kept") + } + var said []string + for _, e := range bus.events { + said = append(said, e["event"].(string)) + } + if strings.Join(said, ",") != "provisioned,rotated,deprovisioned" { + t.Errorf("announced %v", said) + } + for _, raw := range bus.raw { + if strings.Contains(raw, `"v1"`) || strings.Contains(raw, `"v2"`) { + t.Errorf("announced a value: %s", raw) + } + } +} + +func logins(hs []Held) string { + var out []string + for _, h := range hs { + out = append(out, h.As) + } + return strings.Join(out, ",") +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools_test.go b/modules/mesh-vault/cmd/mesh-vault/tools_test.go new file mode 100644 index 00000000..dee060cb --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/tools_test.go @@ -0,0 +1,152 @@ +package main + +// The tools the TypeScript vault offered, answering as they did: secret_export, secret_holders and +// secret_verify — none returns a value. And the seat's verb `get`, reached the way the runtime calls it. + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/seal" +) + +func toolNamed(t *testing.T, tools []toolDef, name string) toolDef { + t.Helper() + for _, tl := range tools { + if tl.Name == name { + return tl + } + } + t.Fatalf("no tool %s", name) + return toolDef{} +} + +func TestTheManifestNamesWhatTheBundleServes(t *testing.T) { + raw, _ := os.ReadFile("../../module.json") + var m struct { + Tools []string `json:"tools"` + Claims []struct { + Name string `json:"name"` + Serves []string `json:"serves"` + } `json:"claims"` + Emits []string `json:"emits"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + served := map[string]bool{} + for _, tl := range definitions(&Env{}) { + served[tl.Name] = true + } + for _, name := range m.Tools { + if !served[name] { + t.Errorf("the manifest names the tool %s, which the bundle does not serve", name) + } + } + for _, c := range m.Claims { + for _, verb := range c.Serves { + if !served[c.Name+"."+verb] { + t.Errorf("the claim of %s serves %s, which the bundle does not", c.Name, verb) + } + } + } + for name := range served { + if !strings.HasPrefix(name, Seat+".") && !contains(m.Tools, name) { + t.Errorf("the bundle serves %s, which the manifest does not name", name) + } + } + if !contains(m.Emits, ChangedEvent) { + t.Errorf("the manifest does not say the vault emits %s", ChangedEvent) + } +} + +func TestSecretExportAnswersTheMeshsExport(t *testing.T) { + root := t.TempDir() + env := &Env{Root: root} + export := toolNamed(t, definitions(env), "secret_export") + got, _ := export.Run(map[string]any{}) + if got.(map[string]any)["available"] != false { + t.Errorf("no export said %v", got) + } + doc := `{"export":1,"operator-key":"b3A=","fingerprint":"sha256:f","kept":[{"node":"g14","module":"dunst","name":"token","kind":"own","origin":"made","sealed":"c2VhbGVk","key":"b3A=","made-at":"2026-10-01T00:00:00Z"}],"unrecoverable":[{"node":"ace","module":"x","name":"y"}]}` + _ = os.WriteFile(filepath.Join(root, "export.json"), []byte(doc), 0o600) + got, _ = export.Run(map[string]any{}) + a := got.(map[string]any) + if a["available"] != true || a["count"] != 1 || a["operator-key"] != "b3A=" { + t.Errorf("the export said %v", a) + } + got, _ = export.Run(map[string]any{"sealed": false}) + kept := got.(map[string]any)["kept"].([]map[string]any) + if _, ok := kept[0]["sealed"]; ok { + t.Error("sealed=false still returned the blobs") + } +} + +func TestSecretHoldersAndVerifySayFingerprintsOnly(t *testing.T) { + dir := t.TempDir() + l, _ := NewLedger(filepath.Join(dir, "ledger")) + secret := filepath.Join(dir, "s") + _ = os.WriteFile(secret, []byte("the-value"), 0o600) + receives := filepath.Join(dir, "mesh.json") + doc, _ := json.Marshal(map[string]any{"given": []map[string]string{{"from": "redis", "node": "anchor", "as": "anchor-redis", "secret": secret}}}) + _ = os.WriteFile(receives, doc, 0o600) + _, _, _ = l.Record("anchor-redis", "anchor", "the-value", time.Now()) + env := &Env{Ledger: l, Receives: receives} + + holders, _ := toolNamed(t, definitions(env), "secret_holders").Run(map[string]any{}) + verify, _ := toolNamed(t, definitions(env), "secret_verify").Run(map[string]any{"as": "anchor-redis", "fingerprint": Fingerprint("the-value")}) + unknown, _ := toolNamed(t, definitions(env), "secret_verify").Run(map[string]any{"as": "nobody"}) + for _, answer := range []any{holders, verify, unknown} { + raw, _ := json.Marshal(answer) + if strings.Contains(string(raw), "the-value") { + t.Errorf("a tool returned the value: %s", raw) + } + } + v := verify.(map[string]any) + if v["ok"] != true || v["deliveredMatchesRecorded"] != true || v["givenMatchesRecorded"] != true { + t.Errorf("verify said %v", v) + } + h := holders.(map[string]any) + if h["count"] != 1 { + t.Errorf("holders said %v", h) + } + if unknown.(map[string]any)["known"] != false { + t.Errorf("an unknown login said %v", unknown) + } +} + +// The seat's verb, called as the runtime calls it: the caller is the one the runtime proved, never an +// argument. +func TestTheGetVerbTakesItsCallerFromTheRuntime(t *testing.T) { + v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}}) + if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil { + t.Fatal(err) + } + asker, _ := seal.GenerateKeyPair() + get := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".get") + // No runtime proved a caller here, so the call is unproven whatever the arguments say. + if _, err := get.Run(map[string]any{"name": "pair/postgres/gitea", "public_key": asker.PublicKey, "caller": "g14.node-provisioner"}); err == nil { + t.Error("get answered a caller named in its arguments") + } + answer, err := get.runAs("g14.node-provisioner", map[string]any{"name": "pair/postgres/gitea", "public_key": asker.PublicKey}) + if err != nil { + t.Fatal(err) + } + a := answer.(Answer) + if got, err := seal.Open(a.Sealed, asker.PrivateKey); err != nil || got != "x" { + t.Errorf("%q %v", got, err) + } +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +}