Hand a module exactly when the vault's own answer judged it handed, so the node never composes a secret the vault leaves to the provisioner (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-host@a4713e2d28d7
mesh/delivery-group group feat/072-moves merged: every member is merged
mesh/check failed: shanks: the check of novox/mesh-controller#487 failed: go mod download in modules/route-proxy: go: git.novox.be/novox/mesh-controll…

This commit is contained in:
2026-10-31 09:49:14 +01:00
parent a4c0cee251
commit 8bdfddab4a
4 changed files with 37 additions and 2 deletions
+20
View File
@@ -19,6 +19,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"reflect"
"slices"
"sort"
@@ -113,6 +114,10 @@ type Composer struct {
// 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
// ownHanded is, by module, whether the vault judged it handed to the provisioner in its last own answer (its
// `handed`), nil when that answer said nothing of it (a vault older than the field): the composition hands exactly
// those (frombus.HandedReader, issue 774).
ownHanded map[string]bool
// certified is the serving key a certificate was last asked for successfully, so it is asked once per key.
certified string
said string
@@ -1279,6 +1284,7 @@ func (r reader) OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecre
Generation int `json:"generation"`
Sealed json.RawMessage `json:"sealed"`
} `json:"own"`
Handed map[string]bool `json:"handed"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("the vault's own answer is not one: %w", err)
@@ -1288,6 +1294,7 @@ func (r reader) OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecre
}
r.c.mu.Lock()
defer r.c.mu.Unlock()
r.c.ownHanded = a.Handed
out := make([]compose.OwnSecretRow, 0, len(a.Own))
for _, o := range a.Own {
// The box first had is kept while its generation stands, so sealing it again moves no composition; the box the
@@ -1304,6 +1311,19 @@ func (r reader) OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecre
return out, nil
}
// Handed is, of this node's modules, which the vault judged handed to the provisioner when it last answered own
// (frombus.HandedReader, issue 774): the node hands exactly those, so the vault and the node judge a module one way.
func (r reader) Handed(_ context.Context, node string) map[string]bool {
if node != r.c.env.Node {
return nil
}
r.c.mu.Lock()
defer r.c.mu.Unlock()
return maps.Clone(r.c.ownHanded)
}
var _ frombus.HandedReader = reader{}
func (r reader) Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
if node != r.c.env.Node || r.c.env.Own == nil {
return nil, nil, nil
+12 -1
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"os"
"reflect"
"slices"
"strconv"
"strings"
@@ -163,7 +164,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
asks++
sealedAt[fmt.Sprintf(`{"ask":%d}`, asks)] = generation + 1
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
`"generation":%d,"sealed":{"ask":%d}}],"handed":{"keeper":false}}`, generation+1, asks)), nil
}
secretChanged := make(chan []byte, 1)
env.Hear = func(subject string) (<-chan []byte, func(), error) {
@@ -767,6 +768,16 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
if rotated.Key != keeperKey {
t.Fatalf("a new generation handed %s, want keeper again", rotated.Key)
}
// Issue 774: what the vault judged handed in its own answer is what the node's composition hands, for n1 alone.
b.mu.Lock()
asked := asks
b.mu.Unlock()
if h := (reader{c: c}).Handed(ctx, "n1"); asked == 0 || !reflect.DeepEqual(h, map[string]bool{"keeper": false}) {
t.Errorf("the vault's handed verdicts as the composition reads them, after %d asks: %v", asked, h)
}
if h := (reader{c: c}).Handed(ctx, "n2"); h != nil {
t.Errorf("another node's verdicts: %v", h)
}
// Review of L2 (round 2): rotated, then the node-engine restarts. The agent has applied nothing yet, so the box the
// node runs comes from keeper's have (Held); the vault seals the value again, other bytes, and the box the have holds