From 84d8198f1540e1015d7e8975befd827c64f19f3c Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 00:57:54 +0000 Subject: [PATCH] mesh-vault: ADR 0349's vault tests (issue 674) Co-Authored-By: Claude Opus 5.5 --- .../mesh-vault/cmd/mesh-vault/vault_test.go | 503 ++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 modules/mesh-vault/cmd/mesh-vault/vault_test.go diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go new file mode 100644 index 00000000..b5e7fce8 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -0,0 +1,503 @@ +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[:]) +} + +// 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")) + 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(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. + if _, err := v.SetOperatorKey(keys.PrivateKey, "the test"); err == nil { + t.Error("the vault took a private key as 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(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")) + 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) + if err != nil { + t.Fatal(err) + } + b, err := OwnKey(dir) + 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()) + } +}