Compare commits

...
Author SHA1 Message Date
mesh-adminandClaude Opus 5.5 eb958f668e Prove the vault's secret-ask test and the issuing provider's test with a stand-in provider (issue 675)
mesh/merge-gate head eb958f668ecd merged into trunk 1a2c917abfdb — pass: [release feat/57-config-and-provisioning, judged by its controller fcdecb436117] t…
mesh/repo-check head eb958f668ecd merged into trunk 1a2c917abfdb — fail: [release feat/57-config-and-provisioning, judged by its controller fcdecb436117] i…
mesh/delivery-group group pkg/57-v2-vault-verbs rejected: a member's own check failed
mesh/delivery stopped: the pull request closed unmerged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:14:34 +00:00
mesh-adminandClaude Opus 5.5 63b7aec065 Prove V1's review follow-ups (issue 675)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:13:09 +00:00
mesh-adminandClaude Opus 5.5 efddb634e5 Open a value only in the vault, sealed to a key made for its call (issue 675)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:12:21 +00:00
mesh-adminandClaude Opus 5.5 eba300794f Serve the vault's intake verbs and say how they work (issue 675)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:10:55 +00:00
mesh-adminandClaude Opus 5.5 2abe1f95e8 Take accept, secret-ask, issued and rotate into the vault (issue 675)
ADR 0349 clauses 8 to 10: a value given at the controller's terminal, typed
at a desk or issued by a provider's software enters the vault and opens
there alone, and rotation is the vault's. Each comes in two steps from the
controller, which reaches the desk and the provider: the vault makes a key
for the one call and opens what was sealed to it. An ask is recorded in the
vault's store before the prompt opens and bounded to one open per secret
and three an hour.

Also V1's review follow-ups: the tool layer passes an act through, a
rolled record is said, the export's fingerprint and vault_status are
tested, and an unproven by is kept beside the proven caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:10:03 +00:00
mesh-admin 1a2c917abf Merge pull request '57/V1: mesh-vault in Go, holding secrets sealed and answering get sealed to the asker (issue 674)' (#286) from pkg/57-v1-vault-in-go into feat/57-config-and-provisioning 2026-10-14 18:27:49 +00:00
mesh-admin b807b1d211 Merge pull request '57/K2: The list of predefined keys, and a merge check refusing a key removed while a module names it (issue 661)' (#291) from pkg/57-k2-predefined-keys into feat/57-config-and-provisioning 2026-10-14 06:52:29 +00:00
mesh-adminandClaude Opus 5.5 ff3f8ca192 Check that a change to predefined.json removes no key a module still names
mesh/merge-gate head ff3f8ca192a4 merged into trunk dae760224526 — pass: the change touches no module of the mesh's graph
mesh/repo-check head ff3f8ca192a4 merged into trunk dae760224526 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): feat/57-config-and-provisioning'…
mesh/delivery delivered
mesh/delivery-group group pkg/57-k2-predefined-keys delivered: every member is delivered
Part 2 of 2: the check merge-check.sh runs, and its tests with a planted removal (hq ADR 0348 clause 7, issue 661).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 05:51:54 +00:00
mesh-adminandClaude Opus 5.5 9ae9e321a8 Add the list of predefined keys, and refuse removing a key a module still names
Mesh and node config may hold values only for keys on a fixed, reviewed list (hq ADR 0348 clause 7, design 57 package K2, issue 661). predefined.json at the root starts with font-size (a number of points, 6 to 72) and agent-managed-settings (a trusted object). The merge check refuses a change that removes a key a manifest still names, against the trunk's list at the merge base; whether a removed key still holds a value is mesh-config's to refuse (package K3). Part 1 of 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 05:51:15 +00:00
mesh-admin dae7602245 Merge pull request '57/F2b: influxdb serves its org and bucket from its settings (issue 696)' (#290) from pkg/57-f2b-served-keys into feat/57-config-and-provisioning 2026-10-14 05:15:53 +00:00
mesh-adminandClaude Opus 5.5 c0d55ee27d mesh-vault: ADR 0230's rules pinned one at a time, and the words held to the controller's types (review 255)
mesh/merge-gate head c0d55ee27dae merged into trunk b807b1d21102 — pass: [release feat/57-config-and-provisioning, judged by its controller fcdecb436117] t…
mesh/repo-check head c0d55ee27dae merged into trunk b807b1d21102 — pass: [release feat/57-config-and-provisioning, judged by its controller fcdecb436117] i…
mesh/delivery delivered
mesh/delivery-group group pkg/57-v1-vault-in-go delivered: every member is delivered
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:41:01 +00:00
mesh-adminandClaude Opus 5.5 dadf824c3e mesh-vault: tests of the bound, the retry's real path and a hand-out without its record; the README corrected (review 255)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head dadf824c3e33 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head dadf824c3e33 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:39:49 +00:00
mesh-adminandClaude Opus 5.5 8c5d70a186 mesh-vault: the retirement tools take the controller's consumers, confirm, by and via; vault_status; the export's fingerprint (review 255)
mesh/merge-gate head 8c5d70a18677 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 8c5d70a18677 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:39:15 +00:00
mesh-adminandClaude Opus 5.5 958018e696 mesh-vault: the ledger keeps the ledger; retirement moves to retirement.go (review 255)
mesh/merge-gate head 958018e696e4 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 958018e696e4 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:38:27 +00:00
mesh-adminandClaude Opus 5.5 b71e696993 mesh-vault: retirement in the controller's words, held to its types by golden files (review 255)
mesh/merge-gate head b71e696993b3 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head b71e696993b3 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:37:27 +00:00
mesh-adminandClaude Opus 5.5 2dd6510ded influxdb: serve org and bucket from its settings (issue 696)
mesh/merge-gate head 2dd6510ded29 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 2dd6510ded29 merged into trunk 8aa37e8b835d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group pkg/57-f2b-served-keys delivered: every member is delivered
A settings layer will stop replacing what a provider serves (ADR 0348
clause 11). influxdb on ace relies on that today: its layer sets org and
bucket over the served literals, and grafana writes the bucket into its
data source. Serving ${setting:org} and ${setting:bucket}, with today's
literals as defaults, keeps every consumer's binding the same before and
after the controller change. Declaring settings means runtime-config names
the keys it takes; it keeps every key its client reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:36:59 +00:00
mesh-adminandClaude Opus 5.5 6ab38066c9 mesh-vault: bound the record of hand-outs and record only a well-formed ask (review 255)
mesh/merge-gate head 6ab38066c910 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 6ab38066c910 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:36:11 +00:00
mesh-adminandClaude Opus 5.5 c63f7d6e5c mesh-vault: the ledger's tests say ADR 0230's rules, not a withdrawal on the first pass (review 250)
mesh/merge-gate head c63f7d6e5cea merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head c63f7d6e5cea merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:22:30 +00:00
mesh-adminandClaude Opus 5.5 39c38f3745 mesh-vault: tests for the review's fixes, and the README saying what at-rest encryption protects (review 250)
mesh/merge-gate head 39c38f37459a merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 39c38f37459a merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:21:30 +00:00
mesh-adminandClaude Opus 5.5 da889d421d mesh-vault: finish a new operator key after a failure partway, record every hand-out, reach the vault's copies from secret recover (review 250)
mesh/merge-gate head da889d421d71 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/delivery superseded: a newer head of the same pull request
mesh/repo-check head da889d421d71 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:20:25 +00:00
mesh-adminandClaude Opus 5.5 b67fe1ffb1 mesh-vault: sync before rename, record hand-outs, refuse a new key beside secrets, take the operator key as PEM only (review 250)
mesh/delivery-group group pkg/57-v1-vault-in-go delivering: 1 of 2 delivered
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head b67fe1ffb16e merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head b67fe1ffb16e merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:19:36 +00:00
mesh-adminandClaude Opus 5.5 2000c02559 mesh-vault: retire a holder by ADR 0230, never delete it on a pass; refuse a contributions file that says nothing (review 250)
mesh/delivery superseded: a newer head of the same pull request
mesh/delivery-group group pkg/57-v1-vault-in-go checking: 1 of 2 member(s) ready
mesh/merge-gate head 2000c02559d8 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 2000c02559d8 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:18:51 +00:00
mesh-adminandClaude Opus 5.5 84d8198f15 mesh-vault: ADR 0349's vault tests (issue 674)
mesh/merge-gate head 84d8198f1540 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 84d8198f1540 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery-group group pkg/57-v1-vault-in-go rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:57:54 +00:00
mesh-adminandClaude Opus 5.5 329fc420c9 mesh-vault: the ledger's and the tools' tests, and the README settling the key formats (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:56:44 +00:00
mesh-adminandClaude Opus 5.5 da04ad62ae mesh-vault: the tools the TypeScript vault offered, in Go (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:55:34 +00:00
mesh-adminandClaude Opus 5.5 11fc3258b4 mesh-vault: get, secret-changed and the custody ledger (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:54:22 +00:00
mesh-adminandClaude Opus 5.5 44d6a18e43 mesh-vault: its store, its keys and its rules (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:53:38 +00:00
mesh-adminandClaude Opus 5.5 71c611b768 Rewrite mesh-vault in Go, holding secrets sealed and handing them out on request (issue 674)
The vault was read-only TypeScript and held no secret the mesh uses (hq ADR
0349 §7, §11; ADR 0113 §1, §2, §22). It now keeps each secret in its own store,
sealed to its own key and to the operator key, answers the seat's verb get
sealed to the asker's key and only to an allowed asker (nobody yet: V4 and P3
bring the rule), raises secret-changed without a value, and takes no private
key. The custody ledger and its three tools carry over unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:52:59 +00:00
mesh-admin 8aa37e8b83 Open the integration branch of release 57 (config and provisioning)
So the packages of hq design 57 meet here, reviewed one by one, and reach main as one delivery group.
2026-10-13 23:39:24 +00:00
35 changed files with 5654 additions and 514 deletions
+6
View File
@@ -0,0 +1,6 @@
# Release 57: config and provisioning
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/predefined
go 1.22
+153
View File
@@ -0,0 +1,153 @@
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
// 0348 clause 7, design 57 package K2, issue 661).
//
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
// it, naming each module that names the key.
//
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
// itself, removing a key nobody names is let through here, and said.
//
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
// module check; this check reads only its keys.
//
// go run . [-base <the trunk's predefined.json>] <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// File is the list's file at the root of the catalogue.
const File = "predefined.json"
// Keys is the keys of a list, read as a JSON object; an absent file has none.
func Keys(path string) (map[string]bool, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return map[string]bool{}, nil
}
if err != nil {
return nil, err
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", path, err)
}
out := map[string]bool{}
for k := range keys {
out[k] = true
}
return out, nil
}
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
func Named(root string) (map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
named := map[string][]string{}
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Config map[string]json.RawMessage `json:"config"`
Settings map[string]json.RawMessage `json:"settings"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
for _, d := range declared {
var value struct {
Predefined string `json:"predefined"`
}
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
named[value.Predefined] = append(named[value.Predefined], m.Module)
}
}
}
}
return named, nil
}
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
// key nobody names is answered with none.
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
out := map[string][]string{}
for k := range base {
if !tree[k] {
out[k] = named[k]
}
}
return out
}
func main() {
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
flag.Parse()
if flag.NArg() != 1 {
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
os.Exit(2)
}
root := flag.Arg(0)
tree, err := Keys(filepath.Join(root, File))
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if *basePath == "" {
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
return
}
base, err := Keys(*basePath)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
named, err := Named(root)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
removed := Removed(base, tree, named)
keys := make([]string, 0, len(removed))
for k := range removed {
keys = append(keys, k)
}
sort.Strings(keys)
failed := false
for _, k := range keys {
if modules := removed[k]; len(modules) > 0 {
failed = true
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
"clause 7)\n", File, k, strings.Join(modules, ", "))
} else {
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
"value for it is mesh-config's to refuse, not this check's\n", File, k)
}
}
if failed {
os.Exit(1)
}
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// A catalogue whose dunst falls back to font-size, and the trunk's list holding font-size and
// agent-managed-settings.
func aCatalogue(t *testing.T, list string) (root, base string) {
t.Helper()
root = t.TempDir()
write := func(path, text string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(root, "modules", "dunst", "module.json"), `{"module": "dunst", "config": {"font-size":
{"type": "number", "description": "points", "default": 10, "predefined": "font-size"}}}`)
write(filepath.Join(root, "modules", "claude-code", "module.json"), `{"module": "claude-code", "settings": {
"managed_settings": {"kind": "preference", "default": "{}", "why": "none"}}}`)
write(filepath.Join(root, "modules", "plain", "module.json"), `{"module": "plain"}`)
if list != "" {
write(filepath.Join(root, File), list)
}
base = filepath.Join(t.TempDir(), "base.json")
write(base, `{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`)
return root, base
}
func run(t *testing.T, args ...string) (string, error) {
t.Helper()
out, err := exec.Command("go", append([]string{"run", "."}, args...)...).CombinedOutput()
return string(out), err
}
func TestARemovalOfAKeyAModuleStillNamesIsRefused(t *testing.T) {
// The planted removal: font-size goes while dunst still falls back to it.
root, base := aCatalogue(t, `{"agent-managed-settings": {"type": "object"}}`)
out, err := run(t, "-base", base, root)
if err == nil || !strings.Contains(out, `the key "font-size" is removed and still named by dunst`) {
t.Fatalf("a removal of a named key passed:\n%s", out)
}
}
func TestARemovalNobodyNamesPassesAndSaysWhatItCannotKnow(t *testing.T) {
root, base := aCatalogue(t, `{"font-size": {"type": "number"}}`)
out, err := run(t, "-base", base, root)
if err != nil || !strings.Contains(out, `"agent-managed-settings" is removed and no module names it`) ||
!strings.Contains(out, "mesh-config's to refuse") {
t.Fatalf("a removal nobody names: %v\n%s", err, out)
}
}
func TestAListUnchangedOrGrownPasses(t *testing.T) {
for _, list := range []string{
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`,
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}, "accent": {"type": "string"}}`,
} {
root, base := aCatalogue(t, list)
if out, err := run(t, "-base", base, root); err != nil || !strings.Contains(out, "none removed") {
t.Fatalf("%s: %v\n%s", list, err, out)
}
}
// No trunk list given: nothing was removed.
root, _ := aCatalogue(t, `{}`)
if out, err := run(t, root); err != nil || !strings.Contains(out, "none was removed") {
t.Fatalf("without a base: %v\n%s", err, out)
}
}
func TestTheFileRemovedWholeIsARemovalOfEveryKey(t *testing.T) {
root, base := aCatalogue(t, "")
out, err := run(t, "-base", base, root)
if err == nil || !strings.Contains(out, `"font-size" is removed and still named by dunst`) {
t.Fatalf("the list removed whole passed:\n%s", out)
}
}
func TestNamedReadsBothForms(t *testing.T) {
root, _ := aCatalogue(t, "")
if err := os.WriteFile(filepath.Join(root, "modules", "plain", "module.json"), []byte(`{"module": "plain",
"settings": {"size": {"predefined": "font-size"}}}`), 0o644); err != nil {
t.Fatal(err)
}
named, err := Named(root)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(named["font-size"], ","); got != "dunst,plain" {
t.Fatalf("font-size named by %s", got)
}
}
+16
View File
@@ -33,6 +33,10 @@
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
# the nested ones the controller cannot protect alone yet, and only shrinks.
# 7. the list of predefined keys (novox/hq ADR 0348 clause 7): a change to predefined.json removes no key a
# module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a
# value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and
# every manifest against it are judged by the controller's module check in step 1.
set -eu
(cd checks/words && go test -count=1 . && go run . ../..)
@@ -51,6 +55,18 @@ else
echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with"
fi
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi)
# The list of predefined keys (novox/hq ADR 0348 clause 7): no key removed that a module still names. The trunk's
# list at the merge base is what it is judged against; a trunk without the file had none.
predefined_base=""
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
predefined_base=$(mktemp)
if ! git show "$merge_base:predefined.json" >"$predefined_base" 2>/dev/null; then
echo '{}' >"$predefined_base"
fi
else
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen"
fi
(cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi)
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
base_lists=""
+21 -3
View File
@@ -26,12 +26,24 @@
"why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant"
}
],
"settings": {
"org": {
"kind": "preference",
"default": "mesh",
"why": "the organisation influxdb-api names to its consumers; mesh is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11)"
},
"bucket": {
"kind": "preference",
"default": "default",
"why": "the bucket influxdb-api names to its consumers; default is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11)"
}
},
"serves": {
"influxdb-api": {
"scheme": "http",
"port": 8086,
"org": "mesh",
"bucket": "default"
"org": "${setting:org}",
"bucket": "${setting:bucket}"
}
},
"receives": {
@@ -124,7 +136,13 @@
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
"merge": "json",
"takes-settings": [
"bucket",
"org",
"url"
],
"trusted": true
}
],
"requires": [
+216
View File
@@ -0,0 +1,216 @@
# 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.
The key is made only for an empty store: with the key file missing and secrets in the store, the vault
does not start and says so, rather than make a key that opens none of them.
- **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.
Every file is written whole, synced, renamed into place and its directory synced.
### What the encryption at rest protects against, and what not
The vault's private key lives in plaintext in `${dir:state}`, on the same disk as the store, readable by the
vault's account alone. So:
- **It protects** against a copy of the store directory alone — a `store` backup, a file sent somewhere,
a listing of `${dir:store}` by a tool that reads files — holding anything usable, and against a value
being found by searching the disk for it.
- **It does not protect** against anyone who can read the vault's state directory too: root on the
vault's node, the vault's own account, or a backup that holds both `state` and `store` (both are
class `valuable`, so the node's backup holds both). Against those, at-rest encryption adds nothing; the
protection is the node's own security. Keeping the key elsewhere (another directory class, a key the
operator unlocks) is for V3's review, before real secrets move in.
## 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. Each record names the key its copy is sealed to, so a failure partway is finished on
the next minute's pass, and nothing is left sealed to an earlier key for good.
The vault takes an operator key only as SPKI PEM (`SetOperatorKey`). A PEM private key is refused, and so
is a raw 32-byte key, because a raw private key looks the same. The one raw key it reads is the export's,
which the controller writes from what it recorded; the vault wraps it as SPKI before taking it. It keeps
the key raw in base64, the form of the operator's NaCl box. When the secrets move to the vault (design 57's
V3), the operator key moves with them.
### How the operator opens a copy
`mesh-vault.secret_vault_export` answers the vault's copies in the shape `secret export` writes. Saved to a
file, one is opened with the operator's private key by
nox secret recover vault mesh-vault <name> --key <operator key file> --from-export <file> --out <file>
(`--out` is needed: a name holds `/`). `TestTheOperatorRecoversAVaultSecretFromItsExport` reads the file
as that command does and opens it as it does. `secret_export` still answers the controller's export, as
before; until V3 the controller's secrets are there.
## 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.
Every answer and every refusal is recorded in the vault's own store (`handed.jsonl`: who, which secret,
which generation, when, and why a refusal), synced before the answer is given — as ADR 0277 §25 records
asks; a secret whose hand-out cannot be recorded is not handed out. A refusal is recorded only for a caller
of a bus user's shape and a well-formed secret name, so an arbitrary argument fills nothing. The record is
bounded: rolled at 1 MiB into `handed.jsonl.1` … `.3`, the oldest dropped, each roll said loudly in the
journal and counted in `vault_status`. `secret_handed` reads it.
`vault_status` says what needs a person: operator copies still sealed to an earlier key, an operator key in
the export the vault refused, and how often the record was rolled. **It is not yet a condition**: a health
check is declared on a long-running resource (ADR 0240), and the vault is a bundle the tool runner loads, so
nothing asks it on its own; until it runs as a resource of its own (V3's review) these reach a person
through the journal and this tool only.
**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).
## Taking values in: `accept`, `secret-ask`, `issued`, `rotate`
Design 57's V2 (issue 675; ADR 0349 clauses 8 to 10, ADR 0277 §22–§27 and ADR 0305 with the vault in the
controller's place). `intake.go`.
**Two steps of one verb, from the controller alone.** The first step names the secret and answers a `call` and
`seal_to`, the public half of an X25519 key the vault made for that one call (raw, base64 — the NaCl anonymous box
the desk's prompt and a provider's tool already seal to). The second step sends `call` and `sealed`, and the vault
opens it with the private half, which lives in this process only and is forgotten once used or after two minutes.
A value sealed to any other key is refused, and a call is answered once.
**Why the controller carries it.** A desk's prompt (`node-launcher.secret`) and a provider's tool answer the
controller alone: the bus lets only the controller ask them, on the subject that names its caller (issue 365). The
vault cannot call in a name of its own while it runs in its machine's tool runner, whose one bus account carries
that machine's agents' calls too (issue 569, ADR 0305 §3). So the controller asks them with the vault's key and
passes the sealed answer on; it cannot open it. The value opens in the vault and nowhere else. The vault refuses
these steps from any caller but `controller`, as the runtime proved it.
- `accept` — node, module, name (a module's own secret) or provider, provision (a provision's one credential),
and `by`. The controller's terminal seals the value it read to the call's key before it leaves the terminal.
Kept with origin `accepted`; an accepted provision credential is never remade (`rotate` refuses it); a value a
provider's software issued is not taken by `accept`.
- `secret-ask` — node, module, name, desk and `asked_by` (who asked the controller, as the bus named its caller).
The ask is recorded in `asks.jsonl` before anything else, and refused while another ask for the same secret is
open (two minutes at most) or when three were made in the last hour. The answer names who asked as the record
has it, which the prompt shows. The second step carries what the prompt sealed, or `outcome` (dismissed,
timed-out, empty, refused, failed); how each ask ended is recorded. `secret_asks` reads the record. The hand-act
log stays the controller's: it records the act once the vault has taken the value.
- `issued` — provision, provider (machine), module. The controller asks the provider's tool with the call's key,
as it asks it today; the value is kept as the provision's one credential with origin `issued`. The same value
again writes nothing, says nothing and keeps the generation (ADR 0305 §5).
- `rotate` — a module's own secret or a provision's credential, made anew. Refused for an `issued` value (the
software rotates it, ADR 0305 §9) and an accepted provision credential (clause 8). Who asked is the proven caller,
or the `by` the controller names when it carries the act; both are kept (`changed-by`, `changed-via`).
Names in the store: `own/<node>/<module>/<name>` and `provision/<provider node>/<provision>`. V3 copies the
controller's rows in under these names.
**Which modules reach the vault this way.** The controller sends a module's verbs here only when its secrets are
filled the new way (its machine asks the vault for them, V4); every other module keeps the controller's path, so
no value lands where its machine never looks (ADR 0349 clause 17; the release lead's decision on issue 675). Until
V4 that is no module: these verbs are served and tested, and nothing calls them yet.
**What the issuing provider's test is here.** `TestAnIssuingProviderAnswersOnlyTheControllersCallSealedToTheVault`
uses a stand-in provider that refuses every caller but the controller and answers only sealed to the call's key.
plex's own tool already answers the controller alone; whether it moves with the media catalogue is for that move,
after release 57.
## Events
- `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value
or a sealed copy.
- `provisioned`, `rotated` — the custody ledger's, by fingerprint, as before.
- `provisioner.retirement` (ADR 0230 §35) — `waiting`, `settled`, `approved`, `rejected`, `retired`,
`reenabled`, `deleted`, naming holders, never a value.
## 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.
`secret_handed` and `secret_vault_export` read the vault's own store. None returns a value.
**The ledger follows ADR 0230.** A contributions file that does not say who is asked for (unreadable, not
JSON, no `given` list or a null one, or for another requirement) is not "nobody": the pass records nothing,
retires nobody and starts its count again. A holder no longer asked for is retired — a mark with when and
why in its own ledger file, its history kept — only after the same set has gone unasked in five passes and
ten minutes; more than three at once, or more than half of those held, waits for a person
(`provisioner_retire_approve`, `provisioner_retire_reject`); asked for again it is re-enabled; it is deleted
only by `provisioner_delete`, a person's act with a why. `provisioner_retirement` shows it all. The vault is
mark-only: it holds no access to switch off.
The words are the controller's: the `provisioner.retirement` event is mesh-controller's `link.Retirement`
(`provider-node`, `consumers` as objects) and the `provisioner_retirement` answer its `link.RetirementState`
(`held`, `bound`, `retires_by: mark-only`), so `retire approve`, `retire reject` and `cleanup list` read the
vault and a set waiting for a person raises the controller's urgent condition. The acts take what the SDK's
harness takes: approve and reject repeat the set in `consumers` and refuse any other set; delete repeats the
name in `confirm`; each records the `by` and `via` the controller sends. `testdata/retirement-*.json` are
those types marshalled by the controller (feat/57 at dc36536), and the tests hold the vault's words to them.
What the TypeScript vault did and this one does not: it consumed its own events only to print an audit line,
which the pass now prints itself. Its retirement ran on the SDK's harness, which this one follows: mark-only,
and a holder's ledger file removed only through a person's delete, as here. The difference is where the mark
lives: the harness kept it in its process, so a restart forgot it (ADR 0230 §13); here it is in the ledger
file.
## What comes later
Taking values in is built (above); the controller's secrets move in with V3; nodes ask with V4, which also turns
the controller's switch on for each module it moves; 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. `intake_test.go` is ADR 0349's `secret-ask` test as far as
the vault answers it (recorded before the prompt, one open ask per secret and three an hour, the asker named as
the bus named it, the typed value opened only here), the issuing provider's test with a stand-in provider (ADR 0305
§13's checks that belong to the vault: nothing rewritten for a value held, `rotate` refused, no other way in), and
accept and rotate. `followups_test.go` holds V1's review follow-ups.
-172
View File
@@ -1,172 +0,0 @@
// mesh-vault's ledger — vault's own code, living in the module (novox/hq ADR 0039). The provisioner and
// the tools both import it, and nothing outside vault does.
//
// **The vault holds no value.** A `secret` is an ordinary pair credential: the controller mints it,
// seals it to the consumer's node and to this one, and the host unseals this node's copy into the
// file the contribution names (ADR 0048). That file is already on this machine, readable by nothing
// but the vault's runtime, and it is the only copy the vault ever sees. Writing a second copy —
// plain, or sealed to a key the vault keeps — would put back exactly the single place that can open
// everything, which is what sealing to the machine was built to remove (ADR 0085's open question is
// how to recover WITHOUT that; the answer is not "keep one anyway").
//
// So what the vault keeps is what makes a secret *owned* rather than merely delivered: who holds
// one, since when, its fingerprint, and every time it changed. Enough to say "this holder's value is
// the one the mesh last delivered" and "it has been rotated twice, last on Tuesday" — and never
// enough to say what it is. The fingerprint is the only thing a tool may take or return, which is
// the rule the source mesh's secret tools were built on: the secret is never an argument.
import { createHash } from "node:crypto";
import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/** One holder of a secret this vault provides — everything the vault knows, and no value. */
export interface Held {
/** The login the mesh derived for the consumer — `<node>-<module>`, so it names the holder. */
readonly as: string;
/** The consumer's node. */
readonly consumer: string;
/** sha256 of the value the mesh last delivered, `sha256:<hex>`. Compared, never inverted. */
readonly fingerprint: string;
/** Length of the value, so a holder can tell a truncated file from a wrong one. */
readonly length: number;
/** When this holder was first granted a secret. */
readonly since: string;
/** When the value last changed — equal to `since` until the first rotation. */
readonly changed: string;
/** How many times the value has changed since `since`. */
readonly rotations: number;
/** Every earlier fingerprint, oldest first: the audit trail a rotation leaves. */
readonly history: readonly { readonly fingerprint: string; readonly until: string }[];
}
/** What recording a delivery found: a new holder, a changed value, or nothing new. */
export type Outcome = "granted" | "rotated" | "unchanged";
/** sha256 of a value, as `sha256:<hex>`. The one thing about a secret that may be spoken. */
export function fingerprint(value: string): string {
return "sha256:" + createHash("sha256").update(value, "utf8").digest("hex");
}
export class Ledger {
private readonly dir: string;
constructor(dir: string) {
this.dir = dir;
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
/** Build from the module's resolved environment: $MESH_VAULT_LEDGER is where holders are kept. */
static fromEnv(env: NodeJS.ProcessEnv = process.env): Ledger {
const dir = env.MESH_VAULT_LEDGER;
if (!dir) {
throw new Error("MESH_VAULT_LEDGER is not set — the vault has nowhere to keep its ledger");
}
return new Ledger(dir);
}
/**
* Record that the mesh delivered `value` for `as`. Idempotent: the same value again changes
* nothing, a different value is a rotation and is remembered as one. The value is fingerprinted
* here and goes no further.
*/
record(as: string, consumer: string, value: string, now = new Date()): { held: Held; outcome: Outcome } {
const fp = fingerprint(value);
const at = now.toISOString();
const before = this.get(as);
if (!before) {
const held: Held = {
as, consumer, fingerprint: fp, length: value.length,
since: at, changed: at, rotations: 0, history: [],
};
this.write(held);
return { held, outcome: "granted" };
}
if (before.fingerprint === fp && before.length === value.length) {
return { held: before, outcome: "unchanged" };
}
const held: Held = {
...before, consumer, fingerprint: fp, length: value.length, changed: at,
rotations: before.rotations + 1,
history: [...before.history, { fingerprint: before.fingerprint, until: at }],
};
this.write(held);
return { held, outcome: "rotated" };
}
/** Forget a holder the mesh withdrew. Returns whether there was one to forget. */
withdraw(as: string): boolean {
try {
unlinkSync(this.pathOf(as));
return true;
} catch {
return false;
}
}
get(as: string): Held | undefined {
try {
return JSON.parse(readFileSync(this.pathOf(as), "utf8")) as Held;
} catch {
return undefined;
}
}
/** Every holder, by login. */
list(): Held[] {
let names: string[];
try {
names = readdirSync(this.dir);
} catch {
return [];
}
return names
.filter((n) => n.endsWith(".json"))
.map((n) => this.get(n.slice(0, -".json".length)))
.filter((h): h is Held => h !== undefined)
.sort((a, b) => a.as.localeCompare(b.as));
}
private pathOf(as: string): string {
if (!/^[a-z0-9][a-z0-9_.-]*$/.test(as)) {
throw new Error(`a login is a name, not a path: ${JSON.stringify(as)}`);
}
return join(this.dir, `${as}.json`);
}
/** Written whole and renamed into place, so a reader never sees half a record. */
private write(held: Held): void {
const final = this.pathOf(held.as);
const tmp = `${final}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(held, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, final);
}
}
/** One entry of the mesh's contributions file, as the vault reads it for its tools. */
export interface Contribution {
readonly from?: string;
readonly node?: string;
readonly as: string;
readonly secret: string;
}
/** The consumers the mesh currently asks this vault to serve — the `receives` file, read plainly. */
export function contributions(receives: string): Contribution[] {
let doc: { given?: Contribution[] };
try {
doc = JSON.parse(readFileSync(receives, "utf8")) as { given?: Contribution[] };
} catch {
return [];
}
return (doc.given ?? []).filter((g) => g.as && g.secret);
}
/** Fingerprint of the value the host currently holds for one contribution, or why it could not. */
export function deliveredFingerprint(c: Contribution): { fingerprint: string; length: number } | { error: string } {
try {
const value = readFileSync(c.secret, "utf8").replace(/\n$/, "");
return { fingerprint: fingerprint(value), length: value.length };
} catch (err) {
return { error: `the delivered secret is not readable: ${err}` };
}
}
@@ -0,0 +1,37 @@
package main
// Who may ask `get` for which secret: the seam the real rule fills (novox/hq ADR 0349 §11).
//
// The rule is: a node's provisioner client, running as a service under a bus account of its own, for a
// secret that node's declaration names; or a module, for a secret its manifest declares, only once its
// code runs under a bus account of its own (issue 569). Neither exists yet — the client and its account are
// design 57's P3, the vault's answer to a node V4 — so the vault that runs allows nobody (NoAsker), and the
// tests set a stand-in list (AllowList). V4 and P3 put the real rule behind this interface.
// Askers decides whether caller (the bus user the runtime proved) may have the secret name, and if not,
// why not.
type Askers interface {
May(caller, name string) (bool, string)
}
// NoAsker allows nobody: the vault's rule until who may ask is decided by a declaration and an account.
type NoAsker struct{}
// May refuses every caller.
func (NoAsker) May(string, string) (bool, string) {
return false, "the vault hands a secret to no asker yet: a node's provisioner client under its own account, " +
"for what its declaration names, is the first it will answer (novox/hq ADR 0349 §11, issue 647)"
}
// AllowList is a stand-in rule: each caller, and the names it may have.
type AllowList map[string][]string
// May allows a caller only the names listed for it.
func (l AllowList) May(caller, name string) (bool, string) {
for _, n := range l[caller] {
if n == name {
return true, ""
}
}
return false, "not on the list of who may ask for it"
}
@@ -0,0 +1,162 @@
package main
// The four follow-ups V1's third review (review 263 on mesh-catalog#286) left for this package: the tool layer
// passes an act's words through; a rolled record of hand-outs is said in the journal; the export's fingerprint
// and vault_status are what they say; and an act's unproven `by` is kept beside the proven caller.
import (
"fmt"
"sort"
"strings"
"sync"
"testing"
"git.novox.be/novox/mesh-sdk/go/seal"
)
func anyOf(list []string) []any {
out := make([]any, len(list))
for i, s := range list {
out[i] = s
}
return out
}
// (1) and (4): the retirement tools pass consumers, why, by, via and confirm through to the custody ledger, and
// a `by` naming somebody other than the proven caller is kept beside it.
func TestTheRetirementToolsPassTheActThrough(t *testing.T) {
c := newCustody(t)
c.ask(apps(5))
c.passes(1)
c.ask(keepOnly(apps(5), 1))
c.stable()
set := waitingNames(c.Retirement())
sort.Strings(set)
defs := definitions(&Env{Custody: c.Custody})
approve := toolNamed(t, defs, "provisioner_retire_approve")
if _, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set[:2]), "why": "x"}); err == nil {
t.Error("the tool took a set other than the one waiting")
}
got, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set), "why": "the apps are gone",
"by": "person.jochen", "via": "telegram"})
if err != nil {
t.Fatal(err)
}
if done := got.(map[string]any)["retired"].([]string); len(done) != 4 {
t.Errorf("retired %v", done)
}
last := c.bus.events[len(c.bus.events)-5]["body"].(map[string]any)
for _, e := range c.bus.events {
if b := e["body"].(map[string]any); b["change"] == "approved" {
last = b
}
}
if last["why"] != "the apps are gone" || last["by"] != "person.jochen" || last["via"] != "telegram, called by controller" {
t.Errorf("the approval said %v", last)
}
// Delete: consumer and confirm through, the act's words with them.
r := retiredC(t)
del := toolNamed(t, definitions(&Env{Custody: r.Custody}), "provisioner_delete")
if _, err := del.runAs("person.jochen", map[string]any{"consumer": "d-app", "confirm": "c-app", "why": "x"}); err == nil {
t.Error("a confirm naming another was taken through the tool")
}
if _, err := del.runAs("person.jochen", map[string]any{"consumer": "d-app", "confirm": "d-app", "why": "gone"}); err != nil {
t.Fatal(err)
}
body := r.bus.events[len(r.bus.events)-1]["body"].(map[string]any)
if body["change"] != "deleted" || body["by"] != "person.jochen" || body["via"] != nil || body["why"] != "gone" {
t.Errorf("the deletion said %v", body)
}
// Reject, by the proven caller alone: nothing more is added to via.
k := newCustody(t)
k.ask(apps(5))
k.passes(1)
k.ask(keepOnly(apps(5), 1))
k.stable()
waiting := waitingNames(k.Retirement())
if _, err := toolNamed(t, definitions(&Env{Custody: k.Custody}), "provisioner_retire_reject").runAs("person.jochen",
map[string]any{"consumers": anyOf(waiting), "why": "they come back"}); err != nil {
t.Fatal(err)
}
if body := k.bus.events[len(k.bus.events)-1]["body"].(map[string]any); body["by"] != "person.jochen" || body["via"] != nil {
t.Errorf("the rejection said %v", body)
}
}
// (2): a roll of the record of hand-outs is said in the journal, loudly.
func TestARolledRecordOfHandOutsIsSaid(t *testing.T) {
v := newVault(t, nil)
var mu sync.Mutex
var said []string
v.Log = func(format string, args ...any) {
mu.Lock()
defer mu.Unlock()
said = append(said, fmt.Sprintf(format, args...))
}
v.Store.HandedMax = 600
asker, _ := seal.GenerateKeyPair()
for i := 0; i < 20; i++ {
_, _ = v.Get("g14.flood", "pair/postgres/gitea", asker.PublicKey)
}
rolled := 0
for _, s := range said {
if strings.Contains(s, "THE RECORD OF HAND-OUTS WAS ROLLED") {
rolled++
}
}
if rolled == 0 || rolled != v.Store.Rolled() {
t.Errorf("rolled %d times, said %d times", v.Store.Rolled(), rolled)
}
}
// (3): the vault's export names the operator key's fingerprint as the controller's secrets.Fingerprint says it,
// and vault_status says what needs a person.
func TestTheExportsFingerprintAndVaultStatus(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{})
if err != nil {
t.Fatal(err)
}
doc := answer.(map[string]any)
// sha256 of the key, its first eight bytes in hex: the controller's secrets.Fingerprint.
if fp := doc["fingerprint"].(string); !strings.HasPrefix(fp, "sha256:") || len(fp) != len("sha256:")+16 ||
fp != keyFingerprint(v.operatorPublic) || doc["operator-key"] != v.operatorPublic {
t.Errorf("the export says %v", doc)
}
if keyFingerprint("") != "" {
t.Error("no key has a fingerprint")
}
env := &Env{Vault: v.Vault}
status := func() map[string]any {
got, err := toolNamed(t, definitions(env), "vault_status").Run(map[string]any{})
if err != nil {
t.Fatal(err)
}
return got.(map[string]any)
}
if s := status(); s["ok"] != true || s["copies_on_an_earlier_key"] != 0 || s["operator_key"] != v.operatorPublic {
t.Errorf("a vault in order says %v", s)
}
env.refuseKey("a private key was offered")
if s := status(); s["ok"] != false || s["operator_key_refused"] != "a private key was offered" {
t.Errorf("a refused key says %v", s)
}
env.refuseKey("")
// A new operator key whose resealing fails leaves copies on the earlier key, and the status says so.
pub, _ := operatorKey(t)
v.Store.failWrite = func(Record) error { return fmt.Errorf("disk full") }
_, _ = v.SetOperatorKey(asPEM(t, pub), "the test")
v.Store.failWrite = nil
if s := status(); s["ok"] != false || s["copies_on_an_earlier_key"] != 1 {
t.Errorf("a copy on an earlier key says %v", s)
}
if s, _ := toolNamed(t, definitions(&Env{}), "vault_status").Run(map[string]any{}); s.(map[string]any)["ready"] != false {
t.Errorf("no vault says %v", s)
}
}
+472
View File
@@ -0,0 +1,472 @@
package main
// What the vault takes in, and how it rotates (novox/hq ADR 0349 clauses 8 to 10; ADR 0277 §22–§27 and ADR 0305
// with the vault in the controller's place; design 57's V2, issue 675).
//
// Four verbs of the seat: `accept` (a value given at the controller's terminal), `secret-ask` (a value the
// operator types at a desk), `issued` (a value a provider's software issued) and `rotate`.
//
// **A value comes in sealed to a key the vault made for that one call, and opens only here.** The first three
// are two steps of one verb. The first names what is taken and answers a call and the public half of a key made
// for it (`seal_to`, a raw X25519 key in base64, the NaCl anonymous box the desk's prompt and a provider's tool
// already seal to); the second hands back what was sealed to that key, and the vault opens it with the private
// half, which never leaves this process and is forgotten once used or after two minutes.
//
// **The controller carries the call, and cannot open what it carries.** The desk's prompt and a provider's tool
// answer the controller alone: the bus lets only the controller ask them, on the subject that names its caller
// (issue 365). The vault cannot call in a name of its own while it runs in its machine's tool runner, whose one
// bus account carries that machine's agents' calls too (issue 569, ADR 0305 §3). So the controller asks them on
// the vault's behalf with the vault's key, and passes on the sealed answer: the value is opened in the vault and
// nowhere else (ADR 0349 clause 9, ADR 0277 §23), and the vault takes these steps from the controller alone.
//
// Which secret each one is, by name in the vault's store:
//
// own/<node>/<module>/<name> a module's own secret on one machine
// provision/<provider node>/<provision> the one credential of a provider assignment and provision (clause 8)
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"regexp"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"golang.org/x/crypto/nacl/box"
)
// ControllerCaller is the controller's bus user: the one caller that carries a value in (broker.ControllerName).
const ControllerCaller = "controller"
// Where a value came from.
const (
OriginMade = "made"
OriginAccepted = "accepted"
OriginTyped = "typed"
OriginIssued = "issued"
)
// CallOpenFor is how long a call's key waits for its sealed value: longer than any prompt or tool waits.
const CallOpenFor = 2 * time.Minute
// The bounds of asking for one secret (ADR 0277 §24, the controller's inventory.SecretAsk* until now).
const (
// SecretAskOpenFor is how long an ask that has not ended counts as open.
SecretAskOpenFor = 2 * time.Minute
// SecretAsksPerHour is how many asks for one secret an hour takes.
SecretAsksPerHour = 3
)
var namePart = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
// OwnSecret is the vault's name for a module's own secret on one machine.
func OwnSecret(node, module, name string) (string, error) {
for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name} {
if !namePart.MatchString(v) {
return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v)
}
}
return "own/" + node + "/" + module + "/" + name, nil
}
// ProvisionSecret is the vault's name for the one credential of a provider assignment and provision.
func ProvisionSecret(provider, provision string) (string, error) {
for what, v := range map[string]string{"the provider's machine": provider, "the provision": provision} {
if !namePart.MatchString(v) {
return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v)
}
}
return "provision/" + provider + "/" + provision, nil
}
// callKey is one call's key and what the call takes in. Never written anywhere.
type callKey struct {
verb, secret, by string
// ask is the ask's record for `secret-ask`, which the call's id is.
ask *AskRecord
public [32]byte
private [32]byte
until time.Time
}
// Opened is a first step's answer: the call, and the key to seal its value to. Never a value.
type Opened struct {
Call string `json:"call"`
SealTo string `json:"seal_to"`
Secret string `json:"secret"`
AskedBy string `json:"asked_by,omitempty"`
}
// fromTheController refuses every caller but the controller: only it reaches a desk's prompt and a provider's
// tool, and only it carries a value given at its terminal.
func fromTheController(caller, verb string) error {
if caller != ControllerCaller {
if caller == "" {
caller = stdio.Unproven
}
return fmt.Errorf("%s takes a value from the controller alone, which carries it sealed to this vault's key "+
"for one call; %s is not the controller", verb, caller)
}
return nil
}
// openCall makes a key for one call and keeps its private half here until the call is taken or two minutes
// have passed.
func (v *Vault) openCall(verb, secret, by string, ask *AskRecord) (string, string, error) {
pub, priv, err := box.GenerateKey(rand.Reader)
if err != nil {
return "", "", err
}
idRaw := make([]byte, 12)
if _, err := rand.Read(idRaw); err != nil {
return "", "", err
}
id := hex.EncodeToString(idRaw)
if ask != nil {
id = ask.ID
}
v.callsMu.Lock()
defer v.callsMu.Unlock()
if v.pending == nil {
v.pending = map[string]*callKey{}
}
now := v.Now()
for k, c := range v.pending {
if now.After(c.until) {
delete(v.pending, k)
}
}
v.pending[id] = &callKey{verb: verb, secret: secret, by: by, ask: ask, public: *pub, private: *priv,
until: now.Add(CallOpenFor)}
return id, base64.StdEncoding.EncodeToString(pub[:]), nil
}
// takeCall ends a call: its key is forgotten whatever follows.
func (v *Vault) takeCall(verb, id string) (*callKey, error) {
v.callsMu.Lock()
defer v.callsMu.Unlock()
c, ok := v.pending[id]
if !ok || c.verb != verb {
return nil, fmt.Errorf("no %s call %q is open in the vault: it ended, it was never opened, or the vault "+
"started again; open it anew", verb, id)
}
delete(v.pending, id)
if v.Now().After(c.until) {
return nil, fmt.Errorf("the %s call %q waited longer than %s and is closed; open it anew", verb, id, CallOpenFor)
}
return c, nil
}
// open opens what was sealed to the call's key, and forgets the key.
func (c *callKey) open(sealed string) ([]byte, error) {
defer func() { c.private = [32]byte{} }()
blob, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sealed))
if err != nil {
return nil, errors.New("the value is not sealed to this call; nothing was taken")
}
out, ok := box.OpenAnonymous(nil, blob, &c.public, &c.private)
if !ok {
// Never the value, never what failed to open: only that it was not sealed to this call.
return nil, errors.New("the value is not sealed to this call; nothing was taken")
}
return out, nil
}
func zero(b []byte) {
for i := range b {
b[i] = 0
}
}
// ---- accept -------------------------------------------------------------------------------------------------
// AcceptOpen opens a call to take a value given at the controller's terminal for secret, which the terminal
// seals to the call's key before it leaves it (ADR 0349 clause 8: the vault alone opens an accepted value).
func (v *Vault) AcceptOpen(caller, secret, by string) (Opened, error) {
if err := fromTheController(caller, "accept"); err != nil {
return Opened{}, err
}
if err := validName(secret); err != nil {
return Opened{}, err
}
if rec, held, err := v.Store.Read(secret); err != nil {
return Opened{}, err
} else if held && rec.Origin == OriginIssued {
return Opened{}, fmt.Errorf("%s is issued by its provider's software, and `secret issued` is the only way "+
"it enters the vault (ADR 0305 §10); nothing is taken", secret)
}
id, key, err := v.openCall("accept", secret, by, nil)
if err != nil {
return Opened{}, err
}
return Opened{Call: id, SealTo: key, Secret: secret}, nil
}
// AcceptTake opens the value sealed to the call's key and keeps it, accepted: never remade by the vault.
func (v *Vault) AcceptTake(caller, call, sealed string) (Changed, error) {
if err := fromTheController(caller, "accept"); err != nil {
return Changed{}, err
}
c, err := v.takeCall("accept", call)
if err != nil {
return Changed{}, err
}
value, err := c.open(sealed)
if err != nil {
return Changed{}, err
}
defer zero(value)
if strings.TrimSpace(string(value)) == "" {
return Changed{}, errors.New("the value given is empty; nothing was taken")
}
return v.keep(c.secret, string(value), someoneOr(c.by, caller), caller, OriginAccepted)
}
// ---- secret-ask ---------------------------------------------------------------------------------------------
// AskOf is what an ask is for, and where.
type AskOf struct {
Node, Module, Name, Desk string
// AskedBy is who asked the controller, as the bus named its caller; the controller's word, taken from the
// controller alone.
AskedBy string
}
// AskOpen records an ask for a module's own secret before any prompt opens, holding its bounds — one open ask
// per secret, three an hour (ADR 0277 §24, §25) — and answers the key the desk's prompt seals what is typed to,
// and who asked as the record says it, which the prompt names (§26).
func (v *Vault) AskOpen(caller string, a AskOf) (Opened, error) {
if err := fromTheController(caller, "secret-ask"); err != nil {
return Opened{}, err
}
secret, err := OwnSecret(a.Node, a.Module, a.Name)
if err != nil {
return Opened{}, fmt.Errorf("nobody was asked to type anything: %w", err)
}
if !namePart.MatchString(a.Desk) {
return Opened{}, fmt.Errorf("nobody was asked to type anything: the desk %q is not a machine's name", a.Desk)
}
askedBy := askedByName(a.AskedBy)
v.Store.asksMu.Lock()
defer v.Store.asksMu.Unlock()
now := v.Now().UTC()
recent, err := v.Store.AsksSince(now.Add(-time.Hour))
if err != nil {
return Opened{}, fmt.Errorf("nobody was asked to type anything: the record of asks cannot be read: %w", err)
}
ended := map[string]bool{}
for _, r := range recent {
if r.Event == AskEnded {
ended[r.ID] = true
}
}
lastHour, openBy := 0, ""
for _, r := range recent {
if r.Event != AskOpened || r.Secret != secret {
continue
}
lastHour++
if !ended[r.ID] && r.At.After(now.Add(-SecretAskOpenFor)) {
openBy = r.AskedBy
}
}
if openBy != "" {
return Opened{}, fmt.Errorf("an ask for %s of %s on %s is still open (asked by %s): one prompt at a time for a "+
"secret, and this one ends within %s", a.Name, a.Module, a.Node, openBy, SecretAskOpenFor)
}
if lastHour >= SecretAsksPerHour {
return Opened{}, fmt.Errorf("%s of %s on %s was asked for %d times in the last hour, and an hour takes %d asks "+
"for one secret: the operator is not kept at a prompt", a.Name, a.Module, a.Node, lastHour, SecretAsksPerHour)
}
idRaw := make([]byte, 12)
if _, err := rand.Read(idRaw); err != nil {
return Opened{}, err
}
rec := AskRecord{ID: hex.EncodeToString(idRaw), At: now, Event: AskOpened, Secret: secret, Node: a.Node,
Module: a.Module, Name: a.Name, Desk: a.Desk, AskedBy: askedBy, Caller: caller}
if err := v.recordAsk(rec); err != nil {
return Opened{}, fmt.Errorf("nobody was asked to type anything: the ask could not be recorded: %w", err)
}
id, key, err := v.openCall("secret-ask", secret, askedBy, &rec)
if err != nil {
_ = v.recordAsk(AskRecord{ID: rec.ID, At: v.Now().UTC(), Event: AskEnded, Secret: secret, Outcome: "failed"})
return Opened{}, err
}
return Opened{Call: id, SealTo: key, Secret: secret, AskedBy: askedBy}, nil
}
// The outcomes an ask ends with, as the controller's record had them.
var askOutcomes = map[string]bool{"dismissed": true, "timed-out": true, "empty": true, "refused": true, "failed": true}
// AskEnd ends an ask: with what the desk sealed to its key, which the vault opens and keeps as typed, or with
// the outcome the desk answered instead. How it ended is recorded whatever happens.
func (v *Vault) AskEnd(caller, call, sealed, outcome string) (Changed, error) {
if err := fromTheController(caller, "secret-ask"); err != nil {
return Changed{}, err
}
if sealed == "" && !askOutcomes[outcome] {
return Changed{}, fmt.Errorf("an ask ends with what was sealed to its key, or with one of dismissed, " +
"timed-out, empty, refused or failed")
}
c, err := v.takeCall("secret-ask", call)
if err != nil {
return Changed{}, err
}
end := func(how string) {
if err := v.recordAsk(AskRecord{ID: c.ask.ID, At: v.Now().UTC(), Event: AskEnded, Secret: c.secret,
Outcome: how}); err != nil {
v.log("the end of ask %s (%s) could not be recorded: %v", c.ask.ID, how, err)
}
}
if sealed == "" {
end(outcome)
return Changed{Name: c.secret, Unchanged: true}, nil
}
opened, err := c.open(sealed)
if err != nil {
end("failed")
return Changed{}, err
}
value := strings.TrimRight(string(opened), "\r\n")
zero(opened)
if strings.TrimSpace(value) == "" {
end("empty")
return Changed{}, fmt.Errorf("nothing was given: the prompt on %s was answered empty", c.ask.Desk)
}
changed, err := v.keep(c.secret, value, c.ask.AskedBy, caller, OriginTyped)
value = ""
if err != nil {
end("failed")
return Changed{}, err
}
end("given")
return changed, nil
}
func (v *Vault) recordAsk(r AskRecord) error {
rolled, err := v.Store.RecordAsk(r)
if rolled {
v.log("THE RECORD OF ASKS WAS ROLLED: it reached its bound of %d bytes; the oldest of %d kept files is "+
"dropped. Many asks in little time is worth a look: secret_asks", v.Store.HandedBound(), HandedKept)
}
return err
}
// askedByName is who asked as the prompt and the record name it: the first clause of what the bus said, in the
// characters a name has, at most 80 (the controller's own rule, ADR 0277 §26), so no words of the caller's own
// reach the prompt.
func askedByName(caller string) string {
first, _, _ := strings.Cut(caller, ",")
var b strings.Builder
for _, r := range strings.TrimSpace(first) {
if b.Len() >= 80 {
break
}
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '/', r == '@',
r == '-', r == ' ':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
name := strings.TrimSpace(b.String())
if name == "" || strings.HasPrefix(name, "-") {
return "an unnamed caller"
}
return name
}
// ---- issued -------------------------------------------------------------------------------------------------
// IssuedOpen opens a call to take the credential a provider's software issued for a provision (ADR 0349 clause
// 10, ADR 0305 with the vault in the controller's place): the controller asks the provider's tool, on the
// provider's machine, with the call's key, and passes on what it answered.
func (v *Vault) IssuedOpen(caller, provision, provider, module string) (Opened, error) {
if err := fromTheController(caller, "issued"); err != nil {
return Opened{}, err
}
secret, err := ProvisionSecret(provider, provision)
if err != nil {
return Opened{}, err
}
if !namePart.MatchString(module) {
return Opened{}, fmt.Errorf("the provider's module %q is not a name the mesh writes", module)
}
id, key, err := v.openCall("issued", secret, module+" on "+provider, nil)
if err != nil {
return Opened{}, err
}
return Opened{Call: id, SealTo: key, Secret: secret}, nil
}
// IssuedTake opens what the provider's tool sealed to the call's key and keeps it as the provision's one
// credential. The same value again writes nothing (ADR 0305 §5): every holder that has it is left as it is.
func (v *Vault) IssuedTake(caller, call, sealed string) (Changed, error) {
if err := fromTheController(caller, "issued"); err != nil {
return Changed{}, err
}
c, err := v.takeCall("issued", call)
if err != nil {
return Changed{}, err
}
opened, err := c.open(sealed)
if err != nil {
return Changed{}, err
}
value := strings.TrimSpace(string(opened))
zero(opened)
if value == "" {
return Changed{}, fmt.Errorf("%s answered an empty value; nothing was taken", c.by)
}
return v.keep(c.secret, value, c.by, caller, OriginIssued)
}
// ---- rotate -------------------------------------------------------------------------------------------------
// Rotate makes a secret anew (ADR 0114, ADR 0228). Refused: a secret the vault does not hold; a value a
// provider's software issued, which the software rotates (ADR 0305 §9); and a provision's accepted credential,
// which the vault does not remake (ADR 0349 clause 8, ADR 0158 §8). A module's own value given by hand is
// rotated like a made one (ADR 0228). Who asked is the caller the runtime proved, or the person the controller
// names when the controller carries the act — both are kept.
func (v *Vault) Rotate(caller, secret, by string) (Changed, error) {
if caller == "" || caller == stdio.Unproven || !busUser.MatchString(caller) {
return Changed{}, errors.New("the vault rotates only for a caller the runtime proved")
}
if err := validName(secret); err != nil {
return Changed{}, err
}
rec, held, err := v.Store.Read(secret)
if err != nil {
return Changed{}, err
}
switch {
case !held:
return Changed{}, fmt.Errorf("not rotated: the vault holds no secret named %s", secret)
case rec.Origin == OriginIssued:
return Changed{}, fmt.Errorf("not rotated: %s is issued by its provider's software, which the mesh cannot "+
"make or rotate (ADR 0305 §9): change it in that software, and `secret issued` takes the value it holds now",
secret)
case rec.Origin == OriginAccepted && strings.HasPrefix(secret, "provision/"):
return Changed{}, fmt.Errorf("not rotated: %s was accepted, and an accepted credential is not remade by the "+
"vault (ADR 0349 clause 8): accept its new value instead", secret)
}
if caller != ControllerCaller || by == "" {
by = caller
}
raw := make([]byte, 30)
if _, err := rand.Read(raw); err != nil {
return Changed{}, err
}
return v.keep(secret, base64.RawURLEncoding.EncodeToString(raw), by, caller, OriginMade)
}
func someoneOr(by, caller string) string {
if strings.TrimSpace(by) == "" {
return caller
}
return by
}
@@ -0,0 +1,495 @@
package main
// ADR 0349's tests for what the vault takes in (design 57's V2, issue 675): the vault's `secret-ask` test, the
// issuing provider's test with a stand-in provider (plex's own tool moves with the media catalogue, after
// release 57), an accepted value opened in the vault alone and never remade, and rotate.
//
// In each, "the controller" is a test function that holds no private key: it only carries what the desk or the
// provider sealed to the key the vault made for the call, as the controller does on the bus.
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
"golang.org/x/crypto/nacl/box"
)
// sealToCall seals value to a call's key as the desk's prompt and a provider's tool do: the NaCl anonymous box.
func sealToCall(t *testing.T, sealTo, value string) string {
t.Helper()
raw, err := base64.StdEncoding.DecodeString(sealTo)
if err != nil || len(raw) != 32 {
t.Fatalf("seal_to %q is not a key", sealTo)
}
var key [32]byte
copy(key[:], raw)
sealed, err := box.SealAnonymous(nil, []byte(value), &key, rand.Reader)
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(sealed)
}
// handedTo opens what `get` hands an allowed asker: the value the vault holds.
func handedTo(t *testing.T, v testVault, asker, name string) string {
t.Helper()
key, _ := seal.GenerateKeyPair()
a, err := v.Get(asker, name, key.PublicKey)
if err != nil {
t.Fatal(err)
}
value, err := seal.Open(a.Sealed, key.PrivateKey)
if err != nil {
t.Fatal(err)
}
return value
}
// everything is all the vault said and wrote during a test, as text.
func everything(t *testing.T, v testVault, more ...any) string {
t.Helper()
var b strings.Builder
for _, said := range v.bus.raw {
b.WriteString(said)
}
for _, content := range filesUnder(t, v.dir) {
b.WriteString(content)
}
for _, m := range more {
raw, _ := json.Marshal(m)
b.Write(raw)
}
return b.String()
}
const typedAtTheDesk = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g"
func telegramAsk(by string) AskOf {
return AskOf{Node: "anchor", Module: "telegram", Name: "telegram-token", Desk: "laptop", AskedBy: by}
}
// ---- the vault's secret-ask test (ADR 0349 "How it is checked") ---------------------------------------------
// An ask is recorded in the vault's store before the prompt opens; its prompt names the asker as the bus named
// it; the typed value opens only in the vault; the controller carries ciphertext only.
func TestAnAskIsRecordedBeforeThePromptAndTheTypedValueOpensOnlyInTheVault(t *testing.T) {
v := newVault(t, AllowList{"anchor.node-provisioner": {"own/anchor/telegram/telegram-token"}})
opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14/claude-code, via the console"))
if err != nil {
t.Fatal(err)
}
// Recorded before any prompt opens: the record holds the ask now, with no end.
asks, _ := v.Store.Asks(10)
if len(asks) != 1 || asks[0].Event != AskOpened || asks[0].ID != opened.Call || asks[0].AskedBy != "g14/claude-code" ||
asks[0].Caller != ControllerCaller || asks[0].Desk != "laptop" || asks[0].Secret != "own/anchor/telegram/telegram-token" {
t.Fatalf("the record before the prompt: %+v", asks)
}
// The prompt names the asker as the bus named it, cut to a name: what the vault answers is what it recorded.
if opened.AskedBy != "g14/claude-code" {
t.Errorf("the prompt would name %q", opened.AskedBy)
}
// The desk seals what is typed to the call's key; the controller passes it on.
changed, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, typedAtTheDesk+"\n"), "")
if err != nil {
t.Fatal(err)
}
if changed.Generation != 1 || changed.By != "g14/claude-code" {
t.Errorf("kept as %+v", changed)
}
if got := handedTo(t, v, "anchor.node-provisioner", "own/anchor/telegram/telegram-token"); got != typedAtTheDesk {
t.Errorf("the vault holds %q, not what was typed with its line ending taken off", got)
}
rec, _, _ := v.Store.Read("own/anchor/telegram/telegram-token")
if rec.Origin != OriginTyped || rec.ChangedBy != "g14/claude-code" || rec.ChangedVia != ControllerCaller {
t.Errorf("the record says origin %q, by %q, via %q", rec.Origin, rec.ChangedBy, rec.ChangedVia)
}
asks, _ = v.Store.Asks(10)
if len(asks) != 2 || asks[0].Event != AskEnded || asks[0].Outcome != "given" || asks[0].ID != opened.Call {
t.Errorf("how it ended: %+v", asks)
}
if strings.Contains(everything(t, v, opened, changed, asks), typedAtTheDesk) {
t.Fatal("the typed value is in something the vault said, answered or wrote in the clear")
}
}
// One open ask per secret, and three an hour (ADR 0277 §24), read from the vault's own record.
func TestAnAskIsBoundedToOneOpenPerSecretAndThreeAnHour(t *testing.T) {
v := newVault(t, nil)
first, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
if err != nil {
t.Fatal(err)
}
if _, err := v.AskOpen(ControllerCaller, telegramAsk("ace.claude-code")); err == nil ||
!strings.Contains(err.Error(), "still open (asked by g14.claude-code)") {
t.Fatalf("a second ask while one is open: %v", err)
}
// Another secret is not held back by it.
other := telegramAsk("ace.claude-code")
other.Name = "webhook-secret"
if _, err := v.AskOpen(ControllerCaller, other); err != nil {
t.Fatalf("another secret's ask was held back: %v", err)
}
if _, err := v.AskEnd(ControllerCaller, first.Call, "", "dismissed"); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
o, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
if err != nil {
t.Fatalf("ask %d of the hour: %v", i+2, err)
}
if _, err := v.AskEnd(ControllerCaller, o.Call, "", "timed-out"); err != nil {
t.Fatal(err)
}
}
if _, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err == nil ||
!strings.Contains(err.Error(), "3 times in the last hour") {
t.Fatalf("a fourth ask in the hour: %v", err)
}
asks, _ := v.Store.Asks(50)
outcomes := map[string]int{}
for _, a := range asks {
outcomes[a.Outcome]++
}
if outcomes["dismissed"] != 1 || outcomes["timed-out"] != 2 {
t.Errorf("the record's outcomes: %v", outcomes)
}
// An ask left open counts as open for SecretAskOpenFor only: a prompt that died does not hold the secret.
w := newVault(t, nil)
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
w.Now = func() time.Time { return clock }
if _, err := w.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err != nil {
t.Fatal(err)
}
clock = clock.Add(SecretAskOpenFor + time.Second)
if _, err := w.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err != nil {
t.Errorf("an ask left open past its time still held the secret: %v", err)
}
}
// Only the controller reaches a desk's prompt, so only it opens and ends an ask; a value not sealed to the
// call's key, or a call that waited too long, keeps nothing.
func TestAnAskIsTakenFromTheControllerAloneAndOnlySealedToItsCall(t *testing.T) {
v := newVault(t, nil)
for _, caller := range []string{"g14.node-tools", "unproven", "", "person.jochen"} {
if _, err := v.AskOpen(caller, telegramAsk("x")); err == nil {
t.Errorf("%q opened an ask", caller)
}
}
if asks, _ := v.Store.Asks(10); len(asks) != 0 {
t.Fatalf("a refused caller filled the record: %+v", asks)
}
opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
if err != nil {
t.Fatal(err)
}
if _, err := v.AskEnd("g14.node-tools", opened.Call, sealToCall(t, opened.SealTo, "x"), ""); err == nil {
t.Error("another caller ended the ask")
}
other, _, _ := box.GenerateKey(rand.Reader)
if _, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, base64.StdEncoding.EncodeToString(other[:]), "x"), ""); err == nil ||
!strings.Contains(err.Error(), "not sealed to this call") {
t.Errorf("a value sealed to another key: %v", err)
}
if _, held, _ := v.Store.Read("own/anchor/telegram/telegram-token"); held {
t.Error("a value not sealed to the call was kept")
}
// The call's key is forgotten once used: the same call cannot be answered twice.
if _, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, "x"), ""); err == nil {
t.Error("a call was answered twice")
}
asks, _ := v.Store.Asks(1)
if asks[0].Outcome != "failed" {
t.Errorf("the ask that failed ended %+v", asks[0])
}
// A call that waited longer than its time is closed.
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
v.Now = func() time.Time { return clock }
late, err := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex")
if err != nil {
t.Fatal(err)
}
clock = clock.Add(CallOpenFor + time.Second)
if _, err := v.IssuedTake(ControllerCaller, late.Call, sealToCall(t, late.SealTo, "x")); err == nil {
t.Error("a call that waited too long was taken")
}
// An empty answer gives nothing and says so.
clock = clock.Add(time.Hour)
empty, _ := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
if _, err := v.AskEnd(ControllerCaller, empty.Call, sealToCall(t, empty.SealTo, " \n"), ""); err == nil {
t.Error("an empty answer was kept")
}
if asks, _ := v.Store.Asks(1); asks[0].Outcome != "empty" {
t.Errorf("an empty answer ended %+v", asks[0])
}
}
// ---- the issuing provider's test, with a stand-in provider -------------------------------------------------
// standInProvider is a provider's tool for an `issued-by: software` offer, as plex's must answer (ADR 0305 §2
// with the vault in the controller's place): it refuses every caller but the controller — the one caller the
// bus lets ask it, carrying the vault's call — and answers only the software's value sealed to the call's key.
func standInProvider(software *string) func(caller string, args map[string]any) (map[string]any, error) {
return func(caller string, args map[string]any) (map[string]any, error) {
if caller != ControllerCaller {
return nil, errors.New("this tool answers the controller alone")
}
key, _ := args["seal_to"].(string)
raw, err := base64.StdEncoding.DecodeString(key)
if err != nil || len(raw) != 32 {
return nil, errors.New("seal_to is not a sealing key")
}
var to [32]byte
copy(to[:], raw)
sealed, err := box.SealAnonymous(nil, []byte(*software), &to, rand.Reader)
if err != nil {
return nil, err
}
return map[string]any{"sealed": base64.StdEncoding.EncodeToString(sealed)}, nil
}
}
// issue is the controller's `secret issued` for one provider, carried as the controller carries it: open the
// vault's call, ask the provider's tool with the call's key, pass on what it sealed.
func issue(t *testing.T, v testVault, provider func(string, map[string]any) (map[string]any, error)) (Changed, error) {
t.Helper()
opened, err := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex")
if err != nil {
return Changed{}, err
}
answer, err := provider(ControllerCaller, map[string]any{"provision": "plex-api", "seal_to": opened.SealTo})
if err != nil {
return Changed{}, err
}
return v.IssuedTake(ControllerCaller, opened.Call, answer["sealed"].(string))
}
func TestAnIssuingProviderAnswersOnlyTheControllersCallSealedToTheVault(t *testing.T) {
v := newVault(t, AllowList{"anchor.node-provisioner": {"provision/anchor/plex-api"}})
software := "plex-token-from-the-vendor"
provider := standInProvider(&software)
// The provider's tool refuses every caller but the controller, whatever it asks.
for _, caller := range []string{"anchor.node-tools", "g14.claude-code", "unproven", ""} {
if _, err := provider(caller, map[string]any{"provision": "plex-api", "seal_to": "x"}); err == nil {
t.Errorf("the provider's tool answered %q", caller)
}
}
// The vault takes the steps from the controller alone.
if _, err := v.IssuedOpen("anchor.node-tools", "plex-api", "anchor", "plex"); err == nil {
t.Error("the vault opened an issued call for another caller")
}
changed, err := issue(t, v, provider)
if err != nil {
t.Fatal(err)
}
if changed.Name != "provision/anchor/plex-api" || changed.Generation != 1 || changed.By != "plex on anchor" {
t.Errorf("kept as %+v", changed)
}
if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software {
t.Errorf("the vault holds %q", got)
}
rec, _, _ := v.Store.Read("provision/anchor/plex-api")
if rec.Origin != OriginIssued || rec.ChangedVia != ControllerCaller {
t.Errorf("the record: origin %q via %q", rec.Origin, rec.ChangedVia)
}
// Nothing rewritten for a value every holder has (ADR 0305 §5): no new generation, no write, nothing said.
path := filepath.Join(v.dir, "store", "secrets", "provision%2Fanchor%2Fplex-api.json")
before, _ := os.ReadFile(path)
said := len(v.bus.raw)
again, err := issue(t, v, provider)
if err != nil {
t.Fatal(err)
}
after, _ := os.ReadFile(path)
if !again.Unchanged || again.Generation != 1 || string(before) != string(after) || len(v.bus.raw) != said {
t.Errorf("the same value again rewrote something: %+v", again)
}
// The software changes its value: the next ask keeps it as a new generation, and says so without it.
software = "plex-token-renewed"
renewed, err := issue(t, v, provider)
if err != nil || renewed.Generation != 2 {
t.Fatalf("%+v %v", renewed, err)
}
if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software {
t.Errorf("after the change the vault holds %q", got)
}
// A value sealed to any other key is refused, and nothing changes.
opened, _ := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex")
stranger, _, _ := box.GenerateKey(rand.Reader)
if _, err := v.IssuedTake(ControllerCaller, opened.Call, sealToCall(t, base64.StdEncoding.EncodeToString(stranger[:]), "forged")); err == nil {
t.Error("a value not sealed to the call was taken")
}
// The software rotates it, not the mesh (ADR 0305 §9); and no other path puts it into the vault (§10).
if _, err := v.Rotate(ControllerCaller, "provision/anchor/plex-api", "person.jochen"); err == nil ||
!strings.Contains(err.Error(), "software") {
t.Errorf("rotate of an issued value: %v", err)
}
if _, err := v.AcceptOpen(ControllerCaller, "provision/anchor/plex-api", "person.jochen"); err == nil {
t.Error("an issued value was taken by accept")
}
if strings.Contains(everything(t, v), "plex-token") {
t.Fatal("the issued value is in something the vault said or wrote in the clear")
}
}
// ---- accept and rotate -------------------------------------------------------------------------------------
// A value given at the controller's terminal is sealed there to the vault's call and opened here alone; an
// accepted provision's credential is never remade; a module's own given value rotates as a made one (ADR 0228).
func TestAnAcceptedValueOpensInTheVaultAloneAndIsNotRemade(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"provision/novox/postgres", "own/g14/dunst/token"}})
if _, err := v.AcceptOpen("g14.claude-code", "provision/novox/postgres", ""); err == nil {
t.Error("accept was opened for a caller that is not the controller")
}
for name, value := range map[string]string{"provision/novox/postgres": "pw-of-last-year", "own/g14/dunst/token": "tok"} {
opened, err := v.AcceptOpen(ControllerCaller, name, "jochen")
if err != nil {
t.Fatal(err)
}
changed, err := v.AcceptTake(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, value))
if err != nil {
t.Fatal(err)
}
if changed.By != "jochen" {
t.Errorf("%s accepted by %q", name, changed.By)
}
if got := handedTo(t, v, "g14.node-provisioner", name); got != value {
t.Errorf("%s holds %q", name, got)
}
rec, _, _ := v.Store.Read(name)
if rec.Origin != OriginAccepted || rec.ChangedVia != ControllerCaller {
t.Errorf("%s: origin %q via %q", name, rec.Origin, rec.ChangedVia)
}
}
if _, err := v.Rotate(ControllerCaller, "provision/novox/postgres", "jochen"); err == nil ||
!strings.Contains(err.Error(), "not remade") {
t.Errorf("rotate of an accepted credential: %v", err)
}
rotated, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen")
if err != nil || rotated.Generation != 2 {
t.Fatalf("rotate of a module's own given value: %+v %v", rotated, err)
}
if got := handedTo(t, v, "g14.node-provisioner", "own/g14/dunst/token"); got == "tok" || len(got) != 40 {
t.Errorf("rotated to %q", got)
}
if strings.Contains(everything(t, v), "pw-of-last-year") {
t.Fatal("an accepted value is in something the vault said or wrote in the clear")
}
}
// Rotate: refused for a secret not held and for an unproven caller; who asked is the proven caller, or the
// person the controller names when it carries the act — both kept.
func TestRotateKeepsWhoAskedAndWhoCarriedIt(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen"); err == nil {
t.Error("a secret not held was rotated")
}
if _, err := v.Mint("own/g14/dunst/token", "the test"); err != nil {
t.Fatal(err)
}
if _, err := v.Rotate("unproven", "own/g14/dunst/token", ""); err == nil {
t.Error("an unproven caller rotated")
}
if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen"); err != nil {
t.Fatal(err)
}
rec, _, _ := v.Store.Read("own/g14/dunst/token")
if rec.ChangedBy != "jochen" || rec.ChangedVia != ControllerCaller || rec.Generation != 2 {
t.Errorf("by %q via %q generation %d", rec.ChangedBy, rec.ChangedVia, rec.Generation)
}
// A `by` from any caller but the controller names nobody: the proven caller is who asked.
if _, err := v.Rotate("g14.claude-code", "own/g14/dunst/token", "jochen"); err != nil {
t.Fatal(err)
}
rec, _, _ = v.Store.Read("own/g14/dunst/token")
if rec.ChangedBy != "g14.claude-code" || rec.ChangedVia != "" {
t.Errorf("by %q via %q", rec.ChangedBy, rec.ChangedVia)
}
}
// The names the vault keeps them under are names, never paths.
func TestTheNamesOfWhatIsTakenIn(t *testing.T) {
if n, err := OwnSecret("g14", "dunst", "token"); err != nil || n != "own/g14/dunst/token" {
t.Errorf("%q %v", n, err)
}
if n, err := ProvisionSecret("novox", "postgres"); err != nil || n != "provision/novox/postgres" {
t.Errorf("%q %v", n, err)
}
for _, bad := range [][3]string{{"..", "x", "y"}, {"g14", "a/b", "y"}, {"g14", "x", ""}, {"G14", "x", "y"}} {
if _, err := OwnSecret(bad[0], bad[1], bad[2]); err == nil {
t.Errorf("%v was taken", bad)
}
}
}
// ---- the verbs as the runtime calls them ------------------------------------------------------------------
// Each verb takes its caller from the runtime, never from its arguments, and names its secret one way only.
func TestTheVerbsTakeTheirCallerFromTheRuntime(t *testing.T) {
v := newVault(t, nil)
env := &Env{Vault: v.Vault}
defs := definitions(env)
ask := toolNamed(t, defs, Seat+".secret-ask")
args := map[string]any{"node": "anchor", "module": "telegram", "name": "telegram-token", "desk": "laptop",
"asked_by": "g14.claude-code", "caller": ControllerCaller}
if _, err := ask.Run(args); err == nil {
t.Error("an ask opened for a caller named in the arguments")
}
answer, err := ask.runAs(ControllerCaller, args)
if err != nil {
t.Fatal(err)
}
opened := answer.(Opened)
if _, err := ask.runAs(ControllerCaller, map[string]any{"call": opened.Call, "outcome": "dismissed"}); err != nil {
t.Fatal(err)
}
listed, err := toolNamed(t, defs, "secret_asks").Run(map[string]any{})
if err != nil || len(listed.([]AskRecord)) != 2 {
t.Fatalf("secret_asks: %v %v", listed, err)
}
accept := toolNamed(t, defs, Seat+".accept")
if _, err := accept.runAs(ControllerCaller, map[string]any{"node": "g14", "provision": "postgres"}); err == nil {
t.Error("accept took a name both ways")
}
answer, err = accept.runAs(ControllerCaller, map[string]any{"provider": "novox", "provision": "postgres", "by": "jochen"})
if err != nil {
t.Fatal(err)
}
o := answer.(Opened)
if _, err := accept.runAs(ControllerCaller, map[string]any{"call": o.Call, "sealed": sealToCall(t, o.SealTo, "pw")}); err != nil {
t.Fatal(err)
}
rotate := toolNamed(t, defs, Seat+".rotate")
if _, err := rotate.runAs("g14.claude-code", map[string]any{"node": "g14", "module": "dunst", "name": "token"}); err == nil {
t.Error("a secret not held was rotated through the verb")
}
issued := toolNamed(t, defs, Seat+".issued")
answer, err = issued.runAs(ControllerCaller, map[string]any{"provision": "plex-api", "provider": "anchor", "module": "plex"})
if err != nil {
t.Fatal(err)
}
o = answer.(Opened)
if _, err := issued.runAs(ControllerCaller, map[string]any{"call": o.Call, "sealed": sealToCall(t, o.SealTo, "tok")}); err != nil {
t.Fatal(err)
}
broken := &Env{Broken: errors.New("no store")}
if _, err := toolNamed(t, definitions(broken), Seat+".rotate").runAs(ControllerCaller, map[string]any{}); err == nil ||
err.Error() != "no store" {
t.Errorf("a broken vault said %v", err)
}
}
+168
View File
@@ -0,0 +1,168 @@
package main
// The keys the vault meets, and the one it holds (novox/hq ADR 0113 §2, ADR 0206).
//
// - Its own key: an X25519 pair made once in its state directory, the private half never leaving it. Every
// secret is kept sealed to it at rest.
// - An asker's key: the public half only, as SPKI PEM — the form of the ADR 0206 box (mesh-sdk go/seal).
// A raw 32-byte key is refused, because a raw private key looks the same and the vault takes none.
// - The operator key: the public half, taken as SPKI PEM only and kept raw in base64, the form `operator key
// make` writes and the NaCl box the operator opens uses.
//
// No private key is ever accepted: not as a key to seal to, not as a secret to hold.
import (
"crypto/ecdh"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/seal"
"golang.org/x/crypto/nacl/box"
)
// OwnKeyFile is where the vault's own key lives in its state directory.
const OwnKeyFile = "vault-key.json"
// OwnKey is the vault's own key pair, made once in dir. Written whole and synced, then linked into place and
// the directory synced, so a second process reads the first's key and never half of it.
//
// **A key is made only for an empty store.** When the key file is missing and the store holds secrets, the
// key they are sealed to is lost or misplaced, and a new key would open none of them: the vault refuses to
// start, loudly, rather than seal new secrets to a key beside old ones nothing opens. The operator copies
// are what re-raise it (ADR 0113 §21).
func OwnKey(dir string, store *Store) (seal.KeyPair, error) {
if dir == "" {
return seal.KeyPair{}, errors.New("the vault has no state directory: MESH_VAULT_STATE is not set")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return seal.KeyPair{}, err
}
file := filepath.Join(dir, OwnKeyFile)
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
if store != nil {
empty, err := store.Empty()
if err != nil {
return seal.KeyPair{}, fmt.Errorf("the vault's key is missing and its store cannot be read: %w", err)
}
if !empty {
return seal.KeyPair{}, fmt.Errorf("THE VAULT'S KEY IS MISSING (%s) WHILE ITS STORE HOLDS SECRETS SEALED TO IT: "+
"the vault does not start and makes no new key, which would open none of them — put the key back, "+
"or re-raise the vault from the operator copies (novox/hq ADR 0113 §21)", file)
}
}
k, err := seal.GenerateKeyPair()
if err != nil {
return seal.KeyPair{}, err
}
raw, _ := json.Marshal(k)
tmp := filepath.Join(dir, fmt.Sprintf(".vault-key.%d.json", os.Getpid()))
if err := writeSynced(tmp, raw); err != nil {
return seal.KeyPair{}, err
}
_ = os.Link(tmp, file) // fails when another made it first, which is right
_ = os.Remove(tmp)
if err := syncDir(dir); err != nil {
return seal.KeyPair{}, err
}
}
raw, err := os.ReadFile(file)
if err != nil {
return seal.KeyPair{}, err
}
var k seal.KeyPair
if err := json.Unmarshal(raw, &k); err != nil || k.PublicKey == "" || k.PrivateKey == "" {
return seal.KeyPair{}, fmt.Errorf("%s is not the vault's key", file)
}
return k, nil
}
var privateKeyPEM = regexp.MustCompile(`-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----`)
// privateKeyIn says whether text carries a private key.
func privateKeyIn(text string) bool {
return privateKeyPEM.MatchString(text) || strings.Contains(text, "PRIVATE KEY")
}
// askerKey is an asker's public key, checked and as PEM: SPKI, X25519, and never a private key.
func askerKey(key string) (string, error) {
if privateKeyIn(key) {
return "", errors.New("that is a private key, and a private key is never sent to the vault (ADR 0113 §2): send the public half")
}
block, rest := pem.Decode([]byte(strings.TrimSpace(key)))
if block == nil || block.Type != "PUBLIC KEY" || strings.TrimSpace(string(rest)) != "" {
return "", errors.New("the key to seal to is one X25519 public key as SPKI PEM (`-----BEGIN PUBLIC KEY-----`), the form of the sealed box (ADR 0206)")
}
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("the key to seal to is not a public key: %w", err)
}
pub, ok := k.(*ecdh.PublicKey)
if !ok || pub.Curve() != ecdh.X25519() {
return "", errors.New("the key to seal to is not an X25519 key")
}
return string(pem.EncodeToMemory(block)), nil
}
// checkOperatorKey takes the operator's public key as SPKI PEM — never a private key, and never a raw key,
// which cannot be told from a private one — and gives it in the raw base64 form the operator's NaCl box and
// `secret recover` use.
func checkOperatorKey(key string) (string, error) {
if privateKeyIn(key) {
return "", errors.New("that is a private key: the operator key's private half never enters the mesh (ADR 0113 §2)")
}
block, rest := pem.Decode([]byte(strings.TrimSpace(key)))
if block == nil || block.Type != "PUBLIC KEY" || strings.TrimSpace(string(rest)) != "" {
return "", errors.New("the operator key is one X25519 public key as SPKI PEM (`-----BEGIN PUBLIC KEY-----`); " +
"a raw key is refused, because a raw private key looks the same")
}
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("the operator key is not a public key: %w", err)
}
pub, ok := k.(*ecdh.PublicKey)
if !ok || pub.Curve() != ecdh.X25519() {
return "", errors.New("the operator key is not an X25519 key")
}
return base64.StdEncoding.EncodeToString(pub.Bytes()), nil
}
// publicPEM wraps a raw X25519 public key, base64, as SPKI PEM.
func publicPEM(raw string) (string, error) {
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil || len(b) != 32 {
return "", errors.New("not a raw X25519 key in base64")
}
pub, err := ecdh.X25519().NewPublicKey(b)
if err != nil {
return "", err
}
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", err
}
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil
}
// operatorSeal seals value to the operator key in the box the operator opens today.
func operatorSeal(key string, value []byte) (string, error) {
raw, err := base64.StdEncoding.DecodeString(key)
if err != nil || len(raw) != 32 {
return "", errors.New("the operator key is not usable")
}
var pub [32]byte
copy(pub[:], raw)
sealed, err := box.SealAnonymous(nil, value, &pub, rand.Reader)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sealed), nil
}
+251
View File
@@ -0,0 +1,251 @@
package main
// The custody ledger (novox/hq ADR 0085, design 24), carried over from the TypeScript vault: for each pair
// credential the controller delivers to this vault as the `secret` provider, who holds it, since when, its
// fingerprint and every rotation — and never the value. The controller still mints and seals those until
// the secrets move to the vault (design 57's V3, V4); the pass records what it delivered, so
// `secret_holders` and `secret_verify` answer as before. Its files are the same JSON, read as they are.
//
// **A holder the mesh stops asking for is retired, never deleted by a pass** (novox/hq ADR 0230). The vault
// is a mark-only provider: it has no access to switch off, so retiring writes the mark — when and why — into
// the holder's own ledger file, which keeps its rotation history. A holder is retired only once the same set
// has gone unasked in five consecutive passes that read the contributions file and for ten minutes; a set
// over the bound (more than three, or more than half of those held) waits for a person; asked for again, a
// retired holder is re-enabled at once; a holder is deleted only by `provisioner_delete`, a person's act
// with a why. Because the mark is in the ledger file, a restart forgets nothing.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// isoMillis is JavaScript's toISOString, the form the ledger's times were first written in.
const isoMillis = "2006-01-02T15:04:05.000Z"
// Held is one holder of a secret this vault provides: everything the vault knows of it, and no value.
type Held struct {
As string `json:"as"`
Consumer string `json:"consumer"`
Fingerprint string `json:"fingerprint"`
Length int `json:"length"`
Since string `json:"since"`
Changed string `json:"changed"`
Rotations int `json:"rotations"`
History []Earlier `json:"history"`
// Retired is the mark a retirement writes; nil while the holder is active.
Retired *RetiredMark `json:"retired,omitempty"`
}
// RetiredMark is when and why a holder was retired.
type RetiredMark struct {
At string `json:"at"`
Why string `json:"why"`
}
// Earlier is a fingerprint a rotation replaced, and when.
type Earlier struct {
Fingerprint string `json:"fingerprint"`
Until string `json:"until"`
}
// Outcome is what recording a delivery found.
type Outcome string
const (
Granted Outcome = "granted"
Rotated Outcome = "rotated"
Unchanged Outcome = "unchanged"
)
// Fingerprint is sha256 of a value, as `sha256:<hex>`: the one thing about a secret that may be said.
func Fingerprint(value string) string {
sum := sha256.Sum256([]byte(value))
return "sha256:" + hex.EncodeToString(sum[:])
}
// Ledger is the directory of holders.
type Ledger struct{ dir string }
// NewLedger opens (and makes) the ledger at dir.
func NewLedger(dir string) (*Ledger, error) {
if dir == "" {
return nil, errors.New("MESH_VAULT_LEDGER is not set: the vault has nowhere to keep its ledger")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
return &Ledger{dir: dir}, nil
}
var login = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
func (l *Ledger) pathOf(as string) (string, error) {
if !login.MatchString(as) || strings.Contains(as, "..") {
return "", fmt.Errorf("a login is a name, not a path: %q", as)
}
return filepath.Join(l.dir, as+".json"), nil
}
// Record says the mesh delivered value for as: the same value again changes nothing, another is a rotation.
// The value is fingerprinted here and goes no further. A retirement mark is left as it is.
func (l *Ledger) Record(as, consumer, value string, now time.Time) (Held, Outcome, error) {
path, err := l.pathOf(as)
if err != nil {
return Held{}, "", err
}
fp, at := Fingerprint(value), now.UTC().Format(isoMillis)
before, ok := l.Get(as)
if !ok {
h := Held{As: as, Consumer: consumer, Fingerprint: fp, Length: len(value), Since: at, Changed: at, History: []Earlier{}}
return h, Granted, l.write(path, h)
}
if before.Fingerprint == fp && before.Length == len(value) {
return before, Unchanged, nil
}
h := before
h.Consumer, h.Fingerprint, h.Length, h.Changed = consumer, fp, len(value), at
h.Rotations++
h.History = append(append([]Earlier{}, before.History...), Earlier{Fingerprint: before.Fingerprint, Until: at})
return h, Rotated, l.write(path, h)
}
// Mark writes or clears a holder's retirement mark.
func (l *Ledger) Mark(as string, mark *RetiredMark) error {
path, err := l.pathOf(as)
if err != nil {
return err
}
h, ok := l.Get(as)
if !ok {
return fmt.Errorf("the ledger holds nothing for %s", as)
}
h.Retired = mark
return l.write(path, h)
}
// Delete forgets a holder, saying whether there was one. Only a person's `provisioner_delete` reaches it.
func (l *Ledger) Delete(as string) bool {
path, err := l.pathOf(as)
if err != nil {
return false
}
if os.Remove(path) != nil {
return false
}
_ = syncDir(l.dir)
return true
}
// Get is one holder.
func (l *Ledger) Get(as string) (Held, bool) {
path, err := l.pathOf(as)
if err != nil {
return Held{}, false
}
raw, err := os.ReadFile(path)
if err != nil {
return Held{}, false
}
var h Held
if json.Unmarshal(raw, &h) != nil {
return Held{}, false
}
return h, true
}
// List is every holder, by login.
func (l *Ledger) List() []Held {
entries, err := os.ReadDir(l.dir)
if err != nil {
return nil
}
var out []Held
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".json") {
if h, ok := l.Get(strings.TrimSuffix(e.Name(), ".json")); ok {
out = append(out, h)
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].As < out[j].As })
return out
}
func (l *Ledger) write(path string, h Held) error {
raw, err := json.MarshalIndent(h, "", " ")
if err != nil {
return err
}
return writeWhole(path, append(raw, '\n'))
}
// Contribution is one entry of the contributions file the mesh delivers to the vault.
type Contribution struct {
From string `json:"from,omitempty"`
Node string `json:"node,omitempty"`
As string `json:"as"`
Secret string `json:"secret"`
}
// Contributions is what the mesh asks this vault to serve: the entries with a login and a secret file.
//
// **A file that does not say who is asked for is not "nobody".** One that is unreadable, not JSON, without a
// `given` list (missing or null), or written for another requirement than `secret` is refused, so a pass
// over it retires nobody. An empty `given` list is an answer: nobody is asked for.
func Contributions(receives string) ([]Contribution, error) {
raw, err := os.ReadFile(receives)
if err != nil {
return nil, err
}
var doc struct {
Requirement *string `json:"requirement"`
Given json.RawMessage `json:"given"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("the contributions file is not JSON: %w", err)
}
if doc.Requirement != nil && *doc.Requirement != "secret" {
return nil, fmt.Errorf("the contributions file is for %q, not for secret", *doc.Requirement)
}
if len(doc.Given) == 0 || string(doc.Given) == "null" {
return nil, errors.New("the contributions file has no `given` list, so it says nothing of who is asked for")
}
var given []Contribution
if err := json.Unmarshal(doc.Given, &given); err != nil {
return nil, fmt.Errorf("the contributions file's `given` is not a list of contributions: %w", err)
}
var out []Contribution
for _, g := range given {
if g.As != "" && g.Secret != "" {
out = append(out, g)
}
}
return out, nil
}
// delivered is the value the mesh delivered for c, without the newline a file may end with.
func delivered(c Contribution) (string, error) {
raw, err := os.ReadFile(c.Secret)
if err != nil {
return "", fmt.Errorf("the delivered secret is not readable: %v", err)
}
return strings.TrimSuffix(string(raw), "\n"), nil
}
// DeliveredFingerprint is the fingerprint and length of what the mesh currently delivers for c.
func DeliveredFingerprint(c Contribution) (string, int, error) {
v, err := delivered(c)
if err != nil {
return "", 0, err
}
return Fingerprint(v), len(v), nil
}
@@ -0,0 +1,129 @@
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 TestDeletingForgetsAHolderAndListingIsByLogin(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.Delete("a-app") || l.Delete("a-app") {
t.Error("deleting 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)
}
}
func logins(hs []Held) string {
var out []string
for _, h := range hs {
out = append(out, h.As)
}
return strings.Join(out, ",")
}
+94
View File
@@ -0,0 +1,94 @@
// mesh-vault (novox/hq ADR 0085, ADR 0113, ADR 0349 §7 and §11): one binary, launched by its node's runtime
// and speaking MCP to it over stdio through the Go SDK. It holds the `mesh-vault` seat: it keeps every
// secret in its own store, sealed at rest and with a copy sealed to the operator key; it answers the seat's
// verb `get` with a secret sealed to the asker's key; it raises `secret-changed` without a value. Beside
// that it keeps the custody ledger of the pair credentials the controller still delivers to it.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
import (
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func say(format string, args ...any) {
fmt.Fprintf(os.Stderr, "[mesh-vault] "+format+"\n", args...)
}
func emit(event string, body map[string]any) error { return stdio.Emit(event, body) }
// build makes the vault from the module's words: directories, never values.
func build() *Env {
env := &Env{Receives: os.Getenv("MESH_RECEIVES"), Root: os.Getenv("MESH_VAULT_ROOT")}
if l, err := NewLedger(os.Getenv("MESH_VAULT_LEDGER")); err == nil {
env.Ledger = l
} else {
say("%v", err)
}
if env.Ledger != nil && env.Receives != "" {
env.Custody = &Custody{Ledger: env.Ledger, Receives: env.Receives, Resource: "secret", Node: os.Getenv("MESH_NODE"),
Emit: emit, Now: time.Now, Log: say}
}
store, err := OpenStore(os.Getenv("MESH_VAULT_STORE"))
if err != nil {
env.Broken = fmt.Errorf("the vault could not open its store: %w", err)
say("%v", env.Broken)
return env
}
own, err := OwnKey(os.Getenv("MESH_VAULT_STATE"), store)
if err != nil {
env.Broken = fmt.Errorf("the vault could not open its key: %w", err)
say("%v", env.Broken)
return env
}
// Askers is left nil: the vault that runs answers nobody until who may ask arrives (askers.go).
env.Vault = &Vault{Store: store, Own: own, Emit: emit, Now: time.Now, Log: say}
return env
}
// adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what
// `operator key set` recorded; the controller keeps only a public half there), and seals again any
// operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished.
func adoptOperatorKey(env *Env) {
if env.Vault == nil {
return
}
if raw := exportedOperatorKey(env.Root); env.Root != "" && raw != "" {
key, err := publicPEM(raw)
if err == nil {
_, err = env.Vault.SetOperatorKey(key, "the mesh's export")
}
env.refuseKey("")
if err != nil {
env.refuseKey(err.Error())
say("the operator key in the mesh's export is not taken, or not every copy is sealed to it yet: %v", err)
}
return
}
if _, err := env.Vault.Reseal(); err != nil {
say("%v", err)
}
}
func main() {
env := build()
go func() {
tick := time.NewTicker(time.Minute)
defer tick.Stop()
for {
adoptOperatorKey(env)
if env.Custody != nil {
env.Custody.Pass()
}
<-tick.C
}
}()
if err := stdio.Serve("", tools(env)); err != nil {
say("%v", err)
os.Exit(1)
}
}
@@ -0,0 +1,466 @@
package main
// Retirement of a holder the mesh stops asking for (novox/hq ADR 0230), in the words the controller reads:
// the `provisioner.retirement` event as mesh-controller's link.Retirement, the `provisioner_retirement`
// answer as link.RetirementState, and the acts as the SDK's harness takes them (mesh-sdk
// src/provisioner/index.ts) — the set repeated in `consumers`, a `why`, and the person's `by` and the
// `via` the controller's verbs say. testdata/retirement-*.json are those types marshalled by the controller
// at the commit its README names, and the tests hold this file's words to them.
//
// The vault is mark-only: it holds no access to switch off, so a retired holder keeps what it has until a
// person deletes it, and the mark — when and why — is in the holder's own ledger file, so a restart forgets
// nothing.
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
)
// EventRetirement is the one event a provider says about retirement (ADR 0230 §35).
const EventRetirement = "provisioner.retirement"
// The rules of ADR 0230 §15 and §18.
const (
StablePasses = 5
StableFor = 10 * time.Minute
RetireAtOnce = 3
RetireFraction = 0.5
SayAgainEvery = time.Hour
)
// RetiredConsumer is one holder in a retirement word or answer (link.RetiredConsumer).
type RetiredConsumer struct {
Consumer string `json:"consumer"`
Node string `json:"node,omitempty"`
RetiredAt string `json:"retired_at,omitempty"`
Why string `json:"why,omitempty"`
SizeBytes *int64 `json:"size_bytes,omitempty"`
Kind string `json:"kind,omitempty"`
Access string `json:"access,omitempty"`
}
// RetirementWord is the event's body (link.Retirement).
type RetirementWord struct {
Provider string `json:"provider"`
ProviderNode string `json:"provider-node"`
Change string `json:"change"`
Consumers []RetiredConsumer `json:"consumers"`
Held int `json:"held"`
Bound string `json:"bound,omitempty"`
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
Via string `json:"via,omitempty"`
At time.Time `json:"at"`
Since *time.Time `json:"since,omitempty"`
}
// RetirementWaiting, RetirementRejected and RetirementState are the answer to provisioner_retirement
// (link.RetirementWaiting, link.RetirementRejected, link.RetirementState).
type RetirementWaiting struct {
Consumers []RetiredConsumer `json:"consumers"`
Since string `json:"since"`
Held int `json:"held"`
}
type RetirementRejected struct {
Consumers []RetiredConsumer `json:"consumers"`
By string `json:"by"`
Why string `json:"why"`
At string `json:"at"`
}
type RetirementState struct {
Resource string `json:"resource"`
Node string `json:"node"`
Held []string `json:"held"`
StablePasses int `json:"stable_passes"`
StableForSeconds int `json:"stable_for_seconds,omitempty"`
RetiresBy string `json:"retires_by,omitempty"`
Bound string `json:"bound"`
Waiting *RetirementWaiting `json:"waiting"`
Rejected *RetirementRejected `json:"rejected"`
Retired []RetiredConsumer `json:"retired"`
}
// Act is a person's act on retirement: why, who (the person the controller proved) and through what.
type Act struct {
Why, By, Via string
}
// Custody is the pass over the contributions file that keeps the ledger, and what it remembers between
// passes about the holders the mesh no longer asks for.
type Custody struct {
Ledger *Ledger
Receives string
// Resource is the provision the vault provides; Node the machine it runs on.
Resource string
Node string
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
mu sync.Mutex
key string
count int
firstSeen time.Time
asked map[string]bool
read bool
waiting *heldSet
rejected *heldSet
}
type heldSet struct {
set []string
since time.Time
saidAt time.Time
held int
by, why string
}
func (c *Custody) resource() string {
if c.Resource == "" {
return "secret"
}
return c.Resource
}
// Pass records every delivered value, re-enables a retired holder asked for again, and counts the holders
// no longer asked for toward their retirement.
func (c *Custody) Pass() {
c.mu.Lock()
defer c.mu.Unlock()
asked, err := Contributions(c.Receives)
if err != nil {
c.Log("THE CONTRIBUTIONS FILE IS NOT USABLE (%s): %v — nothing is recorded or retired on this pass, and "+
"the count toward retiring anyone starts again", c.Receives, err)
c.key, c.count = "", 0
return
}
now := c.Now()
keep := map[string]bool{}
for _, a := range asked {
keep[a.As] = true
value, err := delivered(a)
if err != nil {
c.Log("%s: %v", a.As, err)
continue
}
held, outcome, err := c.Ledger.Record(a.As, a.Node, value, now)
if err != nil {
c.Log("%s: %v", a.As, err)
continue
}
if held.Retired != nil {
if err := c.Ledger.Mark(a.As, nil); err != nil {
c.Log("%s: asked for again, and its retirement mark could not be cleared: %v", a.As, err)
} else {
c.Log("%s: asked for again — re-enabled, its retirement mark cleared", a.As)
c.tell(RetirementWord{Change: "reenabled", Consumers: []RetiredConsumer{{Consumer: a.As, Node: a.Node}}}, now)
}
}
if outcome == Unchanged {
continue
}
event := "provisioned"
if outcome == Rotated {
event = "rotated"
}
c.Log("%s: %s (%s…, rotations %d)", held.As, outcome, held.Fingerprint[:19], held.Rotations)
c.say(event, map[string]any{"consumer": held.Consumer, "as": held.As, "fingerprint": held.Fingerprint, "rotations": held.Rotations})
}
c.asked, c.read = keep, true
c.countUnasked(now)
}
func (c *Custody) active() []Held {
var out []Held
for _, h := range c.Ledger.List() {
if h.Retired == nil {
out = append(out, h)
}
}
return out
}
func (c *Custody) countUnasked(now time.Time) {
active := c.active()
var unasked []string
for _, h := range active {
if !c.asked[h.As] {
unasked = append(unasked, h.As)
}
}
key := strings.Join(unasked, ",")
if c.waiting != nil && strings.Join(c.waiting.set, ",") != key {
c.settle(c.waiting, "the set the mesh no longer asks for changed", now)
c.waiting = nil
}
if c.rejected != nil && strings.Join(c.rejected.set, ",") != key {
c.settle(c.rejected, "the set the mesh no longer asks for changed", now)
c.rejected = nil
}
switch {
case len(unasked) == 0:
c.key, c.count = "", 0
return
case key != c.key:
c.key, c.count, c.firstSeen = key, 1, now
c.Log("no longer asked for: %s; retired once the same set holds for %d passes and %s (novox/hq ADR 0230)",
key, StablePasses, StableFor)
default:
c.count++
}
if c.rejected != nil || c.count < StablePasses || now.Sub(c.firstSeen) < StableFor {
return
}
if overTheBound(len(unasked), len(active)) {
c.wait(unasked, len(active), now)
return
}
c.retire(unasked, len(active), fmt.Sprintf("the mesh stopped asking for it: the same answer in %d passes over %s, from %s",
c.count, now.Sub(c.firstSeen).Round(time.Second), c.firstSeen.UTC().Format(time.RFC3339)), now)
}
func overTheBound(n, held int) bool {
return n > RetireAtOnce || (held > 1 && float64(n) > RetireFraction*float64(held))
}
func bound(held int) string {
return fmt.Sprintf("more than %d, or more than %d%% of the %d held", RetireAtOnce, int(RetireFraction*100), held)
}
func (c *Custody) wait(set []string, held int, now time.Time) {
if c.waiting == nil {
c.waiting = &heldSet{set: set, since: now, held: held, saidAt: now}
c.Log("RETIREMENT WAITS FOR A PERSON: the mesh no longer asks for %d of the %d holder(s) (%s), %s. "+
"Nothing is retired until `retire approve` or `retire reject` (novox/hq ADR 0230)",
len(set), held, strings.Join(set, ", "), bound(held))
} else if now.Sub(c.waiting.saidAt) < SayAgainEvery {
return
} else {
c.waiting.saidAt = now
}
since := c.waiting.since.UTC()
c.tell(RetirementWord{Change: "waiting", Consumers: c.named(set), Held: c.waiting.held, Since: &since}, now)
}
func (c *Custody) settle(s *heldSet, why string, now time.Time) {
c.Log("the set waiting on retirement (%s) is settled, nothing retired: %s", strings.Join(s.set, ", "), why)
c.tell(RetirementWord{Change: "settled", Consumers: c.named(s.set), Why: why}, now)
}
// retire marks each holder in set retired: the ledger keeps it, and its history, until a person deletes it.
func (c *Custody) retire(set []string, held int, why string, now time.Time) []string {
at := now.UTC().Format(isoMillis)
var done []string
for _, as := range set {
if err := c.Ledger.Mark(as, &RetiredMark{At: at, Why: why}); err != nil {
c.Log("%s: could not be marked retired, asked again once its set is stable again: %v", as, err)
continue
}
done = append(done, as)
}
c.key, c.count = "", 0
if len(done) == 0 {
return done
}
c.Log("%s: RETIRED, MARK ONLY — the vault holds no access to switch off, so each keeps what it holds until a "+
"person deletes it with `cleanup delete` (novox/hq ADR 0230): %s", strings.Join(done, ", "), why)
c.tell(RetirementWord{Change: "retired", Consumers: c.retiredOnes(done), Held: held, Why: why}, now)
return done
}
// named is each holder as the controller's words name it.
func (c *Custody) named(set []string) []RetiredConsumer {
out := make([]RetiredConsumer, 0, len(set))
for _, as := range set {
h, _ := c.Ledger.Get(as)
out = append(out, RetiredConsumer{Consumer: as, Node: h.Consumer})
}
return out
}
func (c *Custody) retiredOnes(set []string) []RetiredConsumer {
out := make([]RetiredConsumer, 0, len(set))
for _, as := range set {
h, _ := c.Ledger.Get(as)
out = append(out, retiredOne(h))
}
return out
}
func retiredOne(h Held) RetiredConsumer {
unknown := int64(-1)
r := RetiredConsumer{Consumer: h.As, Node: h.Consumer, SizeBytes: &unknown, Kind: "consumer", Access: "kept"}
if h.Retired != nil {
r.RetiredAt, r.Why = h.Retired.At, h.Retired.Why
}
return r
}
func sameSet(a, b []string) bool {
x, y := append([]string{}, a...), append([]string{}, b...)
sort.Strings(x)
sort.Strings(y)
return strings.Join(x, ",") == strings.Join(y, ",")
}
// Approve retires exactly the set waiting (or rejected) for a person, repeated in consumers; any other set
// is refused (ADR 0230 §20).
func (c *Custody) Approve(consumers []string, act Act) ([]string, error) {
if strings.TrimSpace(act.Why) == "" {
return nil, errors.New("approve: why is required")
}
c.mu.Lock()
defer c.mu.Unlock()
s := c.waiting
if s == nil {
s = c.rejected
}
if s == nil || !sameSet(s.set, consumers) {
return nil, fmt.Errorf("approve: nothing waits with exactly that set (%s); waiting: %s; rejected: %s. Look again",
strings.Join(consumers, ", "), setOf(c.waiting), setOf(c.rejected))
}
now := c.Now()
held := len(c.active())
c.Log("retirement of %s approved by %s: %s", strings.Join(s.set, ", "), someone(act.By), act.Why)
c.tell(RetirementWord{Change: "approved", Consumers: c.named(s.set), Held: held, Why: act.Why, By: act.By, Via: act.Via}, now)
set := s.set
c.waiting, c.rejected = nil, nil
return c.retire(set, held, "approved by "+someone(act.By)+": "+act.Why, now), nil
}
// Reject keeps the waiting set active, repeated in consumers, and asks no more about it while it stays the
// same set.
func (c *Custody) Reject(consumers []string, act Act) ([]string, error) {
if strings.TrimSpace(act.Why) == "" {
return nil, errors.New("reject: why is required")
}
c.mu.Lock()
defer c.mu.Unlock()
if c.waiting == nil || !sameSet(c.waiting.set, consumers) {
return nil, fmt.Errorf("reject: nothing waits with exactly that set (%s); waiting: %s. Look again",
strings.Join(consumers, ", "), setOf(c.waiting))
}
now := c.Now()
c.rejected = &heldSet{set: c.waiting.set, since: now, by: act.By, why: act.Why}
c.waiting = nil
c.Log("retirement of %s rejected by %s: %s; kept active while the mesh says the same",
strings.Join(c.rejected.set, ", "), someone(act.By), act.Why)
c.tell(RetirementWord{Change: "rejected", Consumers: c.named(c.rejected.set), Why: act.Why, By: act.By, Via: act.Via}, now)
return c.rejected.set, nil
}
// Delete forgets one retired holder on a person's word, its name repeated in confirm. Refused: before a
// pass has read the contributions file, a holder that is not retired, one the contributions file asks for
// now, and any act without a why or a matching confirm.
func (c *Custody) Delete(consumer, confirm string, act Act) error {
if strings.TrimSpace(act.Why) == "" {
return errors.New("delete: why is required")
}
if confirm != consumer {
return errors.New("delete: confirm must repeat the consumer's name")
}
c.mu.Lock()
defer c.mu.Unlock()
if !c.read {
return errors.New("delete: no pass has read the contributions file yet, so whether the mesh asks for it is not known; try again in a minute")
}
h, ok := c.Ledger.Get(consumer)
if !ok {
return fmt.Errorf("delete: %s is not held here", consumer)
}
if h.Retired == nil {
return fmt.Errorf("delete: %s is active, not retired — only a retired holder is deleted", consumer)
}
asked, err := Contributions(c.Receives)
if err != nil {
return fmt.Errorf("delete: the contributions file cannot say whether the mesh asks for %s: %v", consumer, err)
}
for _, a := range asked {
if a.As == consumer {
return fmt.Errorf("delete: the mesh asks for %s again; the next pass re-enables it", consumer)
}
}
if !c.Ledger.Delete(consumer) {
return fmt.Errorf("delete: %s could not be deleted", consumer)
}
c.Log("%s: DELETED from the ledger, asked by %s: %s", consumer, someone(act.By), act.Why)
unknown := int64(-1)
c.tell(RetirementWord{Change: "deleted", Consumers: []RetiredConsumer{{Consumer: consumer, Node: h.Consumer, SizeBytes: &unknown}},
Why: act.Why, By: act.By, Via: act.Via}, c.Now())
return nil
}
func someone(by string) string {
if by == "" {
return "someone"
}
return by
}
func setOf(s *heldSet) string {
if s == nil || len(s.set) == 0 {
return "none"
}
return strings.Join(s.set, ", ")
}
// Retirement is the answer to provisioner_retirement.
func (c *Custody) Retirement() RetirementState {
c.mu.Lock()
defer c.mu.Unlock()
st := RetirementState{Resource: c.resource(), Node: c.Node, Held: []string{}, StablePasses: StablePasses,
StableForSeconds: int(StableFor.Seconds()), RetiresBy: "mark-only", Retired: []RetiredConsumer{}}
for _, h := range c.Ledger.List() {
if h.Retired == nil {
st.Held = append(st.Held, h.As)
} else {
st.Retired = append(st.Retired, retiredOne(h))
}
}
st.Bound = bound(len(st.Held))
if c.waiting != nil {
st.Waiting = &RetirementWaiting{Consumers: c.named(c.waiting.set),
Since: c.waiting.since.UTC().Format(time.RFC3339), Held: c.waiting.held}
}
if c.rejected != nil {
st.Rejected = &RetirementRejected{Consumers: c.named(c.rejected.set), By: c.rejected.by, Why: c.rejected.why,
At: c.rejected.since.UTC().Format(time.RFC3339)}
}
return st
}
// tell says one retirement word, with the provider, its machine, the bound and the time filled in.
func (c *Custody) tell(w RetirementWord, now time.Time) {
w.Provider, w.ProviderNode, w.At = c.resource(), c.Node, now.UTC()
if w.Bound == "" {
w.Bound = bound(len(c.active()))
}
if w.Consumers == nil {
w.Consumers = []RetiredConsumer{}
}
c.say(EventRetirement, wordBody(w))
}
// wordBody is a word as the map the SDK's Emit takes, through its JSON, so the bus carries exactly the tags.
func wordBody(w RetirementWord) map[string]any {
raw, _ := json.Marshal(w)
var m map[string]any
_ = json.Unmarshal(raw, &m)
return m
}
func (c *Custody) say(event string, body map[string]any) {
if c.Emit == nil {
return
}
if err := c.Emit(event, body); err != nil {
c.Log("%s could not be said: %v", event, err)
}
}
@@ -0,0 +1,537 @@
package main
// ADR 0230 in the vault's custody ledger, and its words held to the controller's: testdata/retirement-*.json
// are mesh-controller's link.Retirement and link.RetirementState marshalled at dc36536 (feat/57, with #317).
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
)
// custodyAt is a custody pass over a contributions file in dir, on a clock the test moves.
type custodyAt struct {
*Custody
bus *published
receives string
dir string
clock *time.Time
}
func newCustody(t *testing.T) custodyAt {
dir := t.TempDir()
l, _ := NewLedger(filepath.Join(dir, "ledger"))
bus := &published{}
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
c := custodyAt{bus: bus, receives: filepath.Join(dir, "mesh.json"), dir: dir, clock: &clock}
c.Custody = &Custody{Ledger: l, Receives: c.receives, Resource: "secret", Node: "novox", Emit: bus.emit,
Now: func() time.Time { return *c.clock }, Log: t.Logf}
return c
}
// ask writes a contributions file asking for these logins, each with its value.
func (c custodyAt) ask(values map[string]string) {
given := []map[string]string{}
for as, v := range values {
f := filepath.Join(c.dir, as+".secret")
_ = os.WriteFile(f, []byte(v), 0o600)
given = append(given, map[string]string{"node": "anchor", "as": as, "secret": f})
}
doc, _ := json.Marshal(map[string]any{"requirement": "secret", "given": given})
_ = os.WriteFile(c.receives, doc, 0o600)
}
// apps is n holders named a-app, b-app, …
func apps(n int) map[string]string {
out := map[string]string{}
for i := 0; i < n; i++ {
out[fmt.Sprintf("%c-app", 'a'+i)] = fmt.Sprint(i)
}
return out
}
func keepOnly(all map[string]string, n int) map[string]string {
names := make([]string, 0, len(all))
for k := range all {
names = append(names, k)
}
sort.Strings(names)
out := map[string]string{}
for _, k := range names[:n] {
out[k] = all[k]
}
return out
}
// passes runs n passes a minute apart.
func (c custodyAt) passes(n int) {
for i := 0; i < n; i++ {
c.Pass()
*c.clock = c.clock.Add(time.Minute)
}
}
// stableFor runs StablePasses passes over an hour: past both of ADR 0230's conditions.
func (c custodyAt) stable() {
for i := 0; i < StablePasses; i++ {
c.Pass()
*c.clock = c.clock.Add(15 * time.Minute)
}
}
func (c custodyAt) changes() []string {
var out []string
for _, e := range c.bus.events {
if e["event"] == EventRetirement {
out = append(out, e["body"].(map[string]any)["change"].(string))
} else {
out = append(out, e["event"].(string))
}
}
return out
}
func (c custodyAt) retired(as string) bool {
h, _ := c.Ledger.Get(as)
return h.Retired != nil
}
func waitingNames(st RetirementState) []string {
if st.Waiting == nil {
return nil
}
var out []string
for _, w := range st.Waiting.Consumers {
out = append(out, w.Consumer)
}
return out
}
func act(why string) Act { return Act{Why: why, By: "person.jochen", Via: "mesh-controller"} }
// ---- the controller's words ----------------------------------------------------------------------------
// paths is every key path in a JSON document, arrays read through their first element.
func paths(t *testing.T, raw []byte) map[string]bool {
t.Helper()
var v any
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatal(err)
}
out := map[string]bool{}
var walk func(prefix string, v any)
walk = func(prefix string, v any) {
switch x := v.(type) {
case map[string]any:
for k, e := range x {
out[prefix+k] = true
walk(prefix+k+".", e)
}
case []any:
if len(x) > 0 {
walk(prefix, x[0])
}
}
}
walk("", v)
return out
}
// sameWords says every key the vault says is a key the controller's type has, and every key the controller
// needs is said.
func sameWords(t *testing.T, what string, ours []byte, golden string, needed ...string) {
t.Helper()
theirs, err := os.ReadFile(filepath.Join("testdata", golden))
if err != nil {
t.Fatal(err)
}
want := paths(t, theirs)
got := paths(t, ours)
for p := range got {
if !want[p] {
t.Errorf("%s says %q, which the controller's type has no field for", what, p)
}
}
for _, p := range needed {
if !got[p] {
t.Errorf("%s does not say %q, which the controller reads", what, p)
}
}
// And decoding strictly into the shape of the controller's golden document's own Go type would succeed:
// the same kinds at the same keys.
var g, o any
_ = json.Unmarshal(theirs, &g)
_ = json.Unmarshal(ours, &o)
sameKinds(t, what, "", g, o)
}
func sameKinds(t *testing.T, what, at string, want, got any) {
switch w := want.(type) {
case map[string]any:
g, ok := got.(map[string]any)
if !ok {
if got != nil {
t.Errorf("%s: %q is %T, the controller's is an object", what, at, got)
}
return
}
for k, v := range g {
if wv, known := w[k]; known {
sameKinds(t, what, at+"."+k, wv, v)
}
}
case []any:
g, ok := got.([]any)
if !ok {
if got != nil {
t.Errorf("%s: %q is %T, the controller's is a list", what, at, got)
}
return
}
if len(w) > 0 {
for _, e := range g {
sameKinds(t, what, at+"[]", w[0], e)
}
}
default:
if got != nil && fmt.Sprintf("%T", want) != fmt.Sprintf("%T", got) {
t.Errorf("%s: %q is %T, the controller's is %T", what, at, got, want)
}
}
}
// Every retirement word the vault says is one the controller's link.ReadRetirement reads: its machine
// named, its change known, its consumers objects.
func TestTheRetirementWordsAreTheControllers(t *testing.T) {
c := newCustody(t)
all := apps(5)
c.ask(all)
c.passes(1)
c.ask(keepOnly(all, 1))
c.stable() // waiting: four of five
st := c.Retirement()
if _, err := c.Reject(waitingNames(st), act("kept for now")); err != nil {
t.Fatal(err)
}
if _, err := c.Approve(waitingNames(st), act("they are gone")); err != nil {
t.Fatal(err)
}
c.passes(1)
if err := c.Delete("b-app", "b-app", act("gone for good")); err != nil {
t.Fatal(err)
}
c.ask(all)
c.passes(1) // re-enabled
seen := map[string]bool{}
for i, e := range c.bus.events {
if e["event"] != EventRetirement {
continue
}
raw := []byte(c.bus.raw[i])
var outer struct {
Body json.RawMessage `json:"body"`
}
_ = json.Unmarshal(raw, &outer)
var w struct {
Change string `json:"change"`
ProviderNode string `json:"provider-node"`
Consumers []struct {
Consumer string `json:"consumer"`
} `json:"consumers"`
}
dec := json.NewDecoder(bytes.NewReader(outer.Body))
if err := dec.Decode(&w); err != nil {
t.Fatalf("a retirement word the controller cannot read: %v: %s", err, outer.Body)
}
if w.ProviderNode != "novox" || len(w.Consumers) == 0 || w.Consumers[0].Consumer == "" {
t.Errorf("a %s word names no machine or no consumer: %s", w.Change, outer.Body)
}
seen[w.Change] = true
sameWords(t, "the "+w.Change+" word", outer.Body, "retirement-word.json",
"provider", "provider-node", "change", "consumers", "consumers.consumer", "at")
}
for _, change := range []string{"waiting", "rejected", "approved", "retired", "deleted", "reenabled"} {
if !seen[change] {
t.Errorf("no %s word was said", change)
}
}
}
// The answer to provisioner_retirement is the controller's RetirementState, so `retire approve`, `retire
// reject` and `cleanup list` read the vault.
func TestTheRetirementAnswerIsTheControllers(t *testing.T) {
c := newCustody(t)
all := apps(5)
c.ask(all)
c.passes(1)
c.ask(keepOnly(all, 1))
c.stable()
raw, _ := json.Marshal(c.Retirement())
sameWords(t, "the waiting answer", raw, "retirement-state.json", "resource", "node", "held", "stable_passes",
"retires_by", "bound", "waiting", "waiting.consumers", "waiting.consumers.consumer", "waiting.since", "retired")
if _, err := c.Approve(waitingNames(c.Retirement()), act("gone")); err != nil {
t.Fatal(err)
}
raw, _ = json.Marshal(c.Retirement())
sameWords(t, "the retired answer", raw, "retirement-state.json", "retired.consumer", "retired.retired_at",
"retired.why", "retired.access")
st := c.Retirement()
if st.RetiresBy != "mark-only" || st.Resource != "secret" || st.Node != "novox" || len(st.Retired) != 4 ||
st.Retired[0].Access != "kept" {
t.Errorf("the answer is %+v", st)
}
}
// ---- the rules, one at a time ---------------------------------------------------------------------------
func TestTheCustodyPassRecordsAndRotates(t *testing.T) {
c := newCustody(t)
c.ask(map[string]string{"anchor-redis": "v1"})
c.passes(1)
c.ask(map[string]string{"anchor-redis": "v2"})
c.passes(1)
if got := strings.Join(c.changes(), ","); got != "provisioned,rotated" {
t.Errorf("announced %s", got)
}
for _, raw := range c.bus.raw {
if strings.Contains(raw, `"v1"`) || strings.Contains(raw, `"v2"`) {
t.Errorf("announced a value: %s", raw)
}
}
}
// A contributions file that does not say who is asked for is no answer: nobody is retired.
func TestAFileThatSaysNothingRetiresNobody(t *testing.T) {
for _, doc := range []string{"{not json", `{"requirement":"secret"}`, `{"requirement":"secret","given":null}`,
`{"requirement":"redis-cache","given":[]}`} {
c := newCustody(t)
c.ask(map[string]string{"anchor-redis": "v1"})
c.passes(1)
_ = os.WriteFile(c.receives, []byte(doc), 0o600)
c.passes(30)
if h, ok := c.Ledger.Get("anchor-redis"); !ok || h.Retired != nil {
t.Errorf("%s: the holder was retired or lost: %+v %v", doc, h, ok)
}
if _, err := Contributions(c.receives); err == nil {
t.Errorf("%s was read as an answer", doc)
}
}
if _, err := Contributions(filepath.Join(t.TempDir(), "missing.json")); err == nil {
t.Error("a missing file was read as an answer")
}
}
// Five passes, not four, however much time has passed.
func TestRetiringTakesFivePasses(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
for i := 0; i < StablePasses-1; i++ {
c.Pass()
*c.clock = c.clock.Add(time.Hour)
}
if c.retired("c-app") {
t.Fatal("retired after four passes")
}
c.Pass()
if !c.retired("c-app") {
t.Fatal("not retired after five passes and hours")
}
}
// Ten minutes, not less, however many passes.
func TestRetiringTakesTenMinutes(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
for i := 0; i < 20; i++ {
c.Pass()
*c.clock = c.clock.Add(25 * time.Second)
}
if c.retired("c-app") {
t.Fatal("retired before ten minutes")
}
*c.clock = c.clock.Add(5 * time.Minute)
c.Pass()
if !c.retired("c-app") {
t.Fatal("not retired after twenty passes and ten minutes")
}
}
// More than three at once waits, though under half: 4 of 10.
func TestMoreThanThreeWaits(t *testing.T) {
c := newCustody(t)
c.ask(apps(10))
c.passes(1)
c.ask(keepOnly(apps(10), 6))
c.stable()
if got := waitingNames(c.Retirement()); len(got) != 4 {
t.Fatalf("four of ten did not wait: %v", got)
}
if c.retired("j-app") {
t.Fatal("retired while waiting")
}
}
// More than half waits, though three or fewer: 2 of 3.
func TestMoreThanHalfWaits(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 1))
c.stable()
if got := waitingNames(c.Retirement()); len(got) != 2 {
t.Fatalf("two of three did not wait: %v", got)
}
}
// Within both bounds it retires without a person: 3 of 7.
func TestWithinTheBoundRetires(t *testing.T) {
c := newCustody(t)
c.ask(apps(7))
c.passes(1)
c.ask(keepOnly(apps(7), 4))
c.stable()
if c.Retirement().Waiting != nil || !c.retired("g-app") {
t.Fatal("three of seven did not retire")
}
}
// A holder is retired, never deleted by a pass, keeps its history, and is re-enabled when asked again.
func TestAHolderNoLongerAskedForIsRetiredNotDeleted(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
c.stable()
h, ok := c.Ledger.Get("c-app")
if !ok || h.Retired == nil || h.Retired.Why == "" || h.Fingerprint != Fingerprint("2") {
t.Fatalf("%+v %v", h, ok)
}
c.ask(apps(3))
c.Pass()
if c.retired("c-app") {
t.Error("asked for again, the holder stayed retired")
}
if got := strings.Join(c.changes(), ","); got != "provisioned,provisioned,provisioned,retired,reenabled" {
t.Errorf("announced %s", got)
}
}
// A file that says nothing starts the count again.
func TestTheCountStartsAgain(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
c.passes(4)
_ = os.WriteFile(c.receives, []byte(`{"given":null}`), 0o600)
c.passes(1)
c.ask(keepOnly(apps(3), 2))
c.passes(4)
*c.clock = c.clock.Add(time.Hour)
if c.retired("c-app") {
t.Fatal("retired though the count was broken")
}
c.passes(1)
if !c.retired("c-app") {
t.Fatal("not retired after five unbroken passes and an hour")
}
}
// Approve and reject take exactly the set waiting, and refuse any other (ADR 0230 §20).
func TestApproveAndRejectTakeExactlyTheSetWaiting(t *testing.T) {
for _, decide := range []string{"approve", "reject"} {
c := newCustody(t)
c.ask(apps(5))
c.passes(1)
c.ask(keepOnly(apps(5), 1))
c.stable()
set := waitingNames(c.Retirement())
for _, wrong := range [][]string{set[:3], append(append([]string{}, set...), "a-app"), {}} {
if _, err := c.Approve(wrong, act("x")); err == nil {
t.Errorf("approve took the set %v", wrong)
}
if _, err := c.Reject(wrong, act("x")); err == nil {
t.Errorf("reject took the set %v", wrong)
}
}
if _, err := c.Approve(set, Act{}); err == nil {
t.Error("an approval without a why was taken")
}
reversed := append([]string{}, set...)
sort.Sort(sort.Reverse(sort.StringSlice(reversed)))
if decide == "approve" {
done, err := c.Approve(reversed, act("the four apps are gone"))
if err != nil || len(done) != 4 || !c.retired("b-app") || c.retired("a-app") {
t.Fatalf("%v %v", done, err)
}
} else {
if _, err := c.Reject(reversed, act("they come back")); err != nil {
t.Fatal(err)
}
c.passes(10)
if c.retired("b-app") || c.Retirement().Waiting != nil {
t.Error("a rejected set was retired or asked about again")
}
}
}
}
// retiredC is a custody with c-app retired, d-app active.
func retiredC(t *testing.T) custodyAt {
c := newCustody(t)
c.ask(apps(4))
c.passes(1)
c.ask(keepOnly(apps(4), 3))
c.stable()
if !c.retired("d-app") {
t.Fatal("d-app was not retired")
}
return c
}
// Each refusal of a delete, one at a time.
func TestEachDeleteRefusal(t *testing.T) {
fresh := newCustody(t)
fresh.ask(apps(1))
if err := fresh.Delete("a-app", "a-app", act("x")); err == nil || !strings.Contains(err.Error(), "no pass") {
t.Errorf("a delete before any pass read the file: %v", err)
}
c := retiredC(t)
if err := c.Delete("a-app", "a-app", act("x")); err == nil || !strings.Contains(err.Error(), "active") {
t.Errorf("an active holder: %v", err)
}
if err := c.Delete("d-app", "d-app", Act{By: "person.jochen"}); err == nil || !strings.Contains(err.Error(), "why") {
t.Errorf("no why: %v", err)
}
if err := c.Delete("d-app", "c-app", act("x")); err == nil || !strings.Contains(err.Error(), "confirm") {
t.Errorf("a confirm naming another: %v", err)
}
if err := c.Delete("nobody", "nobody", act("x")); err == nil {
t.Error("a holder not held")
}
c.ask(apps(4)) // asked for again, no pass yet
if err := c.Delete("d-app", "d-app", act("x")); err == nil || !strings.Contains(err.Error(), "asks for") {
t.Errorf("a holder the file asks for again: %v", err)
}
c.ask(keepOnly(apps(4), 3))
if err := c.Delete("d-app", "d-app", act("gone for good")); err != nil {
t.Fatal(err)
}
if _, ok := c.Ledger.Get("d-app"); ok {
t.Error("deleted, but still in the ledger")
}
last := c.bus.events[len(c.bus.events)-1]["body"].(map[string]any)
if last["change"] != "deleted" || last["by"] != "person.jochen" || last["via"] != "mesh-controller" {
t.Errorf("the deletion said %v", last)
}
}
+440
View File
@@ -0,0 +1,440 @@
package main
// The vault's own store (novox/hq ADR 0349 §7, ADR 0113 §22): one file per secret on the vault's own disk,
// every value in it sealed — once to the vault's own key, the copy the vault opens to answer `get`, and once
// to the operator key, the copy the operator opens when the vault is lost (ADR 0113 §21). No database and no
// bucket: the vault is installed before any other module (ADR 0113 §14), a database login would be a
// secret only the vault makes, and a bucket holds no secret, sealed or not (ADR 0201 §13).
//
// Each file is written whole and renamed into place, so a reader never sees half a record.
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// OperatorBox names the box an operator copy is sealed in: the NaCl anonymous box the operator key is made
// for today (`operator key make`) and `secret recover --from-export` opens.
const OperatorBox = "nacl-anonymous-box"
// Record is one secret as the store keeps it: never its value.
type Record struct {
Name string `json:"name"`
Generation int `json:"generation"`
// AtRest is the value sealed to the vault's own key (the ADR 0206 box).
AtRest seal.Box `json:"at-rest"`
// Operator is the value sealed to the operator key.
Operator OperatorCopy `json:"operator"`
MadeAt time.Time `json:"made-at"`
ChangedAt time.Time `json:"changed-at"`
ChangedBy string `json:"changed-by"`
// ChangedVia is the caller the runtime proved, when it is not who ChangedBy names: the controller
// carrying a person's act (design 57's V2). Empty when they are the same.
ChangedVia string `json:"changed-via,omitempty"`
// Origin is where the value came from: made by the vault, accepted at the controller's terminal, typed at
// a desk, or issued by a provider's software (ADR 0349 clauses 8 to 10). Empty is made, as V1 wrote it.
Origin string `json:"origin,omitempty"`
}
// OperatorCopy is a value sealed to the operator key named in Key.
type OperatorCopy struct {
Box string `json:"box"`
Key string `json:"key"`
Sealed string `json:"sealed"`
}
// Store is the vault's directory of secrets.
type Store struct {
dir string
// failWrite, when a test sets it, fails the write of a record: how a failure partway is proven.
failWrite func(Record) error
// HandedMax is the size at which the record of hand-outs is rolled (0: HandedMaxDefault).
HandedMax int64
handedMu sync.Mutex
rolled int
// asksMu serialises the record of asks, so two asks for one secret at once do not both pass its bounds.
asksMu sync.Mutex
}
// The record of hand-outs is bounded: rolled at HandedMaxDefault into handed.jsonl.1 … .HandedKept, the
// oldest dropped, so a flood of asks costs at most (HandedKept+1) × HandedMaxDefault on the vault's disk.
const (
HandedMaxDefault = 1 << 20
HandedKept = 3
)
// OpenStore opens (and makes) the store at dir.
func OpenStore(dir string) (*Store, error) {
if dir == "" {
return nil, errors.New("the vault's store has no directory: MESH_VAULT_STORE is not set")
}
if err := os.MkdirAll(filepath.Join(dir, "secrets"), 0o700); err != nil {
return nil, err
}
return &Store{dir: dir}, nil
}
var secretName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$`)
// validName says why name is not a secret's name, or nil. A name is words joined by `/`, never a path.
func validName(name string) error {
if len(name) == 0 || len(name) > 200 || !secretName.MatchString(name) {
return fmt.Errorf("%q is not a secret's name: lower-case words joined by /, at most 200 characters", name)
}
return nil
}
func (s *Store) pathOf(name string) string {
return filepath.Join(s.dir, "secrets", url.PathEscape(name)+".json")
}
// Read is the record of one secret, and whether there is one.
func (s *Store) Read(name string) (Record, bool, error) {
if err := validName(name); err != nil {
return Record{}, false, err
}
raw, err := os.ReadFile(s.pathOf(name))
if errors.Is(err, os.ErrNotExist) {
return Record{}, false, nil
}
if err != nil {
return Record{}, false, err
}
var r Record
if err := json.Unmarshal(raw, &r); err != nil {
return Record{}, false, fmt.Errorf("the record of %s is not readable: %w", name, err)
}
return r, true, nil
}
// Write keeps one record, replacing any earlier one.
func (s *Store) Write(r Record) error {
if err := validName(r.Name); err != nil {
return err
}
if s.failWrite != nil {
if err := s.failWrite(r); err != nil {
return err
}
}
raw, err := json.MarshalIndent(r, "", " ")
if err != nil {
return err
}
return writeWhole(s.pathOf(r.Name), append(raw, '\n'))
}
// Empty says whether the store holds no secret at all.
func (s *Store) Empty() (bool, error) {
all, err := s.List()
return len(all) == 0, err
}
// Handing is one answer to `get`, or one refusal, as the store records it (as ADR 0277 §25 records asks):
// who asked, for what, when, and what it got — never the value.
type Handing struct {
At time.Time `json:"at"`
To string `json:"to"`
Name string `json:"name"`
Generation int `json:"generation,omitempty"`
Outcome string `json:"outcome"`
Why string `json:"why,omitempty"`
}
// RecordHanding appends one handing to the store's record, synced before it returns. It says whether the
// record was rolled to make room.
func (s *Store) RecordHanding(h Handing) (rolled bool, err error) {
raw, err := json.Marshal(h)
if err != nil {
return false, err
}
s.handedMu.Lock()
defer s.handedMu.Unlock()
path := filepath.Join(s.dir, "handed.jsonl")
max := s.HandedBound()
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > max {
if err := s.roll(path); err != nil {
return false, err
}
s.rolled++
rolled = true
}
return rolled, appendSynced(path, append(raw, '\n'))
}
// roll moves handed.jsonl to .1, .1 to .2 and so on, dropping what falls past HandedKept.
func (s *Store) roll(path string) error {
_ = os.Remove(fmt.Sprintf("%s.%d", path, HandedKept))
for i := HandedKept - 1; i >= 1; i-- {
if err := os.Rename(fmt.Sprintf("%s.%d", path, i), fmt.Sprintf("%s.%d", path, i+1)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
if err := os.Rename(path, path+".1"); err != nil {
return err
}
return syncDir(s.dir)
}
// Rolled is how many times the record of hand-outs was rolled since the vault started.
func (s *Store) Rolled() int {
s.handedMu.Lock()
defer s.handedMu.Unlock()
return s.rolled
}
func appendSynced(path string, raw []byte) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
if _, err := f.Write(raw); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
return f.Close()
}
// Handed is the latest handings, newest first, at most limit: the current record, then the rolled ones.
func (s *Store) Handed(limit int) ([]Handing, error) {
s.handedMu.Lock()
defer s.handedMu.Unlock()
path := filepath.Join(s.dir, "handed.jsonl")
out := []Handing{}
for i := 0; i <= HandedKept && len(out) < limit; i++ {
file := path
if i > 0 {
file = fmt.Sprintf("%s.%d", path, i)
}
raw, err := os.ReadFile(file)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
for j := len(lines) - 1; j >= 0 && len(out) < limit; j-- {
var h Handing
if json.Unmarshal([]byte(lines[j]), &h) == nil {
out = append(out, h)
}
}
}
return out, nil
}
// HandedBound is the size at which the record of hand-outs is rolled.
func (s *Store) HandedBound() int64 {
if s.HandedMax > 0 {
return s.HandedMax
}
return HandedMaxDefault
}
// List is every record, by name.
func (s *Store) List() ([]Record, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, "secrets"))
if err != nil {
return nil, err
}
var out []Record
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".json") {
continue
}
name, err := url.PathUnescape(strings.TrimSuffix(e.Name(), ".json"))
if err != nil {
continue
}
r, ok, err := s.Read(name)
if err != nil {
return nil, err
}
if ok {
out = append(out, r)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
type operatorFile struct {
Key string `json:"key"`
Since time.Time `json:"since"`
By string `json:"by"`
}
// OperatorKey is the operator's public key the store seals to, or "" when it has none.
func (s *Store) OperatorKey() (string, error) {
raw, err := os.ReadFile(filepath.Join(s.dir, "operator.json"))
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
var f operatorFile
if err := json.Unmarshal(raw, &f); err != nil {
return "", err
}
return f.Key, nil
}
func (s *Store) setOperatorKey(key, by string, at time.Time) error {
raw, _ := json.MarshalIndent(operatorFile{Key: key, Since: at, By: by}, "", " ")
return writeWhole(filepath.Join(s.dir, "operator.json"), append(raw, '\n'))
}
// writeWhole writes a file readable by the vault alone, synced, then renames it into place and syncs the
// directory, so a crash leaves the old file or the new one, never neither.
func writeWhole(path string, raw []byte) error {
tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
if err := writeSynced(tmp, raw); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return syncDir(filepath.Dir(path))
}
// writeSynced writes a new file at 0600 and syncs it to disk.
func writeSynced(path string, raw []byte) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(raw); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
return f.Close()
}
// syncDir syncs a directory, so a rename or a link in it is on disk.
func syncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
// The two lines an ask leaves in the record: when it opened, and how it ended.
const (
AskOpened = "opened"
AskEnded = "ended"
)
// AskRecord is one line of the record of asks for a module's own secret at a desk (ADR 0277 §25, in the vault's
// store since ADR 0349 clause 9): who asked, as the bus named them, for what, at which desk, and how it ended.
// Never a value.
type AskRecord struct {
ID string `json:"id"`
At time.Time `json:"at"`
Event string `json:"event"`
Secret string `json:"secret"`
Node string `json:"node,omitempty"`
Module string `json:"module,omitempty"`
Name string `json:"name,omitempty"`
Desk string `json:"desk,omitempty"`
AskedBy string `json:"asked_by,omitempty"`
Caller string `json:"caller,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
// RecordAsk appends one line to the record of asks, synced before it returns; rolled as the record of
// hand-outs is, and it says whether it was.
func (s *Store) RecordAsk(r AskRecord) (rolled bool, err error) {
raw, err := json.Marshal(r)
if err != nil {
return false, err
}
path := filepath.Join(s.dir, "asks.jsonl")
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > s.HandedBound() {
if err := s.roll(path); err != nil {
return false, err
}
rolled = true
}
return rolled, appendSynced(path, append(raw, '\n'))
}
// asks is every line of the record of asks, oldest first, the rolled files before the current one.
func (s *Store) asks() ([]AskRecord, error) {
path := filepath.Join(s.dir, "asks.jsonl")
var out []AskRecord
for i := HandedKept; i >= 0; i-- {
file := path
if i > 0 {
file = fmt.Sprintf("%s.%d", path, i)
}
raw, err := os.ReadFile(file)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
var r AskRecord
if line != "" && json.Unmarshal([]byte(line), &r) == nil {
out = append(out, r)
}
}
}
return out, nil
}
// AsksSince is every line of the record of asks at or after since, oldest first.
func (s *Store) AsksSince(since time.Time) ([]AskRecord, error) {
all, err := s.asks()
if err != nil {
return nil, err
}
var out []AskRecord
for _, r := range all {
if !r.At.Before(since) {
out = append(out, r)
}
}
return out, nil
}
// Asks is the latest lines of the record of asks, newest first, at most limit.
func (s *Store) Asks(limit int) ([]AskRecord, error) {
s.asksMu.Lock()
defer s.asksMu.Unlock()
all, err := s.asks()
if err != nil {
return nil, err
}
out := []AskRecord{}
for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
out = append(out, all[i])
}
return out, nil
}
@@ -0,0 +1,56 @@
{
"resource": "secret",
"node": "novox",
"held": [
"a"
],
"held_sizes": {
"a": 1
},
"stable_passes": 5,
"stable_for_seconds": 600,
"retires_by": "mark-only",
"bound": "b",
"waiting": {
"consumers": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
],
"since": "t",
"held": 1
},
"rejected": {
"consumers": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
],
"by": "b",
"why": "w",
"at": "t"
},
"retired": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
]
}
@@ -0,0 +1,23 @@
{
"provider": "secret",
"provider-node": "novox",
"change": "waiting",
"consumers": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
],
"held": 1,
"bound": "b",
"why": "w",
"by": "person.x",
"via": "mesh-controller",
"at": "2026-10-14T09:00:00Z",
"since": "2026-10-14T09:00:00Z"
}
+544
View File
@@ -0,0 +1,544 @@
package main
// What the vault serves: the seat's verb `get`, and the three tools the TypeScript vault offered —
// secret_export, secret_holders and secret_verify — answering as they did; beside them secret_handed and
// secret_vault_export over the vault's own store, and ADR 0230's four retirement tools over the custody
// ledger, and vault_status. None returns a value: `get` returns one sealed to the asker's key, the others
// fingerprints and ciphertext only.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Env is what the tools work on. A part the module's words did not give is nil, and a tool that needs it
// says so.
type Env struct {
Vault *Vault
Ledger *Ledger
Receives string
Root string
Custody *Custody
// refused is why the operator key in the mesh's export was not taken, or empty.
mu sync.Mutex
refused string
// Broken is why the vault could not be built, said by `get`.
Broken error
}
func (e *Env) keyRefused() string {
e.mu.Lock()
defer e.mu.Unlock()
return e.refused
}
func (e *Env) refuseKey(why string) {
e.mu.Lock()
defer e.mu.Unlock()
e.refused = why
}
// toolDef is a tool with its caller given apart from its arguments, so a test can be the runtime.
type toolDef struct {
Name string
Description string
Input map[string]any
run func(caller string, a map[string]any) (any, error)
}
// Run answers a call the runtime made: the caller is the one it proved.
func (t toolDef) Run(a map[string]any) (any, error) { return t.run(stdio.CallerOf(a), a) }
func (t toolDef) runAs(caller string, a map[string]any) (any, error) { return t.run(caller, a) }
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func definitions(env *Env) []toolDef {
return []toolDef{
{Name: Seat + ".get",
Description: "One secret, sealed to the public key the asker sends (the sealed box of ADR 0206), with its " +
"generation; answered only to an asker the vault allows, as the runtime proved it (ADR 0349 §11). " +
"Never the value in the clear, and never to a private key.",
Input: map[string]any{"name": str("the secret's name"),
"public_key": str("the asker's X25519 public key as SPKI PEM — never a private key")},
run: func(caller string, a map[string]any) (any, error) {
if env.Vault == nil {
if env.Broken != nil {
return nil, env.Broken
}
return nil, errors.New("the vault is not ready")
}
name, _ := a["name"].(string)
key, _ := a["public_key"].(string)
return env.Vault.Get(caller, strings.TrimSpace(name), key)
}},
{Name: Seat + ".accept",
Description: "Take a value given at the controller's terminal (ADR 0349 clause 8), in two steps from the " +
"controller alone: name the secret (node, module and name for a module's own; provider and provision " +
"for a provision's one credential) and get a call and a key made for it; then send the call and the " +
"value sealed to that key. The vault alone opens it, and never remakes an accepted credential.",
Input: intakeInput(map[string]string{"node": "the machine, for a module's own secret",
"module": "the module, for a module's own secret", "name": "the secret's name in the module",
"provider": "the provider's machine, for a provision's credential", "provision": "the provision",
"by": "who gave it at the terminal, as the controller names them"}),
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
if call := arg(a, "call"); call != "" {
return v.AcceptTake(caller, call, arg(a, "sealed"))
}
secret, err := secretNamed(a)
if err != nil {
return nil, err
}
return v.AcceptOpen(caller, secret, arg(a, "by"))
}},
{Name: Seat + ".secret-ask",
Description: "Ask the operator for a module's own secret at a desk (ADR 0349 clause 9, ADR 0277 §22–§27), in " +
"two steps from the controller alone, which reaches the desk's prompt: node, module, name, desk and " +
"asked_by open the ask — recorded before the prompt opens, one open ask per secret and three an " +
"hour — and answer the key the prompt seals to and who asked as the record names them; then the call " +
"with what the prompt sealed, or with how it ended (dismissed, timed-out, empty, refused, failed). " +
"What is typed opens in the vault alone.",
Input: intakeInput(map[string]string{"node": "the machine the secret is for", "module": "the module",
"name": "the secret's name in the module", "desk": "the machine whose prompt asks",
"asked_by": "who asked the controller, as the bus named its caller",
"outcome": "instead of sealed: how the prompt ended — dismissed, timed-out, empty, refused or failed"}),
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
if call := arg(a, "call"); call != "" {
return v.AskEnd(caller, call, arg(a, "sealed"), arg(a, "outcome"))
}
return v.AskOpen(caller, AskOf{Node: arg(a, "node"), Module: arg(a, "module"), Name: arg(a, "name"),
Desk: arg(a, "desk"), AskedBy: arg(a, "asked_by")})
}},
{Name: Seat + ".issued",
Description: "Take the credential a provider's software issued for a provision (ADR 0349 clause 10, ADR 0305), " +
"in two steps from the controller alone, which asks the provider's tool: provision, provider and module " +
"open a call and answer the key the tool seals to; then the call with what the tool sealed. Kept as the " +
"provision's one credential; the same value again writes nothing.",
Input: intakeInput(map[string]string{"provision": "the provision", "provider": "the provider's machine",
"module": "the provider's module, whose software issued it"}),
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
if call := arg(a, "call"); call != "" {
return v.IssuedTake(caller, call, arg(a, "sealed"))
}
return v.IssuedOpen(caller, arg(a, "provision"), arg(a, "provider"), arg(a, "module"))
}},
{Name: Seat + ".rotate",
Description: "Make a secret anew (ADR 0114, ADR 0228): node, module and name for a module's own, or provider " +
"and provision for a provision's credential. Refused for a value a provider's software issued, which " +
"the software rotates, and for an accepted credential, which the vault does not remake. Never answers " +
"the value.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"node": str("the machine, for a module's own secret"), "module": str("the module"),
"name": str("the secret's name in the module"), "provider": str("the provider's machine"),
"provision": str("the provision"),
"by": str("who asked, as the controller names them when it carries the act")}},
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
secret, err := secretNamed(a)
if err != nil {
return nil, err
}
return v.Rotate(caller, secret, arg(a, "by"))
}},
{Name: "secret_asks",
Description: "The record of asks for a module's own secret at a desk, newest first: who asked as the bus named " +
"them, for what, at which desk, when, and how each ended. Never a value.",
Input: map[string]any{"limit": map[string]any{"type": "number", "description": "how many lines (default 50)"}},
run: func(_ string, a map[string]any) (any, error) {
v, err := env.ready()
if err != nil {
return nil, err
}
return v.Store.Asks(limitOf(a))
}},
{Name: "secret_export",
Description: "The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " +
"sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can open a line " +
"of it; the operator, holding the private key off the mesh, recovers one with " +
"`nox secret recover --from-export` on the control-node. Also lists what is NOT recoverable: secrets " +
"made before the mesh had an operator key.",
Input: map[string]any{"sealed": map[string]any{"type": "boolean",
"description": "include the sealed blobs (default true); false lists holders and the key only"}},
run: func(_ string, a map[string]any) (any, error) {
return exportAnswer(env.Root, a["sealed"] != false), nil
}},
{Name: "secret_holders",
Description: "Who holds a secret from this vault: each consumer's login, node and module, when it was " +
"granted, how many times it has been rotated and when, and the fingerprint of the current value. " +
"Fingerprints only — the value is never returned.",
run: func(string, map[string]any) (any, error) {
if env.Ledger == nil {
return map[string]any{"holders": []any{}, "count": 0, "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}, nil
}
var asked []Contribution
if env.Receives != "" {
asked, _ = Contributions(env.Receives)
}
holders := []map[string]any{}
for _, h := range env.Ledger.List() {
row := map[string]any{}
raw, _ := json.Marshal(h)
_ = json.Unmarshal(raw, &row)
row["module"], row["asked"] = nil, false
for _, c := range asked {
if c.As == h.As {
row["module"], row["asked"] = c.From, true
}
}
holders = append(holders, row)
}
return map[string]any{"holders": holders, "count": len(holders)}, nil
}},
{Name: "secret_verify",
Description: "Check one holder's secret without seeing it: the fingerprint the vault recorded against the " +
"fingerprint of the value the mesh currently delivers here, and optionally against a fingerprint " +
"computed on the holder's own machine (sha256 of the file, as `sha256:<hex>`). Two ends agreeing " +
"proves they agree, not that either works — the login itself is the test.",
Input: map[string]any{"as": str("the holder's login, e.g. anchor-redis"),
"fingerprint": str("optional: sha256:<hex> of the value as the holder reads it, computed there — never the value")},
run: func(_ string, a map[string]any) (any, error) {
as, _ := a["as"].(string)
given, _ := a["fingerprint"].(string)
return verifyAnswer(env, as, given), nil
}},
{Name: "secret_handed",
Description: "Who was handed which secret by `get`, and when, and who was refused, newest first, as the " +
"vault's own store records it. Never a value.",
Input: map[string]any{"limit": map[string]any{"type": "number", "description": "how many (default 50)"}},
run: func(_ string, a map[string]any) (any, error) {
if env.Vault == nil {
return nil, errors.New("the vault is not ready")
}
return env.Vault.Store.Handed(limitOf(a))
}},
{Name: "secret_vault_export",
Description: "Every secret the vault itself holds, as its copy sealed to the operator key, in the shape of " +
"`secret export`: saved to a file, `nox secret recover vault mesh-vault <name> --key <operator key file> " +
"--from-export <file> --out <file>` opens one with the operator's private key. Ciphertext only.",
run: func(string, map[string]any) (any, error) {
if env.Vault == nil {
return nil, errors.New("the vault is not ready")
}
return vaultExport(env.Vault)
}},
{Name: "provisioner_retirement",
Description: "This provider's retirement (novox/hq ADR 0230): the holders it keeps active, a retirement waiting " +
"for a person, one a person rejected, and every holder retired with when and why. Mark-only: a retired " +
"holder keeps what it has until a person deletes it.",
run: func(string, map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
return env.Custody.Retirement(), nil
}},
{Name: "provisioner_retire_approve",
Description: "Retire exactly the set waiting for a person (or rejected earlier). Repeat the set in consumers; why is required.",
Input: actInput(true, false),
run: func(caller string, a map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
done, err := env.Custody.Approve(stringsOf(a["consumers"]), actOf(caller, a))
if err != nil {
return nil, err
}
return map[string]any{"retired": done}, nil
}},
{Name: "provisioner_retire_reject",
Description: "Keep the set waiting for a person active, though the mesh no longer asks for it. Repeat the set in consumers; why is required.",
Input: actInput(true, false),
run: func(caller string, a map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
kept, err := env.Custody.Reject(stringsOf(a["consumers"]), actOf(caller, a))
if err != nil {
return nil, err
}
return map[string]any{"kept": kept}, nil
}},
{Name: "provisioner_delete",
Description: "Delete one RETIRED holder from the custody ledger, its rotation history with it — never an active one. " +
"Repeat its name in confirm; why is required.",
Input: actInput(false, true),
run: func(caller string, a map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
consumer, _ := a["consumer"].(string)
confirm, _ := a["confirm"].(string)
if err := env.Custody.Delete(consumer, confirm, actOf(caller, a)); err != nil {
return nil, err
}
return map[string]any{"deleted": consumer, "freed_bytes": -1}, nil
}},
{Name: "vault_status",
Description: "What needs a person's eye in the vault: operator copies still sealed to an earlier key, the operator " +
"key the mesh's export holds if the vault refused it, and how often the record of hand-outs was rolled.",
run: func(string, map[string]any) (any, error) {
if env.Vault == nil {
return map[string]any{"ready": false, "error": fmt.Sprint(env.Broken)}, nil
}
left, err := env.Vault.StillSealedToAnother()
if err != nil {
return nil, err
}
key, _ := env.Vault.Store.OperatorKey()
return map[string]any{"ready": true, "operator_key": key, "copies_on_an_earlier_key": left,
"operator_key_refused": env.keyRefused(), "handed_rolled": env.Vault.Store.Rolled(),
"ok": left == 0 && env.keyRefused() == "" && key != ""}, nil
}},
}
}
// actInput is the arguments of an act on retirement, as the SDK's harness takes them.
func actInput(set, one bool) map[string]any {
text := map[string]any{"type": "string"}
in := map[string]any{"why": text, "by": text, "via": text}
if set {
in["consumers"] = map[string]any{"type": "array", "items": text}
}
if one {
in["consumer"], in["confirm"] = text, text
}
return in
}
// actOf is an act's why, by and via. `by` is the person the controller names; with none, the caller the
// runtime proved. `by` is an argument, so it is not proven: when it names somebody other than the caller the
// runtime proved, the proven caller is kept beside it in via, so the record says both.
func actOf(caller string, a map[string]any) Act {
act := Act{}
act.Why, _ = a["why"].(string)
act.By, _ = a["by"].(string)
act.Via, _ = a["via"].(string)
if act.By == "" {
act.By = caller
}
if act.By != caller {
proven := "called by " + caller
if act.Via == "" {
act.Via = proven
} else {
act.Via += ", " + proven
}
}
return act
}
// ready is the vault, or why there is none.
func (e *Env) ready() (*Vault, error) {
if e.Vault != nil {
return e.Vault, nil
}
if e.Broken != nil {
return nil, e.Broken
}
return nil, errors.New("the vault is not ready")
}
func arg(a map[string]any, key string) string {
s, _ := a[key].(string)
return strings.TrimSpace(s)
}
// secretNamed is the secret a call names: a module's own (node, module, name), or a provision's one credential
// (provider, provision) — one or the other.
func secretNamed(a map[string]any) (string, error) {
own := arg(a, "node") != "" || arg(a, "module") != "" || arg(a, "name") != ""
pair := arg(a, "provider") != "" || arg(a, "provision") != ""
switch {
case own && !pair:
return OwnSecret(arg(a, "node"), arg(a, "module"), arg(a, "name"))
case pair && !own:
return ProvisionSecret(arg(a, "provider"), arg(a, "provision"))
}
return "", errors.New("name a module's own secret (node, module, name) or a provision's credential " +
"(provider, provision), one of the two")
}
// intakeInput is a two-step verb's arguments: what its first step names, then the call and what was sealed.
func intakeInput(first map[string]string) map[string]any {
props := map[string]any{}
for k, d := range first {
props[k] = str(d)
}
props["call"] = str("the second step: the call the first step answered")
props["sealed"] = str("the second step: the value sealed to the call's key (NaCl anonymous box, base64) — never the value")
return map[string]any{"type": "object", "properties": props}
}
func stringsOf(v any) []string {
list, _ := v.([]any)
out := []string{}
for _, x := range list {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
func limitOf(a map[string]any) int {
if v, ok := a["limit"].(float64); ok && v > 0 {
return int(v)
}
return 50
}
// keyFingerprint is a key's fingerprint as the controller's secrets.Fingerprint says it: sha256 of the key,
// its first eight bytes in hex.
func keyFingerprint(key string) string {
if key == "" {
return ""
}
sum := sha256.Sum256([]byte(strings.TrimSpace(key)))
return "sha256:" + hex.EncodeToString(sum[:8])
}
// vaultExport is the vault's operator copies in the shape `secret export` writes and `secret recover
// --from-export` reads: each as node "vault", module "mesh-vault" and the secret's name.
func vaultExport(v *Vault) (map[string]any, error) {
key, err := v.Store.OperatorKey()
if err != nil {
return nil, err
}
all, err := v.Store.List()
if err != nil {
return nil, err
}
kept, earlier := []map[string]any{}, []map[string]any{}
for _, rec := range all {
k := map[string]any{"node": "vault", "module": Seat, "name": rec.Name, "kind": "own", "origin": "made",
"sealed": rec.Operator.Sealed, "key": rec.Operator.Key, "made-at": rec.MadeAt}
if rec.Operator.Key == key {
kept = append(kept, k)
} else {
earlier = append(earlier, k)
}
}
return map[string]any{"export": 1, "operator-key": key, "fingerprint": keyFingerprint(key), "kept": kept,
"sealed-to-earlier-key": earlier}, nil
}
func exportAnswer(root string, withBlobs bool) map[string]any {
if root == "" {
return map[string]any{"available": false, "error": "this vault keeps no root secrets (MESH_VAULT_ROOT is not set)"}
}
raw, err := os.ReadFile(filepath.Join(root, "export.json"))
if err != nil {
return map[string]any{"available": false, "error": "the mesh has not written an export here yet: " + err.Error()}
}
var doc struct {
Export int `json:"export"`
OperatorKey string `json:"operator-key"`
Fingerprint string `json:"fingerprint"`
Kept []map[string]any `json:"kept"`
Unrecoverable []map[string]any `json:"unrecoverable"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return map[string]any{"available": false, "error": "the export is not readable: " + err.Error()}
}
kept := []map[string]any{}
for _, k := range doc.Kept {
if !withBlobs {
k = map[string]any{"node": k["node"], "module": k["module"], "name": k["name"], "origin": k["origin"], "made-at": k["made-at"]}
}
kept = append(kept, k)
}
if doc.Unrecoverable == nil {
doc.Unrecoverable = []map[string]any{}
}
return map[string]any{"available": true, "export": doc.Export, "operator-key": doc.OperatorKey,
"fingerprint": doc.Fingerprint, "count": len(kept), "kept": kept, "unrecoverable": doc.Unrecoverable}
}
// exportedOperatorKey is the operator key in the export the mesh writes to the vault's root, or "".
func exportedOperatorKey(root string) string {
raw, err := os.ReadFile(filepath.Join(root, "export.json"))
if err != nil {
return ""
}
var doc struct {
OperatorKey string `json:"operator-key"`
}
_ = json.Unmarshal(raw, &doc)
return doc.OperatorKey
}
func verifyAnswer(env *Env, as, given string) map[string]any {
if env.Ledger == nil {
return map[string]any{"as": as, "known": false, "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}
}
recorded, ok := env.Ledger.Get(as)
if !ok {
return map[string]any{"as": as, "known": false, "error": "this vault holds nothing for " + as}
}
var delivered, deliveredErr any
var deliveredMatches any
var asked *Contribution
if env.Receives != "" {
all, _ := Contributions(env.Receives)
for i := range all {
if all[i].As == as {
asked = &all[i]
}
}
}
if asked == nil {
deliveredErr = "the mesh does not currently ask this vault to serve that login"
} else if fp, _, err := DeliveredFingerprint(*asked); err != nil {
deliveredErr = err.Error()
} else {
delivered, deliveredMatches = fp, fp == recorded.Fingerprint
}
var givenAny, givenMatches, earlier any
if given != "" {
givenAny, givenMatches = given, given == recorded.Fingerprint
was := false
for _, h := range recorded.History {
was = was || h.Fingerprint == given
}
earlier = was
}
return map[string]any{"as": recorded.As, "known": true, "recorded": recorded.Fingerprint,
"rotations": recorded.Rotations, "changed": recorded.Changed,
"delivered": delivered, "deliveredError": deliveredErr, "deliveredMatchesRecorded": deliveredMatches,
"given": givenAny, "givenMatchesRecorded": givenMatches, "givenIsAnEarlierValue": earlier,
"ok": deliveredMatches != false && givenMatches != false}
}
// tools are the definitions as the SDK serves them.
func tools(env *Env) []stdio.Tool {
var out []stdio.Tool
for _, d := range definitions(env) {
out = append(out, stdio.Tool{Name: d.Name, Description: d.Description, Input: d.Input, Run: d.Run})
}
return out
}
@@ -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
}
+313
View File
@@ -0,0 +1,313 @@
package main
// The vault's rules (novox/hq ADR 0349 §7 and §11; ADR 0113 §1, §2, §22): it holds every secret sealed at
// rest, with a copy sealed to the operator key; it hands one out only on request and reply, sealed to the
// asker's key, and only to an asker it allows; it says a secret changed — who, when, never a value.
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"regexp"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// Seat is the role this module holds.
const Seat = "mesh-vault"
// ChangedEvent is what the vault raises when a secret it holds changes: who, when, which, its generation.
const ChangedEvent = "secret-changed"
// Vault holds the secrets.
type Vault struct {
Store *Store
// Own is the vault's own key; the private half never leaves its state directory.
Own seal.KeyPair
// Askers decides who may ask `get` for what. Nil allows nobody.
Askers Askers
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
mu sync.Mutex
// callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere.
callsMu sync.Mutex
pending map[string]*callKey
}
// Changed is what one change to a secret was. Never its value.
type Changed struct {
Name string `json:"name"`
Generation int `json:"generation"`
At time.Time `json:"at"`
By string `json:"by"`
Unchanged bool `json:"unchanged,omitempty"`
}
// Answer is `get`'s reply: the secret sealed to the asker's key, and which generation it is.
type Answer struct {
Name string `json:"name"`
Generation int `json:"generation"`
ChangedAt time.Time `json:"changed-at"`
Sealed seal.Box `json:"sealed"`
}
func (v *Vault) log(format string, args ...any) {
if v.Log != nil {
v.Log(format, args...)
}
}
// Put keeps value as the secret name: sealed to the vault's key and to the operator key, a new generation
// when it differs from the one held, and `secret-changed` said. The same value again changes nothing.
// Refused: a private key (it stays where it was made), and any value while the vault has no operator key —
// a secret only the vault could open is what ADR 0113 §22 rules out.
func (v *Vault) Put(name, value, by string) (Changed, error) {
return v.keep(name, value, by, "", OriginMade)
}
// keep is Put with where the value came from (origin) and the caller the runtime proved when it is not who
// by names (via).
func (v *Vault) keep(name, value, by, via, origin string) (Changed, error) {
if err := validName(name); err != nil {
return Changed{}, err
}
if value == "" {
return Changed{}, fmt.Errorf("%s: a secret is not empty", name)
}
if privateKeyIn(value) {
return Changed{}, fmt.Errorf("%s: that is a private key, and a private key stays where it was made and never reaches the vault (ADR 0113 §2)", name)
}
if by == "" {
by = "the vault"
}
v.mu.Lock()
defer v.mu.Unlock()
operator, err := v.Store.OperatorKey()
if err != nil {
return Changed{}, err
}
if operator == "" {
return Changed{}, fmt.Errorf("%s is not kept: the vault has no operator key to seal its copy to (ADR 0113 §22) — the mesh writes it with the export once `operator key set` has been run", name)
}
before, held, err := v.Store.Read(name)
if err != nil {
return Changed{}, err
}
if held {
if old, err := seal.Open(before.AtRest, v.Own.PrivateKey); err == nil && old == value {
return Changed{Name: name, Generation: before.Generation, At: before.ChangedAt, By: before.ChangedBy, Unchanged: true}, nil
}
}
atRest, err := seal.Seal(value, v.Own.PublicKey)
if err != nil {
return Changed{}, err
}
forOperator, err := operatorSeal(operator, []byte(value))
if err != nil {
return Changed{}, err
}
now := v.Now().UTC()
if via == by {
via = ""
}
rec := Record{Name: name, Generation: 1, AtRest: atRest,
Operator: OperatorCopy{Box: OperatorBox, Key: operator, Sealed: forOperator},
MadeAt: now, ChangedAt: now, ChangedBy: by, ChangedVia: via, Origin: origin}
if held {
rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt
}
if err := v.Store.Write(rec); err != nil {
return Changed{}, err
}
c := Changed{Name: name, Generation: rec.Generation, At: now, By: by}
v.announce(c)
return c, nil
}
// Mint makes a fresh value for name and keeps it: 30 random bytes, base64url without padding — 40
// characters, the controller's shape, which fits the tightest backend a minted password reaches.
func (v *Vault) Mint(name, by string) (Changed, error) {
raw := make([]byte, 30)
if _, err := rand.Read(raw); err != nil {
return Changed{}, err
}
return v.Put(name, base64.RawURLEncoding.EncodeToString(raw), by)
}
// announce raises `secret-changed`: who, when, which and its generation — never a value or a sealed copy.
func (v *Vault) announce(c Changed) {
if v.Emit == nil {
return
}
body := map[string]any{"name": c.Name, "by": c.By, "at": c.At.Format(time.RFC3339Nano), "generation": c.Generation}
if err := v.Emit(ChangedEvent, body); err != nil {
v.log("%s is kept at generation %d, but %s could not be said: %v", c.Name, c.Generation, ChangedEvent, err)
}
}
// recordHanding keeps who asked for what, and what they got, in the vault's own store, and says loudly
// when the record had to be rolled to make room.
func (v *Vault) recordHanding(h Handing) error {
h.At = v.Now().UTC()
rolled, err := v.Store.RecordHanding(h)
if err != nil {
v.log("the record of %s asking for %s could not be written: %v", h.To, h.Name, err)
return err
}
if rolled {
v.log("THE RECORD OF HAND-OUTS WAS ROLLED (%d times since the vault started): it reached its bound of %d bytes; "+
"the oldest of %d kept files is dropped. Many asks in little time is worth a look: secret_handed and vault_status",
v.Store.Rolled(), v.Store.HandedBound(), HandedKept)
}
return nil
}
// busUser is the shape of a caller the runtime proves (the SDK's): the one shape a refusal is recorded for.
var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
// Get answers caller with the secret name sealed to publicKey, if caller may ask for it. The caller is the
// bus user the runtime proved, never an argument; an unproven call is refused.
func (v *Vault) Get(caller, name, publicKey string) (Answer, error) {
if caller == "" || caller == stdio.Unproven {
return Answer{}, errors.New("the vault answers only a caller the runtime proved")
}
askers := v.Askers
if askers == nil {
askers = NoAsker{}
}
if !busUser.MatchString(caller) || len(caller) > 200 {
return Answer{}, errors.New("the vault answers only a caller the runtime proved")
}
// A name is judged before anything is recorded, so an arbitrary argument fills no record.
if err := validName(name); err != nil {
return Answer{}, err
}
if ok, why := askers.May(caller, name); !ok {
v.log("refused %s to %s: %s", name, caller, why)
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
}
key, err := askerKey(publicKey)
if err != nil {
return Answer{}, err
}
rec, held, err := v.Store.Read(name)
if err != nil {
return Answer{}, err
}
if !held {
return Answer{}, fmt.Errorf("the vault holds no secret named %s", name)
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
return Answer{}, fmt.Errorf("%s does not open with the vault's key: %w", name, err)
}
sealed, err := seal.Seal(value, key)
if err != nil {
return Answer{}, err
}
if err := v.recordHanding(Handing{To: caller, Name: name, Generation: rec.Generation, Outcome: "handed"}); err != nil {
return Answer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", name, err)
}
v.log("handed %s generation %d to %s, sealed to its key", name, rec.Generation, caller)
return Answer{Name: name, Generation: rec.Generation, ChangedAt: rec.ChangedAt, Sealed: sealed}, nil
}
// SetOperatorKey makes key (SPKI PEM) the operator key, then seals again to it every operator copy that is
// not yet — see Reseal. It returns how many copies it sealed again.
//
// The key is recorded first and each record says which key its copy is sealed to, so that record is the
// progress: a failure partway leaves the rest for the next call, and the same key given again (every minute
// from the mesh's export) finishes them. Nothing returns early while a copy is sealed to another key.
func (v *Vault) SetOperatorKey(key, by string) (int, error) {
raw, err := checkOperatorKey(key)
if err != nil {
return 0, err
}
v.mu.Lock()
defer v.mu.Unlock()
current, err := v.Store.OperatorKey()
if err != nil {
return 0, err
}
if current != raw {
if err := v.Store.setOperatorKey(raw, by, v.Now().UTC()); err != nil {
return 0, err
}
v.log("the operator key is now %s (by %s); every operator copy is sealed again to it", raw, by)
}
return v.reseal(raw)
}
// Reseal seals again to the current operator key every copy sealed to another one, and says how many it
// did. Called every minute, so a copy a failure left behind is not left for ever.
func (v *Vault) Reseal() (int, error) {
v.mu.Lock()
defer v.mu.Unlock()
key, err := v.Store.OperatorKey()
if err != nil || key == "" {
return 0, err
}
return v.reseal(key)
}
func (v *Vault) reseal(key string) (int, error) {
all, err := v.Store.List()
if err != nil {
return 0, err
}
n := 0
var failed []error
for _, rec := range all {
if rec.Operator.Key == key && rec.Operator.Box == OperatorBox {
continue
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
failed = append(failed, fmt.Errorf("%s does not open with the vault's key: %w", rec.Name, err))
continue
}
sealed, err := operatorSeal(key, []byte(value))
if err == nil {
rec.Operator = OperatorCopy{Box: OperatorBox, Key: key, Sealed: sealed}
err = v.Store.Write(rec)
}
if err != nil {
failed = append(failed, fmt.Errorf("%s: %w", rec.Name, err))
continue
}
n++
}
if len(failed) > 0 {
err := fmt.Errorf("%d operator copies are still sealed to an earlier key, tried again on the next pass: %w",
len(failed), errors.Join(failed...))
v.log("%v", err)
return n, err
}
return n, nil
}
// StillSealedToAnother is how many operator copies are not yet sealed to the current operator key.
func (v *Vault) StillSealedToAnother() (int, error) {
key, err := v.Store.OperatorKey()
if err != nil {
return 0, err
}
all, err := v.Store.List()
if err != nil {
return 0, err
}
n := 0
for _, rec := range all {
if rec.Operator.Key != key {
n++
}
}
return n, nil
}
@@ -0,0 +1,730 @@
package main
// ADR 0349's vault test list, the part this package (V1, issue 674) answers: no bucket holds a secret; no
// private key is ever sent to the vault; every secret has a copy sealed to the operator key; `get` answers
// only an allowed asker, sealed to its key; `secret-changed` carries no value. The rest of that list (a
// consumer binding remakes the provider's credential, an accepted value is not remade, issued and typed
// values) is V2's and V5's.
import (
"crypto/ecdh"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal"
"golang.org/x/crypto/nacl/box"
)
// published is everything the vault said on the bus during a test, as it said it.
type published struct {
mu sync.Mutex
events []map[string]any
raw []string
}
func (p *published) emit(event string, body map[string]any) error {
p.mu.Lock()
defer p.mu.Unlock()
b, _ := json.Marshal(map[string]any{"event": event, "body": body})
p.raw = append(p.raw, string(b))
p.events = append(p.events, map[string]any{"event": event, "body": body})
return nil
}
// operatorKey is an operator key the way the controller's `operator key make` makes one: NaCl, raw X25519,
// base64 — the private half kept by the test, as the operator keeps it off the mesh.
func operatorKey(t *testing.T) (public, private string) {
t.Helper()
pub, priv, err := box.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(pub[:]), base64.StdEncoding.EncodeToString(priv[:])
}
// asPEM is a raw operator key as the SPKI PEM the vault takes.
func asPEM(t *testing.T, raw string) string {
t.Helper()
p, err := publicPEM(raw)
if err != nil {
t.Fatal(err)
}
return p
}
// openOperator opens an operator copy the way `secret recover --from-export` does.
func openOperator(t *testing.T, public, private, sealed string) string {
t.Helper()
var pub, priv [32]byte
p, _ := base64.StdEncoding.DecodeString(public)
q, _ := base64.StdEncoding.DecodeString(private)
copy(pub[:], p)
copy(priv[:], q)
blob, err := base64.StdEncoding.DecodeString(sealed)
if err != nil {
t.Fatal(err)
}
out, ok := box.OpenAnonymous(nil, blob, &pub, &priv)
if !ok {
t.Fatal("the operator copy does not open with the operator's key")
}
return string(out)
}
type testVault struct {
*Vault
dir string
bus *published
operatorPublic string
operatorPrivate string
}
func newVault(t *testing.T, askers Askers) testVault {
t.Helper()
dir := t.TempDir()
store, err := OpenStore(filepath.Join(dir, "store"))
if err != nil {
t.Fatal(err)
}
own, err := OwnKey(filepath.Join(dir, "state"), store)
if err != nil {
t.Fatal(err)
}
bus := &published{}
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
v := &Vault{Store: store, Own: own, Askers: askers, Emit: bus.emit,
Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Log: t.Logf}
pub, priv := operatorKey(t)
if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil {
t.Fatal(err)
}
return testVault{Vault: v, dir: dir, bus: bus, operatorPublic: pub, operatorPrivate: priv}
}
// filesUnder is every file's content under dir.
func filesUnder(t *testing.T, dir string) map[string]string {
t.Helper()
out := map[string]string{}
_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
raw, _ := os.ReadFile(path)
out[path] = string(raw)
}
return nil
})
return out
}
// ---- no bucket holds a secret ---------------------------------------------------------------------------
// The manifest declares no bucket, and the code opens none: the vault's only place for a secret is its own
// store on its own disk (ADR 0349 §7, ADR 0201 §13).
func TestNoBucketHoldsASecret(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var manifest map[string]any
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
if s, ok := manifest["state"]; ok {
t.Errorf("the manifest declares buckets %v: a secret is in no bucket, sealed or not (ADR 0201 §13)", s)
}
sources, _ := filepath.Glob("*.go")
bucket := regexp.MustCompile(`stdio\.State\(|nats-io|jetstream|KeyValue`)
for _, f := range sources {
if strings.HasSuffix(f, "_test.go") {
continue
}
code, _ := os.ReadFile(f)
if m := bucket.Find(code); m != nil {
t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m)
}
}
// And what the vault publishes while it holds, changes and hands out secrets carries none of them, in
// the clear or sealed.
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
value := "the-password-nobody-may-see"
if _, err := v.Put("pair/postgres/gitea", value, "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
if err != nil {
t.Fatal(err)
}
rec, _, _ := v.Store.Read("pair/postgres/gitea")
for _, said := range v.bus.raw {
for _, secret := range []string{value, answer.Sealed.Ct, rec.AtRest.Ct, rec.Operator.Sealed} {
if strings.Contains(said, secret) {
t.Errorf("the vault published %s, which carries a secret or a sealed copy of one", said)
}
}
}
}
// Every secret is encrypted at rest: no file the vault writes holds a value in the clear, and each opens
// only with the vault's own key.
func TestEverySecretIsEncryptedAtRest(t *testing.T) {
v := newVault(t, nil)
values := map[string]string{"own/g14/dunst/token": "tok-1-plain", "pair/postgres/gitea": "pw-2-plain"}
for name, value := range values {
if _, err := v.Put(name, value, "the test"); err != nil {
t.Fatal(err)
}
}
for path, content := range filesUnder(t, filepath.Join(v.dir, "store")) {
for _, value := range values {
if strings.Contains(content, value) {
t.Errorf("%s holds a value in the clear", path)
}
}
if info, _ := os.Stat(path); info.Mode().Perm()&0o077 != 0 {
t.Errorf("%s is readable by others: %v", path, info.Mode())
}
}
for name, value := range values {
rec, ok, err := v.Store.Read(name)
if err != nil || !ok {
t.Fatalf("%s: %v %v", name, ok, err)
}
got, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil || got != value {
t.Errorf("%s at rest opens to %q (%v), not its value", name, got, err)
}
}
}
// ---- no private key is accepted -------------------------------------------------------------------------
func TestNoPrivateKeyIsAccepted(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
keys, _ := seal.GenerateKeyPair()
ssh := "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----\n"
rsa := "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAK\n-----END RSA PRIVATE KEY-----\n"
// Not as a secret to hold: a private key stays where it was made (ADR 0113 §2).
for _, value := range []string{keys.PrivateKey, ssh, rsa, "prefix " + keys.PrivateKey} {
if _, err := v.Put("own/g14/sshd/host", value, "the test"); err == nil {
t.Errorf("the vault took a private key as a secret: %.40q", value)
}
}
if _, ok, _ := v.Store.Read("own/g14/sshd/host"); ok {
t.Error("a refused private key was kept all the same")
}
// Not as the key to seal an answer to.
if _, err := v.Put("pair/postgres/gitea", "pw", "the test"); err != nil {
t.Fatal(err)
}
raw32 := base64.StdEncoding.EncodeToString(make([]byte, 32))
for _, key := range []string{keys.PrivateKey, ssh, raw32, "", "not a key"} {
if _, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", key); err == nil {
t.Errorf("get sealed an answer to %.40q, which is not a PEM public key", key)
}
}
// Not as the operator key: neither a PEM private key nor a raw key, which looks the same as a raw
// private one.
rawPub, rawPriv := operatorKey(t)
for _, key := range []string{keys.PrivateKey, rawPriv, rawPub} {
if _, err := v.SetOperatorKey(key, "the test"); err == nil {
t.Errorf("the vault took %.20q as the operator key", key)
}
}
if k, _ := v.Store.OperatorKey(); k != v.operatorPublic {
t.Error("a refused operator key replaced the operator key")
}
// And nothing it publishes or writes names one that it refused.
for _, said := range v.bus.raw {
if strings.Contains(said, "PRIVATE KEY") {
t.Errorf("the vault published a private key: %s", said)
}
}
for path, content := range filesUnder(t, filepath.Join(v.dir, "store")) {
if strings.Contains(content, "PRIVATE KEY") {
t.Errorf("%s holds a private key", path)
}
}
}
// ---- every secret has an operator-sealed copy -----------------------------------------------------------
func TestEverySecretHasAnOperatorSealedCopy(t *testing.T) {
v := newVault(t, nil)
values := map[string]string{"own/g14/dunst/token": "a", "pair/postgres/gitea": "b"}
for name, value := range values {
if _, err := v.Put(name, value, "the test"); err != nil {
t.Fatal(err)
}
}
if _, err := v.Mint("pair/postgres/keycloak", "the test"); err != nil {
t.Fatal(err)
}
all, err := v.Store.List()
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("the store lists %d secrets, not 3", len(all))
}
for _, rec := range all {
if rec.Operator.Key != v.operatorPublic || rec.Operator.Box != OperatorBox {
t.Errorf("%s is sealed to %q in %q, not to the operator key", rec.Name, rec.Operator.Key, rec.Operator.Box)
}
got := openOperator(t, v.operatorPublic, v.operatorPrivate, rec.Operator.Sealed)
want, _ := seal.Open(rec.AtRest, v.Own.PrivateKey)
if got != want || got == "" {
t.Errorf("%s: the operator copy opens to %q, the vault's to %q", rec.Name, got, want)
}
}
// A new operator key: every copy is sealed again to it, and none stays sealed to the old one.
pub, priv := operatorKey(t)
n, err := v.SetOperatorKey(asPEM(t, pub), "the test")
if err != nil || n != 3 {
t.Fatalf("a new operator key sealed %d copies again (%v), not 3", n, err)
}
all, _ = v.Store.List()
for _, rec := range all {
if rec.Operator.Key != pub {
t.Errorf("%s is still sealed to the earlier operator key", rec.Name)
}
openOperator(t, pub, priv, rec.Operator.Sealed)
}
}
// Without an operator key the vault keeps nothing: a secret only it could open is what ADR 0113 §22 rules
// out, so the refusal is loud rather than a copy quietly missing.
func TestNoOperatorKeyKeepsNothing(t *testing.T) {
dir := t.TempDir()
store, _ := OpenStore(filepath.Join(dir, "store"))
own, _ := OwnKey(filepath.Join(dir, "state"), store)
v := &Vault{Store: store, Own: own, Emit: (&published{}).emit, Now: time.Now, Log: t.Logf}
if _, err := v.Put("own/g14/dunst/token", "x", "the test"); err == nil || !strings.Contains(err.Error(), "operator key") {
t.Fatalf("a secret was kept with no operator key to seal its copy to: %v", err)
}
if all, _ := store.List(); len(all) != 0 {
t.Fatalf("the store holds %d secrets", len(all))
}
}
// ---- get answers only an allowed asker, sealed to its key -----------------------------------------------
func TestGetAnswersOnlyAnAllowedAskerSealedToItsKey(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "the-value", "the test"); err != nil {
t.Fatal(err)
}
if _, err := v.Put("own/ace/dunst/token", "another", "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
if err != nil {
t.Fatal(err)
}
if got, err := seal.Open(answer.Sealed, asker.PrivateKey); err != nil || got != "the-value" {
t.Fatalf("the answer opens to %q (%v)", got, err)
}
other, _ := seal.GenerateKeyPair()
if _, err := seal.Open(answer.Sealed, other.PrivateKey); err == nil {
t.Error("the answer opens with a key it was not sealed to")
}
if _, err := seal.Open(answer.Sealed, v.Own.PrivateKey); err == nil {
t.Error("the answer opens with the vault's own key")
}
if answer.Generation != 1 || answer.Name != "pair/postgres/gitea" {
t.Errorf("the answer says %+v", answer)
}
refusals := []struct{ caller, name, why string }{
{"ace.node-provisioner", "pair/postgres/gitea", "an asker not on the list"},
{"g14.node-provisioner", "own/ace/dunst/token", "a secret the asker is not allowed"},
{stdio.Unproven, "pair/postgres/gitea", "a call whose caller the runtime did not prove"},
{"", "pair/postgres/gitea", "no caller at all"},
{"g14.node-provisioner", "pair/postgres/nothing", "a secret the vault does not hold"},
}
for _, r := range refusals {
if a, err := v.Get(r.caller, r.name, asker.PublicKey); err == nil {
t.Errorf("get answered %s: %+v", r.why, a)
}
}
}
// Until who may ask is decided by a node's declaration and a module's own account (V4, P3), the vault
// that runs answers nobody.
func TestTheRunningVaultAnswersNobodyYet(t *testing.T) {
v := newVault(t, nil) // what main builds: no list
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
for _, caller := range []string{"g14.node-provisioner", "novox.mesh-provisioner", "controller", "person.jochen", "novox.node-tools"} {
if _, err := v.Get(caller, "pair/postgres/gitea", asker.PublicKey); err == nil {
t.Errorf("the running vault answered %s", caller)
}
}
}
// The node-engine's sealing key (mesh-host internal/identity/sealing.go: raw X25519, base64, the NaCl
// anonymous box) is the same curve as the ADR 0206 box. A node's client asks with that key's public half as
// SPKI PEM, and opens the answer with its private half as PKCS #8 — no second key on the node, and no NaCl
// in the vault's answers. This is the path V4 builds on.
func TestANodesSealingKeyOpensTheAnswer(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "for-the-node", "the test"); err != nil {
t.Fatal(err)
}
// As mesh-host's GenerateSealingKey makes it.
priv, _ := ecdh.X25519().GenerateKey(rand.Reader)
nodePublic := base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes())
nodePrivate := base64.StdEncoding.EncodeToString(priv.Bytes())
rawPub, _ := base64.StdEncoding.DecodeString(nodePublic)
pubKey, _ := ecdh.X25519().NewPublicKey(rawPub)
spki, _ := x509.MarshalPKIXPublicKey(pubKey)
askWith := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spki}))
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", askWith)
if err != nil {
t.Fatal(err)
}
rawPriv, _ := base64.StdEncoding.DecodeString(nodePrivate)
privKey, _ := ecdh.X25519().NewPrivateKey(rawPriv)
pkcs8, _ := x509.MarshalPKCS8PrivateKey(privKey)
openWith := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}))
if got, err := seal.Open(answer.Sealed, openWith); err != nil || got != "for-the-node" {
t.Fatalf("the node's own sealing key does not open the answer: %q %v", got, err)
}
}
// ---- secret-changed carries no value --------------------------------------------------------------------
func TestSecretChangedCarriesNoValue(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Put("pair/postgres/gitea", "first-value", "g14.mesh-vault-test"); err != nil {
t.Fatal(err)
}
if _, err := v.Put("pair/postgres/gitea", "second-value", "person.jochen"); err != nil {
t.Fatal(err)
}
if _, err := v.Mint("own/g14/dunst/token", "the vault"); err != nil {
t.Fatal(err)
}
var changed []map[string]any
for _, e := range v.bus.events {
if e["event"] == ChangedEvent {
changed = append(changed, e["body"].(map[string]any))
}
}
if len(changed) != 3 {
t.Fatalf("%d secret-changed events, not 3: %v", len(changed), v.bus.events)
}
for i, body := range changed {
keys := make([]string, 0, len(body))
for k := range body {
keys = append(keys, k)
}
for _, k := range keys {
switch k {
case "name", "by", "at", "generation":
default:
t.Errorf("secret-changed carries %q, beyond who, when, which and its generation", k)
}
}
if body["by"] == "" || body["at"] == "" {
t.Errorf("secret-changed %d says not who or when: %v", i, body)
}
}
if changed[1]["by"] != "person.jochen" || changed[1]["generation"] != 2 {
t.Errorf("the second change says %v", changed[1])
}
minted, _, _ := v.Store.Read("own/g14/dunst/token")
mintedValue, _ := seal.Open(minted.AtRest, v.Own.PrivateKey)
for _, said := range v.bus.raw {
for _, value := range []string{"first-value", "second-value", mintedValue} {
if strings.Contains(said, value) {
t.Errorf("an event carries a value: %s", said)
}
}
}
}
// The same value again is no change: no new generation, no event.
func TestTheSameValueAgainIsNoChange(t *testing.T) {
v := newVault(t, nil)
for i := 0; i < 2; i++ {
if _, err := v.Put("pair/postgres/gitea", "same", "the test"); err != nil {
t.Fatal(err)
}
}
rec, _, _ := v.Store.Read("pair/postgres/gitea")
if rec.Generation != 1 {
t.Errorf("the same value twice is generation %d", rec.Generation)
}
n := 0
for _, e := range v.bus.events {
if e["event"] == ChangedEvent {
n++
}
}
if n != 1 {
t.Errorf("%d secret-changed events for one value", n)
}
}
// A secret's name is a name, not a path.
func TestASecretsNameIsNotAPath(t *testing.T) {
v := newVault(t, nil)
for _, name := range []string{"../etc/passwd", "/abs", "a//b", "A/b", "a/../b", "", strings.Repeat("a", 300)} {
if _, err := v.Put(name, "x", "the test"); err == nil {
t.Errorf("the vault kept a secret named %q", name)
}
}
}
// The vault's own key is made once, kept beside it, and the same on the next start.
func TestTheVaultsOwnKeyIsMadeOnce(t *testing.T) {
dir := t.TempDir()
a, err := OwnKey(dir, nil)
if err != nil {
t.Fatal(err)
}
b, err := OwnKey(dir, nil)
if err != nil {
t.Fatal(err)
}
if a != b {
t.Error("the vault's key changed between two starts")
}
info, _ := os.Stat(filepath.Join(dir, OwnKeyFile))
if info.Mode().Perm() != 0o600 {
t.Errorf("the vault's key is %v", info.Mode())
}
}
// The vault's key gone while its store holds secrets: no new key is made, and the vault does not start.
func TestAMissingKeyBesideSecretsIsRefused(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(v.dir, "state", OwnKeyFile)); err != nil {
t.Fatal(err)
}
if _, err := OwnKey(filepath.Join(v.dir, "state"), v.Store); err == nil || !strings.Contains(err.Error(), "MISSING") {
t.Fatalf("a new key was made beside secrets sealed to the lost one: %v", err)
}
if _, err := os.Stat(filepath.Join(v.dir, "state", OwnKeyFile)); err == nil {
t.Error("a key file was written all the same")
}
}
// A new operator key that fails partway leaves no copy sealed to the earlier key for good: the next call
// with the same key — every minute, from the mesh's export — finishes the rest.
func TestANewOperatorKeyFailingPartwayIsFinished(t *testing.T) {
v := newVault(t, nil)
for _, name := range []string{"a/one", "b/two", "c/three"} {
if _, err := v.Mint(name, "the test"); err != nil {
t.Fatal(err)
}
}
pub, priv := operatorKey(t)
v.Store.failWrite = func(r Record) error {
if r.Name == "b/two" {
return os.ErrPermission
}
return nil
}
n, err := v.SetOperatorKey(asPEM(t, pub), "the test")
if err == nil || n != 2 {
t.Fatalf("a failure partway said %d sealed again, err %v", n, err)
}
if left, _ := v.StillSealedToAnother(); left != 1 {
t.Fatalf("%d copies left on the earlier key, not 1", left)
}
v.Store.failWrite = nil
// The path the minute takes while the export holds the key: the same key again.
if n, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil || n != 1 {
t.Fatalf("the same key again sealed %d again (%v), not the 1 left", n, err)
}
if n, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil || n != 0 {
t.Fatalf("the same key a third time sealed %d (%v)", n, err)
}
all, _ := v.Store.List()
for _, rec := range all {
if rec.Operator.Key != pub {
t.Errorf("%s is still sealed to an earlier key", rec.Name)
}
openOperator(t, pub, priv, rec.Operator.Sealed)
}
}
// Who was handed which secret, and when, and who was refused, is in the vault's own store.
func TestWhoWasHandedWhatIsRecordedInTheStore(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "the-value", "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
if _, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey); err != nil {
t.Fatal(err)
}
_, _ = v.Get("ace.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
handed, err := v.Store.Handed(10)
if err != nil || len(handed) != 2 {
t.Fatalf("%v %v", handed, err)
}
if handed[1].To != "g14.node-provisioner" || handed[1].Outcome != "handed" || handed[1].Generation != 1 || handed[1].At.IsZero() {
t.Errorf("the hand-out is recorded as %+v", handed[1])
}
if handed[0].To != "ace.node-provisioner" || handed[0].Outcome != "refused" || handed[0].Why == "" {
t.Errorf("the refusal is recorded as %+v", handed[0])
}
raw, _ := os.ReadFile(filepath.Join(v.dir, "store", "handed.jsonl"))
if strings.Contains(string(raw), "the-value") {
t.Error("the record of hand-outs holds a value")
}
}
// The vault's operator copies reach the operator's recovery: secret_vault_export is a document
// `secret recover --from-export` reads, found by node "vault", module "mesh-vault" and the secret's name,
// and opened with the operator's private key as that command opens it (mesh-controller
// cmd/mesh-controller/secret.go keptFromExport, internal/secrets Open).
func TestTheOperatorRecoversAVaultSecretFromItsExport(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Put("pair/postgres/gitea", "recover-me", "the test"); err != nil {
t.Fatal(err)
}
answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{})
if err != nil {
t.Fatal(err)
}
file := filepath.Join(t.TempDir(), "vault-export.json")
raw, _ := json.Marshal(answer)
_ = os.WriteFile(file, raw, 0o600)
// What `secret recover vault mesh-vault pair/postgres/gitea --from-export <file>` reads.
var e struct {
Kept []struct {
Node, Module, Name, Sealed, Key string
} `json:"kept"`
}
read, _ := os.ReadFile(file)
if err := json.Unmarshal(read, &e); err != nil {
t.Fatal(err)
}
var sealed string
for _, k := range e.Kept {
if k.Node == "vault" && k.Module == "mesh-vault" && k.Name == "pair/postgres/gitea" {
sealed = k.Sealed
if k.Key != v.operatorPublic {
t.Errorf("the export names key %s, not the operator's", k.Key)
}
}
}
if sealed == "" {
t.Fatalf("the export holds no copy of the secret: %s", read)
}
// secrets.Open: the public half derived from the private key, then the anonymous box.
privRaw, _ := base64.StdEncoding.DecodeString(v.operatorPrivate)
key, _ := ecdh.X25519().NewPrivateKey(privRaw)
if got := openOperator(t, base64.StdEncoding.EncodeToString(key.PublicKey().Bytes()), v.operatorPrivate, sealed); got != "recover-me" {
t.Fatalf("recovered %q", got)
}
if strings.Contains(string(read), "recover-me") {
t.Error("the export holds the value in the clear")
}
}
// With no key in the export the minute calls Reseal, which finishes what is pending too.
func TestResealFinishesWhatIsPending(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Mint("a/one", "the test"); err != nil {
t.Fatal(err)
}
pub, _ := operatorKey(t)
v.Store.failWrite = func(Record) error { return os.ErrPermission }
if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err == nil {
t.Fatal("a failing write was not said")
}
v.Store.failWrite = nil
if n, err := v.Reseal(); err != nil || n != 1 {
t.Fatalf("%d %v", n, err)
}
}
// A secret is not handed out when who was handed it cannot be recorded.
func TestNoHandOutWithoutItsRecord(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)
}
if err := os.Mkdir(filepath.Join(v.dir, "store", "handed.jsonl"), 0o700); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
if a, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey); err == nil {
t.Fatalf("handed out with no record of it: %+v", a)
}
}
// What an asker cannot shape fills no record: a name that is no secret's name, a caller that is no bus user.
func TestAnArbitraryAskFillsNoRecord(t *testing.T) {
v := newVault(t, nil)
asker, _ := seal.GenerateKeyPair()
for _, ask := range [][2]string{{"g14.x", strings.Repeat("junk ", 1000)}, {"g14.x", "../../etc"},
{"not a bus user!", "pair/a"}, {strings.Repeat("a", 300), "pair/a"}} {
_, _ = v.Get(ask[0], ask[1], asker.PublicKey)
}
if h, _ := v.Store.Handed(10); len(h) != 0 {
t.Errorf("recorded %d arbitrary asks: %+v", len(h), h)
}
}
// The record of hand-outs is bounded: rolled at its size into a fixed number of files, the oldest dropped.
func TestTheRecordOfHandOutsIsBounded(t *testing.T) {
v := newVault(t, nil) // refuses everyone, as the running vault does
v.Store.HandedMax = 2000
asker, _ := seal.GenerateKeyPair()
for i := 0; i < 500; i++ {
_, _ = v.Get("g14.flood", "pair/postgres/gitea", asker.PublicKey)
}
var total int64
files, _ := filepath.Glob(filepath.Join(v.dir, "store", "handed.jsonl*"))
if len(files) != HandedKept+1 {
t.Errorf("%d files of the record, not %d: %v", len(files), HandedKept+1, files)
}
for _, f := range files {
info, _ := os.Stat(f)
if info.Size() > 2000 {
t.Errorf("%s is %d bytes, over its bound", f, info.Size())
}
total += info.Size()
}
if total > int64(HandedKept+1)*2000 {
t.Errorf("the record holds %d bytes", total)
}
if v.Store.Rolled() == 0 {
t.Error("rolling was not counted")
}
if h, _ := v.Store.Handed(5); len(h) != 5 || h[0].To != "g14.flood" {
t.Errorf("the newest are not read back: %+v", h)
}
}
+10
View File
@@ -0,0 +1,10 @@
module mesh-vault
go 1.25.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009
golang.org/x/crypto v0.50.0
)
require golang.org/x/sys v0.43.0 // indirect
+6
View File
@@ -0,0 +1,6 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-31
View File
@@ -1,31 +0,0 @@
// mesh-vault's events entrypoint, loaded by the per-node tool host (the provisioner runs in the same
// process — ADR 0052). The lifecycle events are EMITTED from the provisioner, where custody
// actually changes (novox/hq ADR 0041/0042):
// mesh-vault.provisioned — a consumer was granted a secret
// mesh-vault.rotated — that consumer's value changed (`rotate secret`)
// mesh-vault.deprovisioned — the consumer went away and its secret was withdrawn
// Here the vault reacts to them, keeping a lightweight audit line of who holds what and when it
// moved — the audit an owner of secrets is best placed to log. Fingerprints, never values.
import { on } from "@novox/mesh-sdk/events";
interface SecretEvent {
as: string;
consumer?: string;
fingerprint?: string;
rotations?: number;
}
await on<SecretEvent>("provisioned", async (e) => {
console.log(`[mesh-vault] secret provisioned for ${e.body?.as} on ${e.body?.consumer} (${e.body?.fingerprint})`);
});
await on<SecretEvent>("rotated", async (e) => {
console.log(`[mesh-vault] secret rotated for ${e.body?.as} — rotation ${e.body?.rotations} (${e.body?.fingerprint})`);
});
await on<SecretEvent>("deprovisioned", async (e) => {
console.log(`[mesh-vault] secret withdrawn from ${e.body?.as}`);
});
console.log("[mesh-vault] auditing secret lifecycle events");
+41 -20
View File
@@ -10,18 +10,10 @@
}
}
],
"capabilities": [
"container-runtime"
],
"emits": [
"provisioned",
"rotated",
"deprovisioned"
],
"consumes": [
"mesh-vault.provisioned",
"mesh-vault.rotated",
"mesh-vault.deprovisioned"
"secret-changed"
],
"receives": {
"secret": "${dir:grants}/mesh.json"
@@ -49,6 +41,12 @@
"path": "${dir:root}",
"class": "valuable",
"why": "the vault's root material"
},
{
"id": "store",
"path": "${dir:store}",
"class": "valuable",
"why": "every secret the vault holds, sealed to its own key and to the operator key"
}
],
"consumers": {
@@ -80,6 +78,11 @@
"id": "root",
"type": "directory",
"mode": "0700"
},
{
"id": "store",
"type": "directory",
"mode": "0700"
}
],
"build": {
@@ -87,29 +90,47 @@
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"language": "go",
"system": "arch",
"from": "cmd/mesh-vault",
"binary": "mesh-vault",
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
"mesh-vault"
],
"env": {
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_VAULT_LEDGER": "${dir:ledger}",
"MESH_VAULT_ROOT": "${dir:root}"
"MESH_VAULT_ROOT": "${dir:root}",
"MESH_VAULT_STORE": "${dir:store}",
"MESH_VAULT_STATE": "${dir:state}"
}
}
]
},
"tools": [
"secret_export",
"secret_holders",
"secret_verify",
"secret_handed",
"secret_asks",
"secret_vault_export",
"provisioner_retirement",
"provisioner_retire_approve",
"provisioner_retire_reject",
"provisioner_delete",
"vault_status"
],
"claims": [
{
"name": "mesh-vault",
"scope": "mesh"
"scope": "mesh",
"serves": [
"get",
"accept",
"secret-ask",
"issued",
"rotate"
]
}
]
}
-17
View File
@@ -1,17 +0,0 @@
{
"name": "@novox/module-mesh-vault",
"version": "0.1.0",
"description": "mesh-vault — provides the mesh `secret` interface: a module's own secret as an ordinary pair credential, held, audited and rotated like any other (novox/hq ADR 0085). Its ledger, provisioner, tools and events live here (ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-48
View File
@@ -1,48 +0,0 @@
// mesh-vault's provisioner — the adapter that makes vault a provider of the mesh `secret` interface. The
// reconcile loop, the contributions file, and reading the mesh's minted value are the sdk harness's;
// this writes only the per-service half (novox/hq ADR 0039/0040/0048) — and for a vault that half is
// taking custody, not creating anything.
//
// The `secret` interface (ADR 0085, design 24): a consumer requires a value for its own use — the
// password of a store it runs privately, an internal token — and reads it from the file the mesh
// writes on its machine. There is no server to create a login on. **The value is the pair
// credential itself**: the controller minted it, sealed it to both nodes, and delivered each its
// copy. What makes it *owned* is this: the vault records who holds it and its fingerprint, notices
// when `rotate secret` delivers a different one, and says so on the mesh. Rotation is not new
// machinery — it is the machinery that already moves a database password, pointed at a secret the
// vault provides (design 13).
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { Ledger } from "../client.js";
const ledger = Ledger.fromEnv();
/** Emit a lifecycle event without letting a broker hiccup fail the custody itself. */
async function announce(type: string, body: Record<string, string | number>): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[provisioner:secret] emit ${type} failed: ${err}`);
}
}
runProvisioner("secret", {
async create(p: Provision): Promise<void> {
const { held, outcome } = ledger.record(p.as, p.consumer ?? "", p.password);
if (outcome === "unchanged") return; // the harness re-runs create on restart; nothing happened
console.log(`[mesh-vault] ${outcome}: ${held.as} (${held.fingerprint.slice(0, 19)}…, rotations ${held.rotations})`);
await announce(`module.mesh-vault.secret.${outcome === "granted" ? "provisioned" : "rotated"}`, {
consumer: held.consumer,
as: held.as,
fingerprint: held.fingerprint,
rotations: held.rotations,
});
},
async remove(p: { as: string }): Promise<void> {
if (!ledger.withdraw(p.as)) return;
console.log(`[mesh-vault] withdrawn: ${p.as}`);
await announce("deprovisioned", { as: p.as });
},
});
-80
View File
@@ -1,80 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Ledger, fingerprint, contributions, deliveredFingerprint } from "../client.ts";
function fresh(): Ledger {
return new Ledger(mkdtempSync(join(tmpdir(), "vault-ledger-")));
}
test("a first delivery is a grant, the same value again is nothing, a new value is a rotation", () => {
const ledger = fresh();
const t0 = new Date("2026-09-20T10:00:00Z");
const t1 = new Date("2026-09-21T10:00:00Z");
const granted = ledger.record("anchor-redis", "anchor", "first-value", t0);
assert.equal(granted.outcome, "granted");
assert.equal(granted.held.rotations, 0);
assert.equal(granted.held.since, t0.toISOString());
assert.equal(granted.held.fingerprint, fingerprint("first-value"));
assert.equal(ledger.record("anchor-redis", "anchor", "first-value", t1).outcome, "unchanged");
assert.equal(ledger.get("anchor-redis")!.rotations, 0, "an unchanged delivery counted as a rotation");
const rotated = ledger.record("anchor-redis", "anchor", "second-value", t1);
assert.equal(rotated.outcome, "rotated");
assert.equal(rotated.held.rotations, 1);
assert.equal(rotated.held.since, t0.toISOString(), "a rotation reset the grant date");
assert.equal(rotated.held.changed, t1.toISOString());
assert.equal(rotated.held.fingerprint, fingerprint("second-value"));
assert.deepEqual(rotated.held.history, [{ fingerprint: fingerprint("first-value"), until: t1.toISOString() }]);
});
test("the ledger holds fingerprints and never the value, in files nobody else can read", () => {
const dir = mkdtempSync(join(tmpdir(), "vault-ledger-"));
const ledger = new Ledger(dir);
ledger.record("anchor-redis", "anchor", "the-actual-password", new Date());
ledger.record("anchor-redis", "anchor", "the-rotated-password", new Date());
for (const name of readdirSync(dir)) {
const raw = readFileSync(join(dir, name), "utf8");
assert.doesNotMatch(raw, /the-actual-password|the-rotated-password/, `${name} holds a value`);
assert.equal(statSync(join(dir, name)).mode & 0o777, 0o600, `${name} is readable by others`);
}
});
test("withdrawing forgets a holder, and listing is by login", () => {
const ledger = fresh();
ledger.record("b-app", "b", "x", new Date());
ledger.record("a-app", "a", "y", new Date());
assert.deepEqual(ledger.list().map((h) => h.as), ["a-app", "b-app"]);
assert.equal(ledger.withdraw("a-app"), true);
assert.equal(ledger.withdraw("a-app"), false, "withdrawing twice said it found something");
assert.deepEqual(ledger.list().map((h) => h.as), ["b-app"]);
});
test("a login is a name, not a path", () => {
const ledger = fresh();
assert.throws(() => ledger.record("../etc/passwd", "n", "v"), /a login is a name/);
});
test("what the mesh delivers is read from the contributions file and fingerprinted, never returned", () => {
const dir = mkdtempSync(join(tmpdir(), "vault-grants-"));
const secret = join(dir, "anchor.redis.secret");
writeFileSync(secret, "minted-value\n"); // the host may leave a trailing newline; the value has none
const receives = join(dir, "mesh.json");
writeFileSync(receives, JSON.stringify({
requirement: "secret",
given: [
{ from: "redis", node: "anchor", as: "anchor-redis", secret },
{ from: "offer-only", node: "anchor" }, // a contribution with no login grants nothing
],
}));
const asked = contributions(receives);
assert.deepEqual(asked.map((c) => c.as), ["anchor-redis"]);
const seen = deliveredFingerprint(asked[0]);
assert.deepEqual(seen, { fingerprint: fingerprint("minted-value"), length: "minted-value".length });
assert.match(JSON.stringify(deliveredFingerprint({ as: "x", secret: join(dir, "missing") })), /not readable/);
});
-131
View File
@@ -1,131 +0,0 @@
// mesh-vault's tools — vault's own code (novox/hq ADR 0039), served through the sdk's tool harness. They
// return structured data about the secrets this vault provides, and **never a value**: a holder is
// identified by its login and a value by its fingerprint. That is the rule the source mesh's
// secret_locate / secret_verify were built on, after a secret printed into a transcript.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { Ledger, contributions, deliveredFingerprint, type Held } from "../client.js";
/** The mesh's export of every operator-sealed secret, as the mesh wrote it into the root dir. */
interface KeptExport {
export: number;
"operator-key": string;
fingerprint: string;
kept: { node: string; module: string; name: string; origin: string; sealed: string; key: string; "made-at": string }[];
unrecoverable?: { node: string; module: string; name: string }[];
}
export function getVaultTools(ledger: Ledger, receives: string | undefined, root: string | undefined): ToolDefinition[] {
return [
{
name: "secret_export",
description:
"The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " +
"sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can " +
"open a line of it; the operator, holding the private key off the mesh, recovers one with " +
"`nox secret recover --from-export` on the control-node. Also lists what is NOT recoverable: secrets " +
"made before the mesh had an operator key.",
input: {
sealed: {
type: "boolean",
description: "include the sealed blobs (default true); false lists holders and the key only",
},
},
run: async (args) => {
if (!root) return { available: false, error: "this vault keeps no root secrets (MESH_VAULT_ROOT is not set)" };
let doc: KeptExport;
try {
doc = JSON.parse(readFileSync(join(root, "export.json"), "utf8")) as KeptExport;
} catch (err) {
return { available: false, error: `the mesh has not written an export here yet: ${err}` };
}
const withBlobs = args.sealed !== false;
return {
available: true,
export: doc.export,
"operator-key": doc["operator-key"],
fingerprint: doc.fingerprint,
count: doc.kept.length,
kept: doc.kept.map((k) => (withBlobs ? k : { node: k.node, module: k.module, name: k.name, origin: k.origin, "made-at": k["made-at"] })),
unrecoverable: doc.unrecoverable ?? [],
};
},
},
{
name: "secret_holders",
description:
"Who holds a secret from this vault: each consumer's login, node and module, when it was " +
"granted, how many times it has been rotated and when, and the fingerprint of the current " +
"value. Fingerprints only — the value is never returned.",
input: {},
run: async () => {
const asked = receives ? contributions(receives) : [];
const holders = ledger.list().map((h) => ({
...h,
module: asked.find((c) => c.as === h.as)?.from ?? null,
asked: asked.some((c) => c.as === h.as),
}));
return { holders, count: holders.length };
},
},
{
name: "secret_verify",
description:
"Check one holder's secret without seeing it: the fingerprint the vault recorded against " +
"the fingerprint of the value the mesh currently delivers here, and optionally against a " +
"fingerprint computed on the holder's own machine (sha256 of the file, as `sha256:<hex>`). " +
"Two ends agreeing proves they agree, not that either works — the login itself is the test.",
input: {
as: { type: "string", description: "the holder's login, e.g. anchor-redis" },
fingerprint: {
type: "string",
description: "optional: sha256:<hex> of the value as the holder reads it, computed there — never the value",
},
},
run: async (args) => {
const as = String(args.as ?? "");
const recorded = ledger.get(as);
if (!recorded) return { as, known: false, error: `this vault holds nothing for ${as}` };
const asked = receives ? contributions(receives).find((c) => c.as === as) : undefined;
const delivered = asked ? deliveredFingerprint(asked) : { error: "the mesh does not currently ask this vault to serve that login" };
const given = args.fingerprint ? String(args.fingerprint) : undefined;
return verdict(recorded, delivered, given);
},
},
];
}
function verdict(
recorded: Held,
delivered: { fingerprint: string; length: number } | { error: string },
given: string | undefined,
): Record<string, unknown> {
const deliveredMatches = "fingerprint" in delivered ? delivered.fingerprint === recorded.fingerprint : null;
const givenMatches = given === undefined ? null : given === recorded.fingerprint;
return {
as: recorded.as,
known: true,
recorded: recorded.fingerprint,
rotations: recorded.rotations,
changed: recorded.changed,
delivered: "fingerprint" in delivered ? delivered.fingerprint : null,
deliveredError: "error" in delivered ? delivered.error : null,
deliveredMatchesRecorded: deliveredMatches,
given: given ?? null,
givenMatchesRecorded: givenMatches,
givenIsAnEarlierValue: given === undefined ? null : recorded.history.some((h) => h.fingerprint === given),
ok: deliveredMatches !== false && givenMatches !== false,
};
}
// The tools exist only when the ledger can be reached from the environment; without it, vault
// contributes none rather than failing the whole tool runtime.
registerModuleTools("mesh-vault", (env) => {
try {
return getVaultTools(Ledger.fromEnv(env), env.MESH_RECEIVES, env.MESH_VAULT_ROOT);
} catch {
return [];
}
});
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"agent-managed-settings": {
"type": "object",
"description": "The managed settings every agent session on a node is held to (Claude Code's managed-settings.json), as a JSON object.",
"trusted": true
},
"font-size": {
"type": "number",
"description": "The size a desktop program shows its text in, in points.",
"minimum": 6,
"maximum": 72
}
}