package main // Issue 786 (ADR 0372): a machine's node-engine states its own sealing key to the vault, in its own name, at start and // when it changes, whether or not it ever moved a secret in. The caller is the bus user the runtime proved; a changed // key (or a first one) raises `secret-changed` for the machine's core secrets, the same key says nothing. import ( "git.novox.be/novox/mesh-sdk/go/seal" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" ) func changedNames(p *published) []string { p.mu.Lock() defer p.mu.Unlock() var out []string for _, e := range p.events { if e["event"] == ChangedEvent { out = append(out, e["body"].(map[string]any)["name"].(string)) } } return out } func TestANodeEngineStatesItsOwnKeyAndAChangeRaisesItsCoreSecrets(t *testing.T) { v := newVault(t, nil) tool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".node-key") // ace holds two core secrets and a leaf module's; g14 holds a core secret too. old, _ := aSealingKey(t) for _, m := range []MoveOf{ {Node: "ace", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", Generation: "g-1", SealingKey: old}, {Node: "ace", Module: "nats", Name: "broker", Origin: "made", Generation: "g-1"}, {Node: "ace", Module: "postgres", Name: "superuser-password", Origin: "made", Generation: "g-1"}, {Node: "g14", Module: "nats", Name: "broker", Origin: "made", Generation: "g-1"}, } { value := movedValue if m.Name != "superuser-password" { value = brokerValue // a bus account is kept only as one } if _, err := moveIn(t, v, "node."+m.Node, m, value); err != nil { t.Fatal(err) } } // ace never stated a key in the moves above except through the first row; forget it to be a machine that never moved. if err := os.Remove(v.Store.nodeKeyPath("ace")); err != nil { t.Fatal(err) } before := len(changedNames(v.bus)) pub, _ := aSealingKey(t) if _, err := tool.runAs("controller", map[string]any{"key": pub}); err == nil { t.Error("the controller stated a machine's key") } if _, err := tool.runAs("", map[string]any{"key": pub}); err == nil { t.Error("an unproven caller stated a key") } if _, err := tool.runAs("node.ace", map[string]any{"key": "c2hvcnQ="}); err == nil { t.Error("a short key was taken") } if _, err := tool.runAs("node.ace", map[string]any{"node": "g14", "key": pub}); err != nil { t.Fatal(err) } // The machine is the caller's; an argument naming another one changes nothing. if got, held, _ := v.Store.NodeKey("ace"); !held || got != pub { t.Errorf("ace's key is %q, %v", got, held) } if _, held, _ := v.Store.NodeKey("g14"); held { t.Error("a key was recorded for g14 by ace's engine") } got := changedNames(v.bus)[before:] want := []string{"own/ace/mesh-provisioner-client/provisioner-broker", "own/ace/nats/broker"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Errorf("secret-changed was raised for %v, want %v", got, want) } // The same key again changes nothing and says nothing. if _, err := tool.runAs("node.ace", map[string]any{"key": pub}); err != nil { t.Fatal(err) } if n := len(changedNames(v.bus)); n != before+2 { t.Errorf("the same key raised %d more", n-before-2) } // A different key raises them again. other, _ := aSealingKey(t) if _, err := tool.runAs("node.ace", map[string]any{"key": other}); err != nil { t.Fatal(err) } if n := len(changedNames(v.bus)); n != before+4 { t.Errorf("a changed key raised %d, want 2", n-before-2) } if got, _, _ := v.Store.NodeKey("ace"); got != other { t.Error("the changed key was not recorded") } } const nodeKeyBus = `listen: 127.0.0.1:-1 authorization { users = [ { user: "novox.mesh-vault", password: "pw", permissions: { publish: { deny: [">"] } subscribe: { allow: ["mesh.seat.mesh-vault.call.node-key.*", "mesh.seat.mesh-vault.call.own.*"] } allow_responses: true } } { user: "node.ace", password: "pw", permissions: { publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_ace", "mesh.seat.mesh-vault.call.own.node_ace"] } subscribe: { allow: ["_INBOX.node.ace.>"] } } } { user: "node.g14", password: "pw", permissions: { publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_g14", "mesh.seat.mesh-vault.call.own.node_g14"] } subscribe: { allow: ["_INBOX.node.g14.>"] } } } { user: "controller", password: "pw", permissions: { publish: { allow: ["mesh.seat.mesh-vault.call.own.controller"] } subscribe: { allow: ["_INBOX.controller.>"] } } } ] } ` // On a real bus whose accounts may publish only their own subject: node.ace's statement records ace's key; node.g14 // cannot speak in ace's name, and what it states is g14's. func TestOnARealBusANodeEngineStatesOnlyItsOwnKey(t *testing.T) { file := filepath.Join(t.TempDir(), "bus.conf") if err := os.WriteFile(file, []byte(nodeKeyBus), 0o600); err != nil { t.Fatal(err) } opts, err := server.ProcessConfigFile(file) if err != nil { t.Fatal(err) } opts.NoLog, opts.NoSigs = true, true s, err := server.NewServer(opts) if err != nil { t.Fatal(err) } go s.Start() if !s.ReadyForConnections(10 * time.Second) { t.Fatal("the bus did not come up") } t.Cleanup(s.Shutdown) v := newVault(t, nil) v.Askers = Declared{Read: assignments{"ace.web": `{"node":"ace","module":"web"}`}.read, Store: v.Store} tools := map[string]toolDef{"node-key": toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".node-key"), "own": toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".own")} vaultConn := connectAs(t, s.ClientURL(), "novox.mesh-vault") serve := func(m *nats.Msg) { parts := strings.Split(m.Subject, ".") var args map[string]any _ = json.Unmarshal(m.Data, &args) answer := busAnswer{} if got, err := tools[parts[len(parts)-2]].runAs(untoken(parts[len(parts)-1]), args); err != nil { answer.Error = err.Error() } else { answer.Result, _ = json.Marshal(got) } raw, _ := json.Marshal(answer) _ = m.Respond(raw) } for _, verb := range []string{"node-key", "own"} { if _, err := vaultConn.Subscribe("mesh.seat.mesh-vault.call."+verb+".*", serve); err != nil { t.Fatal(err) } } _ = vaultConn.Flush() call := func(user, subject string, args any) (json.RawMessage, error) { raw, _ := json.Marshal(args) msg, err := connectAs(t, s.ClientURL(), user).Request(subject, raw, 3*time.Second) if err != nil { return nil, err } var a busAnswer _ = json.Unmarshal(msg.Data, &a) if a.Error != "" { return nil, errString(a.Error) } return a.Result, nil } say := func(user, subject, key string) (string, error) { _, err := call(user, subject, map[string]string{"key": key}) return "", err } aceKey, acePriv := aSealingKey(t) g14Key, _ := aSealingKey(t) if _, err := say("node.ace", "mesh.seat.mesh-vault.call.node-key.node_ace", aceKey); err != nil { t.Fatal(err) } if got, held, _ := v.Store.NodeKey("ace"); !held || got != aceKey { t.Errorf("ace's key is %q, %v", got, held) } // g14 may not publish on ace's subject: the bus refuses it, so the vault never hears it. if _, err := say("node.g14", "mesh.seat.mesh-vault.call.node-key.node_ace", g14Key); err == nil { t.Error("node.g14 spoke in ace's name") } if got, _, _ := v.Store.NodeKey("ace"); got != aceKey { t.Error("ace's key was replaced by g14's") } if _, err := say("node.g14", "mesh.seat.mesh-vault.call.node-key.node_g14", g14Key); err != nil { t.Fatal(err) } if got, held, _ := v.Store.NodeKey("g14"); !held || got != g14Key { t.Errorf("g14's key is %q, %v", got, held) } // Issue 1126 (ADR 0547 §2, ADR 0550 §5): `own` on the same bus. node.ace asks its own and gets its module's secret // sealed to the key it stated, opening with its key alone; g14 cannot ask in ace's name, and asking in its own gets // none of ace's; the controller is refused, as no node-engine. if _, err := moveIn(t, v, "node.ace", MoveOf{Node: "ace", Module: "web", Name: "password", Origin: "made", Generation: "g-1", SealingKey: aceKey}, "ace-web-pw"); err != nil { t.Fatal(err) } raw, err := call("node.ace", "mesh.seat.mesh-vault.call.own.node_ace", map[string]any{}) var own OwnAnswer if err != nil || json.Unmarshal(raw, &own) != nil || own.Node != "ace" || len(own.Own) != 1 { t.Fatalf("ace's own over the bus: %s, %v", raw, err) } if value, err := seal.Open(own.Own[0].Sealed, acePriv); err != nil || string(value) != "ace-web-pw" { t.Errorf("ace's secret does not open with ace's key: %q, %v", value, err) } if strings.Contains(string(raw), "ace-web-pw") { t.Error("ace's secret crossed the bus in the clear") } if _, err := call("node.g14", "mesh.seat.mesh-vault.call.own.node_ace", map[string]any{}); err == nil { t.Error("node.g14 asked ace's own in ace's name") } if raw, err := call("node.g14", "mesh.seat.mesh-vault.call.own.node_g14", map[string]any{}); err != nil || strings.Contains(string(raw), "web") { t.Errorf("g14 asking its own was answered %s, %v", raw, err) } if _, err := call("controller", "mesh.seat.mesh-vault.call.own.controller", map[string]any{}); err == nil || !strings.Contains(err.Error(), "not a node-engine") { t.Errorf("the controller asking own: %v", err) } }