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