diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 554c6a1..d1a3d8c 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -38,6 +38,15 @@ func beyondTheControllers(user string) map[string]bool { return out } +// mayBeyondTheControllers is what the copy may grant beyond the controller's where the records allow it, and need not +// where they do not: a node's ask of the vault's `own` while the vault serves it (issue 1126, ADR 0550 §5). +func mayBeyondTheControllers(user string) map[string]bool { + if _, ok := strings.CutPrefix(user, "node."); !ok { + return nil + } + return map[string]bool{VaultOwnCall(Principal{Kind: KindNode, Node: strings.TrimPrefix(user, "node.")}): true} +} + // quotedToken is a quoted token of a composed list. var quotedToken = regexp.MustCompile(`"[^"]*"`) @@ -89,7 +98,7 @@ func grantsAsTheControllers(t *testing.T, got, want string) { } } for g := range now { - if !was[g] && !intended[g] && !createdAdvisory.MatchString(`"`+g+`", `) { + if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) { t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g) } } @@ -194,6 +203,22 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS") } }) + // Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no + // other node's. + t.Run("a node asks the vault its own secrets", func(t *testing.T) { + one := Principal{Kind: KindNode, Node: "one", AsksItsOwnSecrets: true} + perms, err := PermissionsFor(one) + if err != nil { + t.Fatal(err) + } + if !MayPublish(perms, VaultOwnCall(one)) || MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})) { + t.Errorf("node one asks own as %v, and as two %v", MayPublish(perms, VaultOwnCall(one)), + MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"}))) + } + if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one"}); MayPublish(plain, VaultOwnCall(one)) { + t.Error("a node asks own while the vault does not serve it") + } + }) t.Run("the controller's golden server configuration", func(t *testing.T) { seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} got, err := Compose( diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 8f5379b..067fbd4 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -213,6 +213,10 @@ type Principal struct { // StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name // (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new. StatesItsKeyToTheVault bool + // AsksItsOwnSecrets is a machine principal whose node-engine may ask the vault's `own` in its own name (novox/hq + // ADR 0547 §2, ADR 0550 §5, issue 1126): its modules' own secrets sealed to its key, for the compositions it makes + // itself. Set while the vault's holder serves it. + AsksItsOwnSecrets bool // FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants // part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows). FollowsGrantsUserList bool @@ -344,6 +348,10 @@ var VaultMoveIn = SeatVerb{Seat: "mesh-vault", Verb: "move-in"} // novox/hq ADR 0372); granted as Principal.StatesItsKeyToTheVault says. var VaultNodeKey = SeatVerb{Seat: "mesh-vault", Verb: "node-key"} +// VaultOwn is the vault's verb a machine's node-engine asks in its own name for its modules' own secrets sealed to its +// key (issue 1126, ADR 0550 §5); granted as Principal.AsksItsOwnSecrets says. +var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"} + // VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant // chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the // controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release @@ -923,6 +931,11 @@ func PermissionsFor(p Principal) (Permissions, error) { if p.StatesItsKeyToTheVault { pub = append(pub, "mesh.seat."+VaultNodeKey.Seat+"."+CallKind+"."+VaultNodeKey.Verb+"."+CallerToken(p.Username())) } + // And, while the vault serves it, its modules' own secrets in its own name (issue 1126): sealed to its key, for the + // compositions it makes itself. + if p.AsksItsOwnSecrets { + pub = append(pub, VaultOwnCall(p)) + } // And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR // 0547 clause 7): one key, and an object by its name. if p.FollowsGrantsUserList { @@ -1792,3 +1805,8 @@ const ( nodeToolsService = "node-tools" leaseKey = "holder" ) + +// VaultOwnCall is the subject a machine principal's node-engine asks the vault's `own` on, in its own name. +func VaultOwnCall(p Principal) string { + return "mesh.seat." + VaultOwn.Seat + "." + CallKind + "." + VaultOwn.Verb + "." + CallerToken(p.Username()) +} diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 472a0c4..56dbcc9 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -159,6 +159,7 @@ func Users(r Records) ([]Principal, error) { } out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks, MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), + AsksItsOwnSecrets: servesVerb(r, VaultOwn), FollowsGrantsUserList: bus}) // **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one // per machine that runs it, delivered to the module running it. diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 0c7386a..ae1dba4 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -19,10 +19,11 @@ // - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the // caller composes every node (ADR 0547 §2, the prepare step's L4). // -// **Not yet read** (each a later subtask of issue 1126): licences, own secrets, issued credentials and core boxes, which -// the vault answers sealed to the node (ADR 0550 §3, §5). Without them the composition stands in for each and names it -// foreseen, as compose.Reading does for a value the Input does not hold. No node is joining before ADR 0547's third -// milestone (ADR 0550 §6), so Joining is always empty. +// **Own secrets** come from the vault sealed to the composing node's key (OwnSecrets), for that node alone. **Not yet +// read** (each a later subtask of issue 1126): licences, pair credentials, issued credentials and core boxes. Without one +// the composition stands in for it, and Compose refuses a composition that stands in for anything (compose.ErrStoodIn): +// a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is +// always empty. package frombus import ( @@ -54,6 +55,9 @@ type Reader interface { // Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`, // keys `..`), decoded; nil when it has none. Values(ctx context.Context, node, module string) (map[string]any, error) + // OwnSecrets is the own secrets of the composing node's modules, each sealed to that node's key, as the vault answers + // the node's engine (the vault's verb `own`, ADR 0550 §5); asked only for the node composing itself. + OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecretRow, error) } // Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the @@ -197,6 +201,10 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er if _, ok := records[s.Node]; !ok { return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node) } + // Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5). + if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil { + return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err) + } } return in, nil } diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index 8e93364..8fdc0d5 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -26,6 +26,7 @@ type bus struct { manifests map[string][]byte values map[string]map[string]any // by node.module valuesRead []string + own []compose.OwnSecretRow } func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { @@ -40,6 +41,8 @@ func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) { } return raw, nil } +func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.own, nil } + func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) { b.valuesRead = append(b.valuesRead, node+"."+module) return b.values[node+"."+module], nil @@ -86,6 +89,7 @@ func TestInput(t *testing.T) { modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "unread": {}}, manifests: map[string][]byte{digest: xterm}, values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"}}, + own: []compose.OwnSecretRow{{Node: "laptop", Module: "xterm", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, } } @@ -112,9 +116,10 @@ func TestInput(t *testing.T) { Ports: map[string]map[int]int{"xterm": {80: 8080}}, Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}}}}, }, - Holdings: []catalogue.Held{{Claim: "mesh-terminal", Scope: "mesh", Node: "anchor", Module: "xterm"}}, - Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}}, - Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true}, + Holdings: []catalogue.Held{{Claim: "mesh-terminal", Scope: "mesh", Node: "anchor", Module: "xterm"}}, + OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "xterm", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, + Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}}, + Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true}, // The controller's order is by address; laptop enrolled under its peer's key, and a person named the other. CarriedPeers: []compose.CarriedPeer{{PublicKey: keys["laptop"], Address: "10.77.0.2", EnrolledAs: "laptop"}, {PublicKey: "peer", Address: "10.77.0.9", Named: "phone"}}, @@ -149,8 +154,8 @@ func TestInput(t *testing.T) { } b = mk() - if _, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil { - t.Fatal(err) + if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil { + t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets) } if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm"}) { // Every node's placed modules, and nothing placed nowhere: anchor places no xterm.