A node's shadow answer counts the values that drifted from the vault's, and names the own secrets among them (issue 1126, cause C4)

delivery.OwnValuesDiffer, ValuesDiffer and WithValuesDiffer read the shown bodies masked as another value; a candidate
answer carries values_differ. The node agent hands the vault those own secrets' running values through move-in's
replace_running, so no seat verb and no bus grant changes here.
This commit is contained in:
2026-10-28 09:49:03 +01:00
parent 16b8d8a1ee
commit 1062b33d32
3 changed files with 105 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
package delivery
// The own secrets a node's shadow answer finds the vault holds another value for than the node runs (novox/hq issue 1126,
// cause C4): the vault's `move-in` never replaces a value it holds, so a value the controller made anew later (a rotation
// at start, a re-issue) runs while the vault keeps the first one it was handed. The node opens both seals with its own
// key and its answer's shown bodies mask the value as MaskedAnotherValue; this reads which own secrets those are, for the
// node agent (which hands the vault what runs) and for the vault's `own_drift` (which lists them mesh-wide), so both read
// the answer one way.
import (
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// MaskedAnotherValue is the word a shown body masks a value with when the node opened both sides with its key and they
// are two values (MaskedPair, maskWord).
const MaskedAnotherValue = "<masked: another value>"
// OwnValueDiffers is one own secret of a module whose value composed (the vault's) is another than the one that runs.
type OwnValueDiffers struct {
Module string `json:"module"`
Name string `json:"name"`
}
// ownNeedPrefix starts the id of the file a module's own secret is placed in (catalogue.NeedID).
var ownNeedPrefix = catalogue.NeedID("")
// OwnValuesDiffer is every own secret a's shown bodies say holds another value composed than running: a body of a
// module's own secret's file (catalogue.NeedID) masked MaskedAnotherValue on either side. Sorted by module and name, each
// once. A body the answer did not show (beyond MaxBodies) is not read.
func OwnValuesDiffer(a CandidateAnswer) []OwnValueDiffers {
seen := map[OwnValueDiffers]bool{}
var out []OwnValueDiffers
for _, d := range a.Differs {
for _, b := range d.Bodies {
name, own := strings.CutPrefix(b.ID, ownNeedPrefix)
if !own || name == "" || !anotherValue(b) {
continue
}
o := OwnValueDiffers{Module: d.Module, Name: name}
if !seen[o] {
seen[o] = true
out = append(out, o)
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Module != out[j].Module {
return out[i].Module < out[j].Module
}
return out[i].Name < out[j].Name
})
return out
}
// ValuesDiffer is how many shown bodies of a hold a value masked MaskedAnotherValue, own secrets and every other
// resource alike: the count a node's answer carries as CandidateAnswer.ValuesDiffer.
func ValuesDiffer(a CandidateAnswer) int {
n := 0
for _, d := range a.Differs {
for _, b := range d.Bodies {
if anotherValue(b) {
n++
}
}
}
return n
}
// WithValuesDiffer is answers with each one's ValuesDiffer counted.
func WithValuesDiffer(answers map[string]CandidateAnswer) map[string]CandidateAnswer {
out := make(map[string]CandidateAnswer, len(answers))
for g, a := range answers {
a.ValuesDiffer = ValuesDiffer(a)
out[g] = a
}
return out
}
func anotherValue(b ResourceBodies) bool {
return strings.Contains(b.Composed, MaskedAnotherValue) || strings.Contains(b.Running, MaskedAnotherValue)
}
+4
View File
@@ -141,6 +141,10 @@ type CandidateAnswer struct {
// Note is what the node says of how it answered, in words, beside the verdict: a pass that ran over a minute, inputs
// it waited for (issue 1126: the shadow shows its work). It changes no verdict.
Note string `json:"note,omitempty"`
// ValuesDiffer is how many shown bodies hold a value the node opened on both sides to two values (ValuesDiffer,
// MaskedAnotherValue): the vault's value and the one that runs drifted apart (issue 1126, cause C4). It changes no
// verdict.
ValuesDiffer int `json:"values_differ,omitempty"`
}
// ReturnsToController is what a node answers a head that deletes its placement file.
+17
View File
@@ -354,6 +354,23 @@ func TestPlacement(t *testing.T) {
!strings.Contains(c, "<masked: the same on both sides>") {
t.Errorf("masked as %s and %s", c, r)
}
// Issue 1126, cause C4: the own secrets whose two sides open to two values are read from the shown bodies, and
// every body so masked is counted, own or not.
drift := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("needs-jwt", "box-1", "0600"),
sealedFile("needs-salt", "box-4", "0600"), sealedFile("conf", "box-6", "0600"))},
[]Composed{comp(sealedFile("needs-jwt", "box-2", "0600"), sealedFile("needs-salt", "box-5", "0600"),
sealedFile("conf", "box-7", "0600"))},
func(a, b string) (same, known bool) { return a == "box-4", true })
got := CandidateAnswer{Head: "h", Differs: drift.Differs}
if own := OwnValuesDiffer(got); len(own) != 1 || own[0] != (OwnValueDiffers{Module: "nats", Name: "jwt"}) {
t.Errorf("the own secrets with another value are %+v, want nats jwt alone", own)
}
if n := ValuesDiffer(got); n != 2 {
t.Errorf("%d bodies hold another value, want 2 (jwt and conf)", n)
}
if w := WithValuesDiffer(map[string]CandidateAnswer{"g": got}); w["g"].ValuesDiffer != 2 {
t.Errorf("the answer carries values_differ %d", w["g"].ValuesDiffer)
}
if c := CompareComposed(nil, []Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(c.Removed) != 1 || c.Removed[0] != "node" {
t.Errorf("the node's own composition run and not composed is removed: %+v", c)
}