The node's composer asks the vault's own for its modules' secrets sealed to its key, and keeps a value first had while its generation stands; two agent tests folded to hold the count (issue 1126, ADR 0550 §5)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 2dc384d641a2 on ff9386dcccc2 | novox/mesh-host#149 in group feat/1126-nodes-compose
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 2dc384d641a2 on ff9386dcccc2 | novox/mesh-host#149 in group feat/1126-nodes-compose
This commit is contained in:
@@ -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{})
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user