diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index d6b3fe3..0736705 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -795,5 +795,9 @@ func selfComposer(ctx context.Context, session *link.Session, node string, say f return h[len(h)-1].Value, nil }, Mesh: frombus.Mesh{InternalSuffix: overlay.Suffix()}, - Say: say}) + Say: say, + // Its modules' own secrets, sealed by the vault to this machine's key (ADR 0550 §5). + Own: func(ctx context.Context) (json.RawMessage, error) { + return session.AskVault(ctx, "own", map[string]any{}) + }}) } diff --git a/go.mod b/go.mod index 87c7994..9570a41 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026174406-6df0dfe208c6 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026180300-f1d7c7377dc4 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 4861dd1..6679278 100644 --- a/go.sum +++ b/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/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 91c167b..7b29c9b 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -327,6 +327,7 @@ func TestStartingOnWhatTheKeptDeclarationHoldsAppliesNothing(t *testing.T) { // What the node-engine itself last applied is what runs after a restart, not the kept declaration. func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) { + t.Run("says the have again", testStartingOnItsOwnHaveSaysTheHaveAgainSoItsHealthIsThisEngines) b := newBed(t) fp := fpOf("a") ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1")) @@ -346,7 +347,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) { // it and writes the health this engine judges. Not said, its key kept the health an older engine wrote: xorg's have on // g14 and shanks said unknown from before the engine said a module with nothing to judge ok, and the assigner held every // module requiring x11-display back for it (2026-10-25). -func TestStartingOnItsOwnHaveSaysTheHaveAgainSoItsHealthIsThisEngines(t *testing.T) { +func testStartingOnItsOwnHaveSaysTheHaveAgainSoItsHealthIsThisEngines(t *testing.T) { b := newBed(t) fp := fpOf("a") ck, craw, dig := composition(t, "xorg", fp, false, file("xorg", "x", "1")) @@ -599,6 +600,7 @@ func TestTheNodesOwnCompositionComesBeforeAnyModules(t *testing.T) { // R6-A AC9: a composition signed by either trusted key is applied; one signed by another key, one changed after it was // signed and one unsigned are refused and nothing of them is applied (ADR 0539 §3). func TestOnlyACompositionSignedByATrustedKeyIsApplied(t *testing.T) { + t.Run("with no trusted key", testWithNoTrustedKeyNothingIsApplied) b := newBed(t) stop := b.run() defer stop() @@ -649,7 +651,7 @@ func TestOnlyACompositionSignedByATrustedKeyIsApplied(t *testing.T) { } // With no key trusted, nothing is applied: a node that does not know its signers fails closed. -func TestWithNoTrustedKeyNothingIsApplied(t *testing.T) { +func testWithNoTrustedKeyNothingIsApplied(t *testing.T) { b := newBed(t) b.env.Trusted = nil stop := b.run() diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index 42f5aa0..47bd659 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -19,6 +19,7 @@ import ( "sync" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" "git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -38,6 +39,9 @@ type Env struct { Get func(ctx context.Context, bucket, key string) ([]byte, error) Mesh frombus.Mesh Say func(string) + // Own asks the vault's `own` in this node's engine's name: its modules' own secrets sealed to its key (ADR 0550 §5). + // Nil asks nothing, and a module with an own secret does not compose. + Own func(ctx context.Context) (json.RawMessage, error) } // Composer composes the node and is the agent's watcher of its compositions (agent.Env.Composed). @@ -52,9 +56,12 @@ type Composer struct { values map[string]map[string]any // by module, this node's manifests map[string][]byte // by digest, read once handed map[string]string // composed key to the digest handed - said string - caught bool - rev uint64 + // sealedOwn is each own secret's sealed value as first handed, by module/name and generation: a value sealed again + // is other bytes, so the one first had is kept while the generation stands, and the composition stays the same. + sealedOwn map[string]ownSealed + said string + caught bool + rev uint64 } // New is a composer for one node. @@ -64,7 +71,14 @@ func New(env Env) *Composer { } return &Composer{env: env, wants: map[string]delivery.Want{}, placements: map[string]delivery.Placement{}, records: map[string]delivery.NodeRecord{}, modules: map[string]delivery.Module{}, values: map[string]map[string]any{}, - manifests: map[string][]byte{}, handed: map[string]string{}} + manifests: map[string][]byte{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{}} +} + +// ownSealed is one own secret's sealed value at a generation and the key it is sealed to. +type ownSealed struct { + generation int + key string + sealed string } // input is one watched bucket and how its updates are taken. @@ -492,3 +506,44 @@ func (r reader) Values(_ context.Context, node, module string) (map[string]any, } return out, nil } + +func (r reader) OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecretRow, error) { + if node != r.c.env.Node || r.c.env.Own == nil { + return nil, nil + } + raw, err := r.c.env.Own(ctx) + if err != nil { + return nil, fmt.Errorf("the vault's own: %w", err) + } + var a struct { + Node string `json:"node"` + Key string `json:"key"` + Own []struct { + Module string `json:"module"` + Name string `json:"name"` + Origin string `json:"origin"` + Generation int `json:"generation"` + Sealed json.RawMessage `json:"sealed"` + } `json:"own"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("the vault's own answer is not one: %w", err) + } + if a.Node != node { + return nil, fmt.Errorf("the vault answered %q's own secrets to %s", a.Node, node) + } + r.c.mu.Lock() + defer r.c.mu.Unlock() + out := make([]compose.OwnSecretRow, 0, len(a.Own)) + for _, o := range a.Own { + id := o.Module + "/" + o.Name + kept, ok := r.c.sealedOwn[id] + if !ok || kept.generation != o.Generation || kept.key != a.Key { + kept = ownSealed{generation: o.Generation, key: a.Key, sealed: string(o.Sealed)} + r.c.sealedOwn[id] = kept + } + out = append(out, compose.OwnSecretRow{Node: node, Module: o.Module, Name: o.Name, Origin: o.Origin, + SealedTo: a.Key, Sealed: kept.sealed}) + } + return out, nil +} diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 35c87bf..b928394 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -2,6 +2,8 @@ package selfcompose import ( "context" + "encoding/json" + "fmt" "strings" "sync" "testing" @@ -84,14 +86,24 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - c := New(b.env()) + env := b.env() + // The vault's own: keeper's password sealed to n1's key, sealed again (other bytes) at every ask. + var asks, generation int + env.Own = func(context.Context) (json.RawMessage, error) { + b.mu.Lock() + defer b.mu.Unlock() + asks++ + return json.RawMessage(fmt.Sprintf(`{"node":"n1","key":"sk","own":[{"module":"keeper","name":"password","origin":"made",`+ + `"generation":%d,"sealed":{"ask":%d}}]}`, generation+1, asks)), nil + } + c := New(env) out, err := c.Watch(ctx, "n1.>") if err != nil { t.Fatal(err) } place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []delivery.PlacedModule{{Module: "lemurs"}}}) - have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", Capabilities: []string{"systemd"}}}) + have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) want, _ := delivery.EncodeWant(delivery.Want{FP: fp}) nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{}) @@ -156,4 +168,44 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { if n != 1 { t.Errorf("the fault was said %d times, want once: %v", n, b.said) } + + // An own secret comes sealed from the vault into the composition; sealed again at the same generation it is the value + // first had, so nothing is handed again; a new generation is handed. + b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) + if u := next(t, out); u.Key != lemursKey { + t.Fatalf("lemurs wanted back handed %s", u.Key) + } + keeper := `{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` + + `"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}` + kdigest := delivery.ManifestDigest([]byte(keeper)) + kman, _ := delivery.EncodeManifest(delivery.Manifest{Module: "keeper", JSON: keeper}) + kkey, _ := delivery.ManifestsKey(kdigest) + b.mu.Lock() + b.kv[delivery.ManifestsBucket+"/"+kkey] = kman + b.mu.Unlock() + kmod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: kdigest, Repository: "novox/k", Commit: "c"}) + both, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, + Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "keeper"}}}) + b.put(t, delivery.ModulesBucket, "keeper", kmod) + b.put(t, delivery.PlacementBucket, "placement.n1", both) + b.put(t, delivery.AssignerWantsBucket, "want.n1.keeper", want) + keeperKey, _ := delivery.ComposedKey("n1", "keeper", fp) + var handed string + for handed == "" { + if u := next(t, out); u.Key == keeperKey { + handed = string(u.Value) + } + } + if !strings.Contains(handed, `{\"ask\":`) { + t.Fatalf("keeper's composition does not carry the sealed value: %s", handed) + } + b.put(t, ResolvedBucket, "n1.other.k", []byte(`{"value":2}`)) + quiet(t, out) + b.mu.Lock() + generation++ + b.mu.Unlock() + b.put(t, ResolvedBucket, "n1.other.k", []byte(`{"value":3}`)) + if u := next(t, out); u.Key != keeperKey { + t.Fatalf("a new generation handed %s, want keeper again", u.Key) + } }