diff --git a/cmd/mesh-controller/vault_secrets_test.go b/cmd/mesh-controller/vault_secrets_test.go new file mode 100644 index 00000000..13d74c24 --- /dev/null +++ b/cmd/mesh-controller/vault_secrets_test.go @@ -0,0 +1,346 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "sync" + "testing" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/secrets" + "github.com/novox/mesh-controller/internal/testbus" +) + +// The controller's secret verbs reach the vault only for a module filled the new way (design 57's V2, issue 675, +// the release lead's decision on it; novox/hq ADR 0349 clauses 8 to 10 and 17). + +// standInVault answers the vault's two-step verbs as mesh-vault does: a key made for each call, the value opened +// with it here and nowhere else. It keeps what it took and every argument it was sent, as the bus carried them. +type standInVault struct { + t *testing.T + mu sync.Mutex + keys map[string]string // call → private key + taken map[string]string // secret → value + sent []string // every argument, as JSON + refuse error // refuses every first step when set + outcome []string + again bool // the value is held already +} + +func newStandInVault(t *testing.T) *standInVault { + return &standInVault{t: t, keys: map[string]string{}, taken: map[string]string{}} +} + +func (v *standInVault) ask(verb string, args map[string]any) (json.RawMessage, error) { + v.mu.Lock() + defer v.mu.Unlock() + raw, _ := json.Marshal(map[string]any{"verb": verb, "args": args}) + v.sent = append(v.sent, string(raw)) + if verb == "rotate" { + return json.Marshal(vaultChanged{Name: "own/g14/dunst/token", Generation: 2, By: fmt.Sprint(args["by"])}) + } + call, _ := args["call"].(string) + if call == "" { + if v.refuse != nil { + return nil, v.refuse + } + public, private, err := secrets.Keypair() + if err != nil { + v.t.Fatal(err) + } + call = fmt.Sprintf("call-%d", len(v.keys)+1) + v.keys[call] = private + secret := fmt.Sprintf("%s/%v", verb, args["name"]) + return json.Marshal(vaultOpened{Call: call, SealTo: public, Secret: secret, AskedBy: "as-the-vault-recorded"}) + } + private, ok := v.keys[call] + if !ok { + return nil, errors.New("no such call") + } + delete(v.keys, call) + if o, _ := args["outcome"].(string); o != "" { + v.outcome = append(v.outcome, o) + return json.Marshal(vaultChanged{Unchanged: true}) + } + value, err := secrets.Open(private, fmt.Sprint(args["sealed"])) + if err != nil { + return nil, errors.New("not sealed to this call") + } + // As mesh-vault takes a typed value: its line ending off (intake.go AskEnd). + v.taken[verb] = strings.TrimRight(string(value), "\r\n") + return json.Marshal(vaultChanged{Name: verb, Generation: 1, Unchanged: v.again}) +} + +func (v *standInVault) said() string { + v.mu.Lock() + defer v.mu.Unlock() + return strings.Join(v.sent, "\n") +} + +// No module's secrets are filled the new way before V4: every verb keeps today's path for every module. +func TestNoModulesSecretsAreTheVaultsBeforeV4(t *testing.T) { + for _, module := range []string{"telegram", "dunst", "plex", "postgres", "mesh-vault", "kometa", ""} { + if secretsOfTheVault(module) { + t.Errorf("%q is filled the new way before V4", module) + } + } +} + +// The switch: today's path for a module filled the old way, the vault's for one filled the new way, and never +// both. +func TestTheSwitchRunsOnePathByTheModulesForm(t *testing.T) { + moved := func(m string) bool { return m == "dunst" } + for module, want := range map[string]string{"telegram": "today", "dunst": "vault"} { + var ran []string + err := bySwitch(module, moved, + func() error { ran = append(ran, "today"); return nil }, + func() error { ran = append(ran, "vault"); return nil }) + if err != nil || strings.Join(ran, ",") != want { + t.Errorf("%s ran %v (%v), not %s", module, ran, err, want) + } + } + var ran []string + _ = bySwitch("dunst", nil, func() error { ran = append(ran, "today"); return nil }, + func() error { ran = append(ran, "vault"); return nil }) + if strings.Join(ran, ",") != "today" { + t.Errorf("no switch ran %v", ran) + } +} + +// `secret accept` for a module filled the new way: the value read at the terminal is sealed there to the key the +// vault made for this one call; no clear value crosses the bus or reaches the controller's log. Over a real bus, +// with a tap on every subject. +func TestAnAcceptedValueCrossesTheBusOnlySealedToTheVault(t *testing.T) { + conn, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(conn.Close) + before := handActConn + handActConn = conn + t.Cleanup(func() { handActConn = before }) + + var tapped bytes.Buffer + var tapMu sync.Mutex + tap, err := conn.Subscribe(">", func(m *nats.Msg) { + tapMu.Lock() + defer tapMu.Unlock() + tapped.WriteString(m.Subject + " " + string(m.Data) + "\n") + }) + if err != nil { + t.Fatal(err) + } + defer tap.Unsubscribe() + + vault := newStandInVault(t) + var subjects []string + holder, err := conn.Subscribe(broker.CalledPattern(link.SeatToolSubject("mesh-vault", "accept")), func(m *nats.Msg) { + subjects = append(subjects, m.Subject) + var args map[string]any + _ = json.Unmarshal(m.Data, &args) + result, err := vault.ask("accept", args) + answer := link.Answer{Result: result} + if err != nil { + answer = link.Answer{Error: err.Error()} + } + raw, _ := json.Marshal(answer) + _ = m.Respond(raw) + }) + if err != nil { + t.Fatal(err) + } + defer holder.Unsubscribe() + + var logged bytes.Buffer + log.SetOutput(&logged) + t.Cleanup(func() { log.SetOutput(nil) }) + + const value = "pw-of-last-year-that-must-not-show" + words, err := acceptIntoVault(theVault(context.Background()), provisionArgs("novox", "postgres"), value, "jochen") + if err != nil { + t.Fatal(err) + } + if vault.taken["accept"] != value { + t.Fatalf("the vault opened %q", vault.taken["accept"]) + } + if err := conn.Flush(); err != nil { + t.Fatal(err) + } + tapMu.Lock() + crossed := tapped.String() + tapMu.Unlock() + if !strings.Contains(crossed, "mesh.seat.mesh-vault.call.accept.controller") { + t.Fatalf("the tap heard no call to the vault: %s", crossed) + } + for _, s := range subjects { + if !strings.HasSuffix(s, ".controller") { + t.Errorf("the vault was asked on %s, not in the controller's name", s) + } + } + for where, text := range map[string]string{"the bus": crossed, "the log": logged.String(), "the answer": words} { + if strings.Contains(text, value) || strings.Contains(text, "must-not-show") { + t.Errorf("the value crossed %s in the clear", where) + } + } + if !strings.Contains(words, "sealed at this terminal") { + t.Errorf("the answer: %q", words) + } + // A vault that refuses the first step: nothing is sealed or sent. + vault.refuse = errors.New("the vault has no operator key") + if _, err := acceptIntoVault(vault.ask, ownSecretArgs("g14", "dunst", "token"), value, "jochen"); err == nil || + !strings.Contains(err.Error(), "nothing was read or sealed") { + t.Errorf("a refused call said %v", err) + } +} + +// The desk's path for a module filled the new way: the vault records the ask and holds its bounds before the +// prompt; the prompt names the asker as the vault recorded it; what is typed passes the controller sealed and +// unopened; today's store is not touched. For a module filled the old way, the vault is never asked. +func TestAnAskForAModuleFilledTheNewWayIsTheVaults(t *testing.T) { + d, accepted, acts, asked := aDesk(t, sealedTo(t, typed)) + d.askedBy = "g14/claude-code" + d.open = func(node, module, name, desk string) (int64, error) { + t.Fatal("today's record was asked") + return 0, nil + } + vault := newStandInVault(t) + d.vault = vault.ask + words, err := d.give("anchor", "telegram", "telegram-token", "laptop") + if err != nil { + t.Fatal(err) + } + if len(*accepted) != 0 { + t.Fatal("the controller opened and kept what was typed") + } + if vault.taken["secret-ask"] != typed { + t.Fatalf("the vault opened %q", vault.taken["secret-ask"]) + } + if p := (*asked)[0]; p["asked_by"] != "as-the-vault-recorded" || p["seal_to"] == "" { + t.Errorf("the prompt was asked %v", p) + } + if !strings.Contains(vault.said(), `"asked_by":"g14/claude-code"`) { + t.Errorf("the vault was not told who asked: %s", vault.said()) + } + if len(*acts) != 1 || (*acts)[0].Cause != "given-at-the-desk" || !strings.Contains((*acts)[0].Why, "kept in the vault") { + t.Errorf("the act: %+v", *acts) + } + raw, _ := json.Marshal(struct { + Words string + Acts []link.HandAct + Asked []map[string]any + }{words, *acts, *asked}) + if strings.Contains(string(raw)+vault.said(), typed) || strings.Contains(string(raw)+vault.said(), "AAEhBP0") { + t.Fatal("the value appears in what was said, asked, recorded or sent to the vault") + } + + // Dismissed: the vault's record ends the ask so. + d2, _, _, _ := aDesk(t, func(map[string]any) (json.RawMessage, error) { return json.RawMessage(`{"cancelled":true}`), nil }) + v2 := newStandInVault(t) + d2.vault = v2.ask + if _, err := d2.give("anchor", "telegram", "telegram-token", "laptop"); !errors.Is(err, errNothingGiven) { + t.Fatalf("%v", err) + } + if strings.Join(v2.outcome, ",") != "dismissed" { + t.Errorf("the vault was told %v", v2.outcome) + } + // The vault's bounds refuse: nobody is asked to type. + d3, _, _, asked3 := aDesk(t, sealedTo(t, typed)) + v3 := newStandInVault(t) + v3.refuse = errors.New("an ask for telegram-token of telegram on anchor is still open") + d3.vault = v3.ask + if _, err := d3.give("anchor", "telegram", "telegram-token", "laptop"); err == nil || + !strings.Contains(err.Error(), "nobody was asked to type anything") || len(*asked3) != 0 { + t.Errorf("a refused ask: %v, prompts %d", err, len(*asked3)) + } + + // Filled the old way: today's path, the vault never asked. + d4, accepted4, _, _ := aDesk(t, sealedTo(t, typed)) + if _, err := d4.give("anchor", "telegram", "telegram-token", "laptop"); err != nil { + t.Fatal(err) + } + if len(*accepted4) != 1 || d4.vault != nil { + t.Error("a module filled the old way did not take today's path") + } +} + +// `secret issued` for a provider filled the new way: the provider's tool seals to the vault's key for the call, +// the controller passes it on unopened, and nothing is kept, sealed or sent here. A provider filled the old way +// keeps today's path and the vault is never asked. +func TestAnIssuedValueForAProviderFilledTheNewWayGoesToTheVault(t *testing.T) { + var r issuedRecorder + vault := newStandInVault(t) + taker := r.taker(t, issuedSealed(t, "the-servers-own-token")) + taker.keep = func(string, string, string) (inventory.IssuedKept, error) { + t.Fatal("today's store was asked to keep it") + return inventory.IssuedKept{}, nil + } + taker.ofTheVault = func(m string) bool { return m == "plex" } + taker.vault = vault.ask + words, err := taker.take("plex-api") + if err != nil { + t.Fatal(err) + } + if vault.taken["issued"] != "the-servers-own-token" { + t.Fatalf("the vault opened %q", vault.taken["issued"]) + } + if len(r.sent) != 0 { + t.Errorf("machines were sent: %v", r.sent) + } + if len(r.acts) != 1 || r.acts[0].Verb != handActIssued || !strings.Contains(r.acts[0].Why, "kept in the vault") { + t.Errorf("the act: %+v", r.acts) + } + if !strings.Contains(vault.said(), `"provider":"ace"`) || !strings.Contains(vault.said(), `"module":"plex"`) { + t.Errorf("the vault was told %s", vault.said()) + } + for _, said := range []string{words, r.acts[0].Why, vault.said()} { + if strings.Contains(said, "the-servers-own-token") { + t.Fatalf("the value was said: %q", said) + } + } + // The same value again: the vault writes nothing, and nothing is recorded. + again := newStandInVault(t) + again.again = true + r2 := issuedRecorder{} + t2 := r2.taker(t, issuedSealed(t, "the-servers-own-token")) + t2.ofTheVault, t2.vault = func(string) bool { return true }, again.ask + if words, err := t2.take("plex-api"); err != nil || len(r2.acts) != 0 || !strings.Contains(words, "nothing was written") { + t.Errorf("%q %v %+v", words, err, r2.acts) + } + + // Filled the old way: today's path. + var r3 issuedRecorder + t3 := r3.taker(t, issuedSealed(t, "the-servers-own-token")) + t3.ofTheVault = func(string) bool { return false } + t3.vault = func(string, map[string]any) (json.RawMessage, error) { + t.Fatal("the vault was asked for a provider filled the old way") + return nil, nil + } + if _, err := t3.take("plex-api"); err != nil || len(r3.kept) != 1 { + t.Errorf("today's path: %v, kept %d", err, len(r3.kept)) + } +} + +// `secret rotate` for a module filled the new way asks the vault, naming who asked; nothing is sent. +func TestARotationForAModuleFilledTheNewWayIsTheVaults(t *testing.T) { + vault := newStandInVault(t) + words, err := rotateInVault(vault.ask, ownSecretArgs("g14", "dunst", "token"), "g14/claude-code") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(vault.said(), `"verb":"rotate"`) || !strings.Contains(vault.said(), `"by":"g14/claude-code"`) || + !strings.Contains(vault.said(), `"module":"dunst"`) { + t.Errorf("the vault was told %s", vault.said()) + } + if !strings.Contains(words, "generation 2") || !strings.Contains(words, "takes it from the vault") { + t.Errorf("%q", words) + } +}