Read the vault's structured move-in refusal and take a value it holds another of as superseded, so the move is not asked again for ever (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-host@ee71ba4dea81
mesh/delivery-group group fix/sweep-2026-10-30 merged: every member is merged
mesh/check checking b9c24a7b42cb on 737bce9c9448 | novox/mesh-host#179 in group fix/sweep-2026-10-30

mesh-vault (mesh-catalog f3b13de2) answers a move it refuses with `refused: {reason, held}` and `why`, and
no read-back. The engine read only the read-back, so every such row failed "answered with no read-back"
and the controller asked it again on every pass.

Quick fix: treat any answer without a read-back as done. Thorough fix (chosen): read `refused`. For
holds-another-value the vault's value is the live one: the move answers done, superseded, at the held
generation with the held record in its words, so the controller records the row and stops asking; the
journal says it once per secret. Any other reason fails in the vault's own words.
This commit is contained in:
2026-10-30 18:36:38 +01:00
parent 744bfec4a0
commit b9c24a7b42
4 changed files with 102 additions and 11 deletions
+54 -1
View File
@@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"sync"
"time" "time"
"golang.org/x/crypto/nacl/box" "golang.org/x/crypto/nacl/box"
@@ -40,6 +41,11 @@ func moveSecretFrom(statePath string, queue *link.Session) link.MoveSecretFunc {
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "mesh-host: a value was not moved into the vault: %v\n", err) fmt.Fprintf(os.Stderr, "mesh-host: a value was not moved into the vault: %v\n", err)
} }
if moved.Superseded != "" {
if _, said := supersededSaid.LoadOrStore(moved.Secret, true); !said {
fmt.Fprintf(os.Stderr, "mesh-host: %s\n", moved.Superseded)
}
}
return moved, err return moved, err
} }
} }
@@ -104,8 +110,30 @@ func moveIntoTheVault(ask vaultAsk, key identity.SealingKey, m link.MoveSecret)
Generation int `json:"generation"` Generation int `json:"generation"`
Unchanged bool `json:"unchanged"` Unchanged bool `json:"unchanged"`
ReadBack string `json:"read_back"` ReadBack string `json:"read_back"`
// Refused is the vault's refusal as fields (mesh-vault's MoveRefused, mesh-catalog f3b13de2): nothing was taken.
Refused *struct {
Reason string `json:"reason"`
Held *heldValue `json:"held"`
} `json:"refused"`
Why string `json:"why"`
} }
if err := json.Unmarshal(raw, &kept); err != nil || kept.Name == "" || kept.ReadBack == "" { if err := json.Unmarshal(raw, &kept); err != nil {
return link.MovedSecret{}, fmt.Errorf("the vault's answer for %s does not read: %v", opened.Secret, err)
}
if r := kept.Refused; r != nil {
// The vault holds another value for the name, and the move never replaces one: the vault's value is the live
// one, and this machine's older value is superseded, never asked again (the sweep of 2026-10-30: it failed
// "answered with no read-back" on every ask).
if r.Reason == vaultHoldsAnother && r.Held != nil && r.Held.Name != "" {
return link.MovedSecret{Secret: r.Held.Name, Generation: r.Held.Generation, Superseded: r.Held.words()}, nil
}
why := kept.Why
if why == "" {
why = r.Reason
}
return link.MovedSecret{}, fmt.Errorf("the vault refused %s: %s", opened.Secret, why)
}
if kept.Name == "" || kept.ReadBack == "" {
return link.MovedSecret{}, fmt.Errorf("the vault answered %s with no read-back", opened.Secret) return link.MovedSecret{}, fmt.Errorf("the vault answered %s with no read-back", opened.Secret)
} }
blob, err := base64.StdEncoding.DecodeString(kept.ReadBack) blob, err := base64.StdEncoding.DecodeString(kept.ReadBack)
@@ -124,6 +152,31 @@ func moveIntoTheVault(ask vaultAsk, key identity.SealingKey, m link.MoveSecret)
return link.MovedSecret{Secret: kept.Name, Generation: kept.Generation, Unchanged: kept.Unchanged}, nil return link.MovedSecret{Secret: kept.Name, Generation: kept.Generation, Unchanged: kept.Unchanged}, nil
} }
// vaultHoldsAnother is the vault's reason when it holds another value for the name (mesh-vault RefusedHeldAnother).
const vaultHoldsAnother = "holds-another-value"
// heldValue is the record of the value the vault holds instead, never the value (mesh-vault HeldAnother).
type heldValue struct {
Name string `json:"name"`
Generation int `json:"generation"`
ChangedBy string `json:"changed_by"`
ChangedAt time.Time `json:"changed_at"`
Origin string `json:"origin"`
}
// words say the held value's record, for the journal and the controller.
func (h heldValue) words() string {
w := fmt.Sprintf("the vault holds another value for %s, generation %d, changed by %s at %s", h.Name, h.Generation,
h.ChangedBy, h.ChangedAt.UTC().Format(time.RFC3339))
if h.Origin != "" {
w += ", origin " + h.Origin
}
return w + ": that value is the live one, and this machine's older value is superseded and not moved"
}
// supersededSaid holds the names a superseded move was said of, so the journal says each once.
var supersededSaid sync.Map
func wipe(b []byte) { func wipe(b []byte) {
for i := range b { for i := range b {
b[i] = 0 b[i] = 0
+34 -7
View File
@@ -17,13 +17,15 @@ import (
// standInVault answers move-in's two steps as the vault does: a key made for the call, the value opened with it, // standInVault answers move-in's two steps as the vault does: a key made for the call, the value opened with it,
// and the value kept read back sealed to the key the second step sent. lie makes its read-back another value. // and the value kept read back sealed to the key the second step sent. lie makes its read-back another value.
type standInVault struct { type standInVault struct {
t *testing.T t *testing.T
pub *[32]byte pub *[32]byte
priv *[32]byte priv *[32]byte
opened map[string]any opened map[string]any
kept string kept string
lie string lie string
refuse string refuse string
// answer replaces the second step's answer: the vault's refusal as fields (mesh-catalog f3b13de2).
answer map[string]any
carried []string carried []string
} }
@@ -51,6 +53,9 @@ func (v *standInVault) ask(verb string, args any) (json.RawMessage, error) {
v.t.Fatal("what the engine sealed does not open with the call's key") v.t.Fatal("what the engine sealed does not open with the call's key")
} }
v.kept = string(value) v.kept = string(value)
if v.answer != nil {
return json.Marshal(v.answer)
}
back := v.kept back := v.kept
if v.lie != "" { if v.lie != "" {
back = v.lie back = v.lie
@@ -119,6 +124,28 @@ func TestTheEngineSaysAMoveThatDidNotHold(t *testing.T) {
!strings.Contains(err.Error(), "never replaces") { !strings.Contains(err.Error(), "never replaces") {
t.Fatalf("the vault's refusal: %v", err) t.Fatalf("the vault's refusal: %v", err)
} }
// The vault's refusal as fields (mesh-catalog f3b13de2, no read-back): for another value held, the vault's is the
// live one and the move is done, superseded, at the held generation; any other reason fails in the vault's words.
held := map[string]any{"name": "own/anchor/postgres/superuser-password", "generation": 3,
"changed_by": "mesh-vault@novox", "changed_at": "2026-10-30T15:50:00Z", "origin": "made"}
for _, row := range []struct {
refused map[string]any
gen int
fails string
}{
{map[string]any{"reason": "holds-another-value", "held": held}, 3, ""},
{map[string]any{"reason": "locked"}, 0, "the vault refused"},
} {
v := &standInVault{t: t, answer: map[string]any{"refused": row.refused, "why": "in words"}}
moved, err := moveIntoTheVault(v.ask, key, aMoveOf(t, key))
switch {
case row.fails != "" && (err == nil || !strings.Contains(err.Error(), row.fails)):
t.Errorf("refused %v: %v", row.refused["reason"], err)
case row.fails == "" && (err != nil || moved.Generation != row.gen || moved.Secret != held["name"] ||
!strings.Contains(moved.Superseded, "changed by mesh-vault@novox")):
t.Errorf("refused %v: moved %+v, %v", row.refused["reason"], moved, err)
}
}
// A pair credential names both its ends. // A pair credential names both its ends.
pair := aMoveOf(t, key) pair := aMoveOf(t, key)
pair.Module, pair.Name = "", "" pair.Module, pair.Name = "", ""
+11 -1
View File
@@ -54,6 +54,9 @@ type MoveSecretAnswer struct {
Secret string `json:"secret,omitempty"` Secret string `json:"secret,omitempty"`
Generation int `json:"generation,omitempty"` Generation int `json:"generation,omitempty"`
Unchanged bool `json:"unchanged,omitempty"` Unchanged bool `json:"unchanged,omitempty"`
// Superseded is set when the vault holds another value for the name, which is the live one: the row is done and
// is not asked again; Generation is the held value's, and Said its record in words.
Superseded bool `json:"superseded,omitempty"`
} }
// MoveSecret is one move asked and believed, as the handler is given it: the row, this machine, and who asked. // MoveSecret is one move asked and believed, as the handler is given it: the row, this machine, and who asked.
@@ -70,6 +73,9 @@ type MovedSecret struct {
Secret string Secret string
Generation int Generation int
Unchanged bool Unchanged bool
// Superseded says, in words, that the vault holds another value for the name, which is the live one: nothing was
// moved, and the move is done (the vault's record of the held value, its generation in Generation).
Superseded string
} }
// MoveSecretFunc moves one value into the vault: what it kept, or why not. // MoveSecretFunc moves one value into the vault: what it kept, or why not.
@@ -133,7 +139,11 @@ func AnswerMoveSecret(check HandOverCheck, handle MoveSecretFunc, body []byte) M
if moved.Unchanged { if moved.Unchanged {
said = fmt.Sprintf("the vault held %s already, the same value (generation %d)", moved.Secret, moved.Generation) said = fmt.Sprintf("the vault held %s already, the same value (generation %d)", moved.Secret, moved.Generation)
} }
return MoveSecretAnswer{Said: said, Secret: moved.Secret, Generation: moved.Generation, Unchanged: moved.Unchanged} if moved.Superseded != "" {
said = moved.Superseded
}
return MoveSecretAnswer{Said: said, Secret: moved.Secret, Generation: moved.Generation, Unchanged: moved.Unchanged,
Superseded: moved.Superseded != ""}
} }
// MoveSecretReply is what a move request is answered with, and whether to answer, as HandOverReply. // MoveSecretReply is what a move request is answered with, and whether to answer, as HandOverReply.
+3 -2
View File
@@ -28,8 +28,9 @@ func TestTheMovesAskKeepsItsFieldNamesAndContext(t *testing.T) {
if MoveSecretContext != "novox-mesh move-secret v1\n" { if MoveSecretContext != "novox-mesh move-secret v1\n" {
t.Fatalf("the signing context is %q", MoveSecretContext) t.Fatalf("the signing context is %q", MoveSecretContext)
} }
answer, _ := json.Marshal(MoveSecretAnswer{Said: "s", Refused: "r", Secret: "own/a/b/c", Generation: 1, Unchanged: true}) answer, _ := json.Marshal(MoveSecretAnswer{Said: "s", Refused: "r", Secret: "own/a/b/c", Generation: 1, Unchanged: true,
if got := keysOf(t, answer); got != "generation refused said secret unchanged" { Superseded: true})
if got := keysOf(t, answer); got != "generation refused said secret superseded unchanged" {
t.Fatalf("the answer's fields are %q", got) t.Fatalf("the answer's fields are %q", got)
} }
} }