diff --git a/checks/placement/go.mod b/checks/placement/go.mod index 3c12b7f7..c13b1baa 100644 --- a/checks/placement/go.mod +++ b/checks/placement/go.mod @@ -2,4 +2,4 @@ module checks/placement go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026174406-6df0dfe208c6 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026180300-f1d7c7377dc4 diff --git a/checks/placement/go.sum b/checks/placement/go.sum index f690b2c7..a95aba9b 100644 --- a/checks/placement/go.sum +++ b/checks/placement/go.sum @@ -6,6 +6,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026173336-9789c8379a74 h1:+LrIwBeG git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026173336-9789c8379a74/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026174406-6df0dfe208c6 h1:sIagBHevPfpISBRK2r5EemnhHEGubdfEx/l3K6ujomo= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026174406-6df0dfe208c6/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026180300-f1d7c7377dc4 h1:rKDfp+IHnv903jcCujf5IdTrWd3mnudpNCfKaKvqp3w= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026180300-f1d7c7377dc4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode.go b/modules/mesh-vault/cmd/mesh-vault/ownnode.go new file mode 100644 index 00000000..2b1e0dc7 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode.go @@ -0,0 +1,117 @@ +package main + +// A machine's node-engine composes its own modules (novox/hq ADR 0547 §2, issue 1126), and asks the vault for their own +// secrets sealed to its own key (ADR 0349 §11, ADR 0550 §5): the verb `own`, answered to the node-engine of a machine +// alone, about that machine alone (the machine is the caller's, as the bus proved it, never an argument). Each value is +// sealed to the sealing key the machine's engine stated (`node-key`), so what crosses the bus opens on that machine only, +// and the composition carries it sealed (no composition carries a secret in the clear). A module handed to the +// provisioner is left out: its client fills its secrets from `get` (ADR 0349 §17). + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// NodeOwnSecret is one own secret of a module on the asking machine, sealed to the machine's key. +type NodeOwnSecret struct { + Module string `json:"module"` + Name string `json:"name"` + Origin string `json:"origin"` + Generation int `json:"generation"` + Sealed seal.Box `json:"sealed"` +} + +// OwnAnswer is what `own` answers: the machine, the sealing key every value is sealed to (raw X25519, base64, as its +// engine stated it), and each own secret of its modules. +type OwnAnswer struct { + Node string `json:"node"` + Key string `json:"key"` + Own []NodeOwnSecret `json:"own"` +} + +// composeOrigin is the vault's origin of a value as the composition reads it: made by the mesh, or given (accepted at a +// terminal, typed at a desk, issued by a provider's software). +func composeOrigin(origin string) string { + if origin == "" || origin == OriginMade { + return OriginMade + } + return OriginAccepted +} + +// OwnOfNode answers the node-engine of a machine the own secrets of its modules, each sealed to the machine's stated key. +func (v *Vault) OwnOfNode(caller string) (OwnAnswer, error) { + node, ok := nodeEngineOf(caller) + if !ok { + if caller == "" || caller == stdio.Unproven { + caller = "an unproven caller" + } + return OwnAnswer{}, fmt.Errorf("own answers a machine's node-engine alone, about its own machine; %s is not a node-engine", caller) + } + key, had, err := v.Store.NodeKey(node) + if err != nil { + return OwnAnswer{}, err + } + if !had { + return OwnAnswer{}, fmt.Errorf("the vault holds no secret named own/%s yet: %s's node-engine has stated no sealing key "+ + "(node-key), so there is no key of its own to seal to", node, node) + } + to, err := publicPEM(key) + if err != nil { + return OwnAnswer{}, fmt.Errorf("%s's sealing key as recorded is not usable: %w", node, err) + } + all, err := v.Store.List() + if err != nil { + return OwnAnswer{}, err + } + out := OwnAnswer{Node: node, Key: key} + for _, r := range all { + parts := strings.Split(r.Name, "/") + if len(parts) != 4 || parts[0] != "own" || parts[1] != node { + continue + } + if v.handed(node, parts[2]) { + continue + } + value, err := seal.Open(r.AtRest, v.Own.PrivateKey) + if err != nil { + return OwnAnswer{}, fmt.Errorf("%s does not open with the vault's key: %w", r.Name, err) + } + sealed, err := seal.Seal(value, to) + if err != nil { + return OwnAnswer{}, err + } + if err := v.recordHanding(Handing{To: caller, Name: r.Name, Generation: r.Generation, Outcome: "handed"}); err != nil { + return OwnAnswer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", r.Name, err) + } + out.Own = append(out.Own, NodeOwnSecret{Module: parts[2], Name: parts[3], Origin: composeOrigin(r.Origin), + Generation: r.Generation, Sealed: sealed}) + } + sort.Slice(out.Own, func(i, j int) bool { + if out.Own[i].Module != out.Own[j].Module { + return out.Own[i].Module < out.Own[j].Module + } + return out.Own[i].Name < out.Own[j].Name + }) + v.log("handed %s's node-engine the %d own secrets of its modules, sealed to its key", node, len(out.Own)) + return out, nil +} + +// handed says the plan of module on node hands it to the provisioner, so its secrets are filled from `get`, not composed. +// Where the plans cannot be read, nothing counts as handed: the composer leaves out a handed module's secrets itself. +func (v *Vault) handed(node, module string) bool { + d, ok := v.Askers.(Declared) + if !ok || d.Read == nil { + return false + } + raw, err := standing(d.Read)(node + "." + module) + if err != nil || raw == nil { + return false + } + var a plannedSecrets + return json.Unmarshal(raw, &a) == nil && a.Provisioning != nil +} diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go new file mode 100644 index 00000000..71cc40b2 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// Issue 1126 (ADR 0547 §2, ADR 0550 §5): `own` answers a machine's node-engine the own secrets of its modules, sealed to +// the key that machine's engine stated, about that machine alone; another caller, another machine's secrets and a +// machine with no stated key get nothing. +func TestOwnAnswersAMachineItsModulesSecretsSealedToItsKey(t *testing.T) { + v := newVault(t, nil) + anchorPub, anchorPriv := aSealingKey(t) + laptopPub, _ := aSealingKey(t) + for _, m := range []struct { + caller string + of MoveOf + value string + }{ + {"node.anchor", MoveOf{Node: "anchor", Module: "web", Name: "password", Origin: "made", Generation: "g-1", SealingKey: anchorPub}, "anchor-web-pw"}, + {"node.anchor", MoveOf{Node: "anchor", Module: "web", Name: "api-key", Origin: "accepted", Generation: "g-1", SealingKey: anchorPub}, "anchor-web-key"}, + {"node.laptop", MoveOf{Node: "laptop", Module: "web", Name: "password", Origin: "made", Generation: "g-1", SealingKey: laptopPub}, "laptop-web-pw"}, + } { + if _, err := moveIn(t, v, m.caller, m.of, m.value); err != nil { + t.Fatal(err) + } + } + got, err := v.OwnOfNode("node.anchor") + if err != nil { + t.Fatal(err) + } + if got.Node != "anchor" || got.Key != anchorPub || len(got.Own) != 2 { + t.Fatalf("anchor was answered %+v", got) + } + want := map[string][2]string{"api-key": {"accepted", "anchor-web-key"}, "password": {"made", "anchor-web-pw"}} + for _, s := range got.Own { + w, ok := want[s.Name] + if !ok || s.Module != "web" || s.Origin != w[0] || s.Generation < 1 { + t.Errorf("anchor's %s: %+v", s.Name, s) + continue + } + value, err := seal.Open(s.Sealed, anchorPriv) + if err != nil || string(value) != w[1] { + t.Errorf("anchor's %s does not open to its value with anchor's key: %q, %v", s.Name, value, err) + } + } + for caller, says := range map[string]string{ + "controller": "is not a node-engine", + "anchor.web": "is not a node-engine", + "": "an unproven caller", + "node.unknown": "no secret named own/unknown yet", + } { + if _, err := v.OwnOfNode(caller); err == nil || !strings.Contains(err.Error(), says) { + t.Errorf("%q: %v, want a refusal saying %q", caller, err, says) + } + } + if laptop, err := v.OwnOfNode("node.laptop"); err != nil || len(laptop.Own) != 1 || laptop.Own[0].Name != "password" { + t.Errorf("laptop was answered %+v, %v; want its one secret alone", laptop, err) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 1bd6fe9f..c17e4af1 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -251,6 +251,20 @@ func definitions(env *Env) []toolDef { // clause 9, adopt.go): a public half, never the private one. return map[string]any{"recorded": true, "vault_key": v.Own.PublicKey}, nil }}, + {Name: Seat + ".own", + Description: "The own secrets of this machine's modules, each sealed to the machine's sealing key (novox/hq ADR 0547 §2, " + + "ADR 0550 §5, issue 1126): from the node-engine of the machine alone, about its own machine (the bus proves which), " + + "for the compositions it makes itself. Answers the machine, the key each value is sealed to, and per module and " + + "name the origin, the generation and the sealed value; a module handed to the provisioner is left out. Never a value " + + "in the clear.", + Input: map[string]any{"type": "object", "properties": map[string]any{}}, + run: func(caller string, _ map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + return v.OwnOfNode(caller) + }}, {Name: Seat + ".move-asks", Description: "Copy rows of the controller's record of asks at a desk into the vault's, as they are and once " + "(design 57's V3): from the controller alone, at most 500 at a time. No value is in any of them.", diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index cdff2835..304b95df 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -177,7 +177,8 @@ "rotate", "move-in", "node-key", - "move-asks" + "move-asks", + "own" ] } ]