From 1faf317069190d6c21c7ffd3a950af143831fdfe Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 20:12:21 +0100 Subject: [PATCH] =?UTF-8?q?Review=20of=20L2:=20the=20vault's=20own=20fails?= =?UTF-8?q?=20closed:=20a=20pair=20credential's=20consumer=20is=20read=20s?= =?UTF-8?q?tanding,=20the=20shared=20value=20goes=20only=20to=20a=20holder?= =?UTF-8?q?=20its=20record=20names,=20and=20unread=20assignments=20refuse,?= =?UTF-8?q?=20never=20hand=20(C1,=20C2);=20the=20placement=20check=20pins?= =?UTF-8?q?=20mesh-sdk=203968849=20(issue=201126,=20ADR=200550=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- checks/placement/go.mod | 2 +- checks/placement/go.sum | 2 + .../mesh-vault/cmd/mesh-vault/nodekey_test.go | 1 + modules/mesh-vault/cmd/mesh-vault/ownnode.go | 103 +++++++++++++++--- .../mesh-vault/cmd/mesh-vault/ownnode_test.go | 29 +++++ 5 files changed, 121 insertions(+), 16 deletions(-) diff --git a/checks/placement/go.mod b/checks/placement/go.mod index 676b2ed7..dba4ac0a 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.20261026183837-9713c3023a18 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36 diff --git a/checks/placement/go.sum b/checks/placement/go.sum index 790d7b5e..b8d29e3f 100644 --- a/checks/placement/go.sum +++ b/checks/placement/go.sum @@ -14,6 +14,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182519-53c2def1a520 h1:twiziR88 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026182519-53c2def1a520/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026183837-9713c3023a18 h1:HWNPfnT9YT9O6xHPLy2y6Q+LYT8XRjvLnZw9O61NETk= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026183837-9713c3023a18/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36 h1:aBkmFUq/zAVdR3PzfgjjC9AxSymNl3eVC7UEZv9tZ0Q= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026190832-3968849b1b36/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/nodekey_test.go b/modules/mesh-vault/cmd/mesh-vault/nodekey_test.go index 5a0c738c..aca4fc1d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/nodekey_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/nodekey_test.go @@ -140,6 +140,7 @@ func TestOnARealBusANodeEngineStatesOnlyItsOwnKey(t *testing.T) { 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") diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode.go b/modules/mesh-vault/cmd/mesh-vault/ownnode.go index fb71a9c7..b873dc44 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ownnode.go +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode.go @@ -9,6 +9,7 @@ package main import ( "encoding/json" + "errors" "fmt" "sort" "strings" @@ -83,10 +84,17 @@ func (v *Vault) credentialRefusal(node string, c CredentialAsked) string { if c.Consumer != node { return fmt.Sprintf("%s asks as a consumer on %s, and is not that machine", node, c.Consumer) } - if d, ok := v.Askers.(Declared); ok && d.Read != nil { - if raw, err := standing(d.Read)(node + "." + c.ConsumerModule); err == nil && raw == nil { - return fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", c.ConsumerModule, node) - } + // The consuming module stands on the machine: read, never assumed (an assignment that cannot be read refuses). + read, why := v.assignmentReader(node) + if why != "" { + return why + } + raw, err := read(node + "." + c.ConsumerModule) + if err != nil { + return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", node, c.ConsumerModule, err) + } + if raw == nil { + return fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", c.ConsumerModule, node) } return "" case "provider": @@ -98,6 +106,47 @@ func (v *Vault) credentialRefusal(node string, c CredentialAsked) string { return fmt.Sprintf("the role %q is not consumer or provider", c.Role) } +// assignmentReader is the vault's reader of the assignments, or why it has none: the rule `own` keeps reads them, +// and without them it refuses rather than hands (a rule that cannot be read is no rule). +func (v *Vault) assignmentReader(node string) (AssignmentReader, string) { + d, ok := v.Askers.(Declared) + if !ok || d.Read == nil { + return nil, "the vault cannot read the assignments, so it cannot tell what " + node + "'s modules are; nothing is handed" + } + return standing(d.Read), "" +} + +// sharedRefusal says why node may not have the provision's one shared value `provision//` for c, +// or "": the vault's record of who holds it names the consumer's module among its consumers, or the machine as its +// provider. Without that record nothing is handed: the shared value is not one any machine may have by naming a +// provider (ADR 0550 ยง5). +func (v *Vault) sharedRefusal(node string, c CredentialAsked) (string, error) { + name := "provision/" + c.Provider + "/" + c.Provision + sh, ok, err := v.Store.ReadShared(name) + if err != nil { + return "", err + } + if !ok { + return fmt.Sprintf("the vault keeps no record of who holds %s", name), nil + } + switch c.Role { + case "consumer": + want := consumerOf(c.Consumer, c.ConsumerModule, c.Local) + for _, held := range sh.Consumers { + if held == want { + return "", nil + } + } + return fmt.Sprintf("%s is held by %s, not by %s", name, strings.Join(sh.Consumers, ", "), want), nil + case "provider": + if sh.Provider == node { + return "", nil + } + return fmt.Sprintf("%s is provided from %s, not %s", name, sh.Provider, node), nil + } + return fmt.Sprintf("the role %q is not consumer or provider", c.Role), nil +} + // 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 { @@ -138,7 +187,11 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e if len(parts) != 4 || parts[0] != "own" || parts[1] != node { continue } - if v.handed(node, parts[2]) { + handed, err := v.handed(node, parts[2]) + if err != nil { + return OwnAnswer{}, err + } + if handed { continue } value, err := seal.Open(r.AtRest, v.Own.PrivateKey) @@ -167,7 +220,8 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e c.Provision, c.Provider, c.ConsumerModule, c.Consumer, why) } found := false - for _, name := range credentialNames(c) { + names := credentialNames(c) + for i, name := range names { r, held, err := v.Store.Read(name) if err != nil { return OwnAnswer{}, err @@ -175,6 +229,17 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e if !held { continue } + if i == len(names)-1 { + // The provision's one shared value: handed only to a machine the record of its holders names. + why, err := v.sharedRefusal(node, c) + if err != nil { + return OwnAnswer{}, err + } + if why != "" { + v.log("%s is not handed %s: %s", node, name, why) + 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", name, err) @@ -200,16 +265,24 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e } // 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 +// A plan that cannot be read is an error, never "not handed": `own` refuses rather than hands a secret the provisioner +// holds. +func (v *Vault) handed(node, module string) (bool, error) { + read, why := v.assignmentReader(node) + if why != "" { + return false, errors.New(why) } - raw, err := standing(d.Read)(node + "." + module) - if err != nil || raw == nil { - return false + raw, err := read(node + "." + module) + if err != nil { + return false, fmt.Errorf(notYetPrefix+"the assignment %s.%s could not be read, so whether it is handed to the "+ + "provisioner is not known: %w", node, module, err) + } + if raw == nil { + return false, nil } var a plannedSecrets - return json.Unmarshal(raw, &a) == nil && a.Provisioning != nil + if err := json.Unmarshal(raw, &a); err != nil { + return false, fmt.Errorf("the assignment %s.%s is not the controller's: %w", node, module, err) + } + return a.Provisioning != nil, nil } diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go index 7693bb36..131e4455 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "strings" "testing" @@ -11,7 +12,9 @@ import ( // 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) { + plans := assignments{"anchor.web": `{"node":"anchor","module":"web"}`, "laptop.web": `{"node":"laptop","module":"web"}`} v := newVault(t, nil) + v.Askers = Declared{Read: plans.read, Store: v.Store} anchorPub, anchorPriv := aSealingKey(t) laptopPub, _ := aSealingKey(t) for _, m := range []struct { @@ -89,4 +92,30 @@ func TestOwnAnswersAMachineItsModulesSecretsSealedToItsKey(t *testing.T) { t.Errorf("%s asking %+v: %v", who, c, err) } } + + // Review of L2 (C1, C2): `own` fails closed. A provision's one shared value is handed only to a consumer the record + // of its holders names, never to a machine that names its provider; assignments that cannot be read, or a vault + // that reads none, refuse rather than hand. + if _, err := v.keep("provision/anchor/redis", "shared-redis", "test", "", OriginMade); err != nil { + t.Fatal(err) + } + if err := v.Store.WriteShared(Shared{Name: "provision/anchor/redis", Provider: "anchor", Provision: "redis", + Consumers: []string{"anchor/web"}}); err != nil { + t.Fatal(err) + } + if got, err := v.OwnOfNode("node.laptop", none); err != nil || len(got.Credentials) != 0 || len(got.Missing) != 1 { + t.Errorf("laptop, whose web the shared value's record does not name, got %+v, %v", got, err) + } + anchorRedis := CredentialAsked{Role: "consumer", Provision: "redis", Consumer: "anchor", ConsumerModule: "web", Provider: "anchor"} + if got, err := v.OwnOfNode("node.anchor", anchorRedis); err != nil || len(got.Credentials) != 1 { + t.Errorf("anchor's web, a holder the record names, got %+v, %v", got, err) + } + v.Askers = Declared{Read: func(string) ([]byte, error) { return nil, errors.New("the bucket did not answer") }, Store: v.Store} + if _, err := v.OwnOfNode("node.anchor"); err == nil || !strings.Contains(err.Error(), "could not be read") { + t.Errorf("own with the assignments unread: %v", err) + } + v.Askers = nil + if _, err := v.OwnOfNode("node.anchor", anchorRedis); err == nil || !strings.Contains(err.Error(), "cannot read the assignments") { + t.Errorf("own with no reader of the assignments: %v", err) + } }