diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode.go b/modules/mesh-vault/cmd/mesh-vault/ownnode.go index 2b1e0dc7..fb71a9c7 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ownnode.go +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode.go @@ -27,11 +27,75 @@ type NodeOwnSecret struct { } // 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. +// engine stated it), each own secret of its modules, and each pair credential it asked for that the vault holds. type OwnAnswer struct { - Node string `json:"node"` - Key string `json:"key"` - Own []NodeOwnSecret `json:"own"` + Node string `json:"node"` + Key string `json:"key"` + Own []NodeOwnSecret `json:"own"` + Credentials []NodeCredential `json:"credentials,omitempty"` + Missing []CredentialAsked `json:"missing,omitempty"` +} + +// CredentialAsked names one pair credential by its ends, as a node composing its own asks for it (mesh-sdk +// compose.CredentialKey): Role is `consumer` when the asking machine consumes it, `provider` when it grants it. +type CredentialAsked struct { + Role string `json:"role"` + Provision string `json:"provision"` + Consumer string `json:"consumer"` + ConsumerModule string `json:"consumer_module"` + Provider string `json:"provider"` + Local string `json:"local,omitempty"` +} + +// NodeCredential is one pair credential answered, sealed to the asking machine's key, with the name the vault keeps it +// under (`pair/...` per pair, `provision////` issued for one holder, or the provision's +// one shared value `provision//`). +type NodeCredential struct { + CredentialAsked + Name string `json:"name"` + Generation int `json:"generation"` + Sealed seal.Box `json:"sealed"` +} + +// credentialNames are the names a pair credential may be kept under, the most particular first. +func credentialNames(c CredentialAsked) []string { + pair := "pair/" + c.Provider + "/" + c.Provision + "/" + c.Consumer + "/" + c.ConsumerModule + if c.Local != "" { + pair += "/" + c.Local + } + return []string{pair, "provision/" + c.Provider + "/" + c.Provision + "/" + c.Consumer + "/" + c.ConsumerModule, + "provision/" + c.Provider + "/" + c.Provision} +} + +// credentialRefusal says why node may not have c, or "": a consumer's credential to the machine its consuming module +// stands on, a provider's to the providing machine, and nothing to a machine that is neither. +func (v *Vault) credentialRefusal(node string, c CredentialAsked) string { + for _, part := range []string{c.Provision, c.Consumer, c.ConsumerModule, c.Provider} { + if !namePart.MatchString(part) { + return fmt.Sprintf("%q is not a name the mesh writes", part) + } + } + if c.Local != "" && !namePart.MatchString(c.Local) { + return fmt.Sprintf("%q is not a name the mesh writes", c.Local) + } + switch c.Role { + case "consumer": + 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) + } + } + return "" + case "provider": + if c.Provider != node { + return fmt.Sprintf("%s asks as the provider %s, and is not that machine", node, c.Provider) + } + return "" + } + return fmt.Sprintf("the role %q is not consumer or provider", c.Role) } // composeOrigin is the vault's origin of a value as the composition reads it: made by the mesh, or given (accepted at a @@ -44,7 +108,7 @@ func composeOrigin(origin string) string { } // 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) { +func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, error) { node, ok := nodeEngineOf(caller) if !ok { if caller == "" || caller == stdio.Unproven { @@ -97,7 +161,41 @@ func (v *Vault) OwnOfNode(caller string) (OwnAnswer, error) { } 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)) + for _, c := range asked { + if why := v.credentialRefusal(node, c); why != "" { + return OwnAnswer{}, fmt.Errorf("%s may not ask for the credential of %s from %s to %s on %s: %s", caller, + c.Provision, c.Provider, c.ConsumerModule, c.Consumer, why) + } + found := false + for _, name := range credentialNames(c) { + r, held, err := v.Store.Read(name) + if err != nil { + return OwnAnswer{}, err + } + if !held { + 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) + } + sealed, err := seal.Seal(value, to) + if err != nil { + return OwnAnswer{}, err + } + if err := v.recordHanding(Handing{To: caller, Name: 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", name, err) + } + out.Credentials = append(out.Credentials, NodeCredential{CredentialAsked: c, Name: name, Generation: r.Generation, Sealed: sealed}) + found = true + break + } + if !found { + out.Missing = append(out.Missing, c) + } + } + v.log("handed %s's node-engine the %d own secrets of its modules and %d credentials (%d not held), sealed to its key", + node, len(out.Own), len(out.Credentials), len(out.Missing)) return out, nil } diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go index 71cc40b2..7693bb36 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go @@ -59,4 +59,34 @@ func TestOwnAnswersAMachineItsModulesSecretsSealedToItsKey(t *testing.T) { 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) } + + // A pair credential: laptop's web consumes anchor's postgres. Each end is answered the one value sealed to its own key; + // a credential the vault holds none of is named missing; a machine at neither end is refused. + if _, err := moveIn(t, v, "node.laptop", MoveOf{Provider: "anchor", Provision: "postgres", Consumer: "laptop", + ConsumerModule: "web", Origin: "made", Generation: "g-1", SealingKey: laptopPub}, "pg-pair"); err != nil { + t.Fatal(err) + } + pair := CredentialAsked{Provision: "postgres", Consumer: "laptop", ConsumerModule: "web", Provider: "anchor"} + asConsumer, asProvider := pair, pair + asConsumer.Role, asProvider.Role = "consumer", "provider" + none := CredentialAsked{Role: "consumer", Provision: "redis", Consumer: "laptop", ConsumerModule: "web", Provider: "anchor"} + laptop, err := v.OwnOfNode("node.laptop", asConsumer, none) + if err != nil || len(laptop.Credentials) != 1 || len(laptop.Missing) != 1 || laptop.Missing[0] != none { + t.Fatalf("laptop as consumer: %+v, %v", laptop, err) + } + if !strings.HasPrefix(laptop.Credentials[0].Name, "pair/anchor/postgres/laptop/web") { + t.Errorf("laptop's credential is kept as %s", laptop.Credentials[0].Name) + } + anchor, err := v.OwnOfNode("node.anchor", asProvider) + if err != nil || len(anchor.Credentials) != 1 { + t.Fatalf("anchor as provider: %+v, %v", anchor, err) + } + if value, err := seal.Open(anchor.Credentials[0].Sealed, anchorPriv); err != nil || string(value) != "pg-pair" { + t.Errorf("anchor's copy does not open to the value with its key: %q, %v", value, err) + } + for who, c := range map[string]CredentialAsked{"node.anchor": asConsumer, "node.laptop": asProvider} { + if _, err := v.OwnOfNode(who, c); err == nil || !strings.Contains(err.Error(), "is not that machine") { + t.Errorf("%s asking %+v: %v", who, c, err) + } + } } diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index c17e4af1..2ef64c8d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -255,15 +255,29 @@ func definitions(env *Env) []toolDef { 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) { + "name the origin, the generation and the sealed value; a module handed to the provisioner is left out. With credentials, " + + "also each pair credential the machine consumes or grants, sealed the same way. Never a value in the clear.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "credentials": map[string]any{"type": "array", "description": "the pair credentials the machine's composition " + + "reads, each {role: consumer|provider, provision, consumer, consumer_module, provider, local}: answered " + + "sealed to the machine's key when the machine is that consumer's or that provider, and named missing when " + + "the vault holds none", "items": map[string]any{"type": "object"}}}}, + run: func(caller string, a map[string]any) (any, error) { v, err := env.ready() if err != nil { return nil, err } - return v.OwnOfNode(caller) + var asked []CredentialAsked + if raw, ok := a["credentials"]; ok && raw != nil { + b, err := json.Marshal(raw) + if err == nil { + err = json.Unmarshal(b, &asked) + } + if err != nil { + return nil, fmt.Errorf("credentials is a list of {role, provision, consumer, consumer_module, provider, local}: %w", err) + } + } + return v.OwnOfNode(caller, asked...) }}, {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 " +