// Package ownresync hands the vault the value of this machine's own secret that runs, where the vault holds another // (novox/hq issue 1126, cause C4). // // The vault's `move-in` never replaces a value it holds, so its first import of an own secret stays fixed; where the // controller later made that secret anew (a rotation at start, a re-issue), the new value runs and the vault keeps the // old one. The node's shadow composer sees it: composing from the vault's value, it opens both seals with this // machine's key and its answer's shown body says delivery.MaskedAnotherValue. After each shadow answer this step asks the // vault's `move-in` with replace_running: true and what runs, for each own secret so masked, once per running value, and // says what it asked and what the vault answered. It is move-in's, the verb this node may ask already, so no seat and no // bus grant changes for it. // // **Only while this node applies the controller's compositions**, and only for a module whose composition that runs is // the one its want names: then the controller's value is what runs, and what runs is the truth the vault lacks. Once the // node composes its own, the vault's value is the source and nothing is handed. **The switch** is the vault's // (`own_take_switch`, a person's act): while it is off the vault refuses every ask, and this step says so once per value. // The vault also refuses any value it changed in the last 15 minutes, so a re-issue the node has not applied yet is // never put back. package ownresync import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "sort" "sync" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/selfcompose" ) // Verb is the vault's verb this step asks, with ReplaceRunning true in both steps. const Verb = "move-in" // ReplaceRunning is move-in's argument that makes it take the running value over the one the vault holds. const ReplaceRunning = "replace_running" // Env is what the step reads and asks. type Env struct { Node string // Composes is whose compositions the node applies now: delivery.ComposesController, delivery.ComposesOwn, or "" // while its agent has not decided. The step hands nothing but for ComposesController. Composes func() string // Running is what the node runs now (agent.Everything). Running func() []delivery.Composed // Get reads one key's latest value; nil and no error when there is none. Get func(ctx context.Context, bucket, key string) ([]byte, error) // Unseal opens a value sealed to this machine; Seal seals a value to a raw X25519 key in base64 (identity.Seal). Unseal func(sealed string) ([]byte, error) Seal func(to string, value []byte) (string, error) // Ask asks one of the vault's verbs in this engine's own name (link.Session.AskVault). Ask func(ctx context.Context, verb string, args map[string]any) (json.RawMessage, error) Say func(string) } // Outcome is what the step did for one own secret: asked or not, and what was said. type Outcome struct { Module string Name string Asked bool Said string // Retry says a read or an ask the bus did not answer stopped it: no answer, kept as none, and the step is to be // taken again after a bound (issue 1126, 2026-10-28: "want.shanks.brother could not be read ... no responders"). Retry bool } // Resync is the step of one node agent's run. type Resync struct { env Env mu sync.Mutex // done is, by module/name, the running value's fingerprint the vault was asked with or why it was not: each is // asked, and each reason said, once. done map[string]string } // New is the step for one node. func New(env Env) *Resync { if env.Say == nil { env.Say = func(string) {} } return &Resync{env: env, done: map[string]string{}} } // Take reads the node's shadow answers and hands the vault what runs of each own secret they say another value of. func (r *Resync) Take(ctx context.Context, answers map[string]delivery.CandidateAnswer) []Outcome { seen := map[delivery.OwnValueDiffers]bool{} var drift []delivery.OwnValueDiffers proposals := make([]string, 0, len(answers)) for g := range answers { proposals = append(proposals, g) } sort.Strings(proposals) for _, g := range proposals { for _, o := range delivery.OwnValuesDiffer(answers[g]) { if !seen[o] { seen[o] = true drift = append(drift, o) } } } if len(drift) == 0 { return nil } composes := "" if r.env.Composes != nil { composes = r.env.Composes() } var out []Outcome for _, o := range drift { res := r.one(ctx, composes, o) if res.Said != "" || res.Retry { out = append(out, res) } } return out } // one is the step for one own secret: Said is empty when it was done before for the same value and reason. func (r *Resync) one(ctx context.Context, composes string, o delivery.OwnValueDiffers) Outcome { res := Outcome{Module: o.Module, Name: o.Name} key := o.Module + "/" + o.Name if composes == "" { return res // the agent has not decided whose compositions it applies: taken again once it has } if composes != delivery.ComposesController { return r.said(key, "not:composes", res, fmt.Sprintf("%s holds another value in the vault than runs here, and "+ "nothing is handed: this node composes its own (%q), so the vault's value is the source", key, composes)) } sealed, why, failed := r.running(ctx, o) if failed != nil { // The bus did not answer: no reason to keep, and taken again (Retry); said once per reason. res.Retry = true return r.said(key, "retry:"+why, res, fmt.Sprintf("%s holds another value in the vault than runs here, and "+ "nothing is handed yet: %s; asked again", key, why)) } if why != "" { return r.said(key, "not:"+why, res, fmt.Sprintf("%s holds another value in the vault than runs here, and "+ "nothing is handed: %s", key, why)) } sum := sha256.Sum256([]byte(sealed)) fp := hex.EncodeToString(sum[:]) r.mu.Lock() asked := r.done[key] == fp r.mu.Unlock() if asked { return res } value, err := r.env.Unseal(sealed) if err != nil { return r.said(key, "not:unseal:"+fp, res, fmt.Sprintf("%s: what runs does not open with this machine's key, so "+ "nothing is handed: %v", key, err)) } defer wipe(value) said, err := r.hand(ctx, o, value) if err != nil { if ctx.Err() != nil { return res // the run ended: asked again in the next } if selfcompose.BusFailed(err) { // The vault did not answer on the bus: no word of the vault's, so not kept as asked (Retry). res.Retry = true return r.said(key, "retry:ask", res, fmt.Sprintf("%s: the vault's %s with replace_running did not answer, "+ "and is asked again: %v", key, Verb, err)) } said = fmt.Sprintf("%s: the vault's %s with replace_running refused: %v", key, Verb, err) } // Once per running value, whatever the vault answered: a refusal is the vault's word, said once, and a new value // that runs asks again. r.mu.Lock() r.done[key] = fp r.mu.Unlock() res.Asked, res.Said = true, said r.env.Say(said) return res } // said says why once per reason, and keeps the reason as done. func (r *Resync) said(key, reason string, res Outcome, why string) Outcome { r.mu.Lock() again := r.done[key] == reason r.done[key] = reason r.mu.Unlock() if again { return res } res.Said = why r.env.Say(why) return res } // running is the sealed value of o in the composition that runs, when that composition is the one the module's want // names; or why it is not handed, with the error of a read the bus did not answer, which is no answer. func (r *Resync) running(ctx context.Context, o delivery.OwnValueDiffers) (sealed, why string, failed error) { var comp *delivery.Composed if r.env.Running != nil { for _, c := range r.env.Running() { if c.Module == o.Module { c := c comp = &c break } } } if comp == nil { return "", o.Module + " runs no composition here", nil } wantKey, err := delivery.WantKey(r.env.Node, o.Module) if err != nil { return "", err.Error(), nil } raw, err := r.env.Get(ctx, delivery.AssignerWantsBucket, wantKey) if err != nil { return "", fmt.Sprintf("its want %s could not be read (%v)", wantKey, err), err } if raw == nil { return "", fmt.Sprintf("it has no want %s", wantKey), nil } want, err := delivery.DecodeWant(raw) if err != nil { return "", fmt.Sprintf("its want %s is not one: %v", wantKey, err), nil } if want.Self || want.Removed || want.Composed == "" { return "", fmt.Sprintf("its want %s names no composition of the controller's", wantKey), nil } // What runs is the composition the want names, as the module's have says: applied and running. haveKey, err := delivery.HaveKey(r.env.Node, o.Module) if err != nil { return "", err.Error(), nil } raw, err = r.env.Get(ctx, delivery.HaveBucket, haveKey) if err != nil { return "", fmt.Sprintf("its have %s could not be read (%v)", haveKey, err), err } if raw == nil { return "", fmt.Sprintf("it has no have %s", haveKey), nil } have, err := delivery.DecodeHave(raw) if err != nil { return "", fmt.Sprintf("its have %s is not one: %v", haveKey, err), nil } if have.Composed != want.Composed || have.Phase != delivery.PhaseRunning { return "", fmt.Sprintf("what runs (%s, %s) is not yet the composition its want names (%s)", have.Composed, have.Phase, want.Composed), nil } // On a node the resource's id carries its module's name (`only-office.needs-jwt`); delivery.OwnNeedName reads it. id := o.Module + "." + catalogue.NeedID(o.Name) for _, res := range comp.Resources { var body struct { ID string `json:"id"` Sealed any `json:"sealed"` } if json.Unmarshal(res, &body) != nil { continue } if name, own := delivery.OwnNeedName(o.Module, body.ID); !own || name != o.Name { continue } if s, ok := body.Sealed.(string); ok && s != "" { return s, "", nil } return "", fmt.Sprintf("its resource %s carries no value sealed to this machine", id), nil } return "", fmt.Sprintf("what runs holds no resource %s", id), nil } // hand asks the vault's two steps with value, and checks what the vault holds after by its fingerprint. func (r *Resync) hand(ctx context.Context, o delivery.OwnValueDiffers, value []byte) (string, error) { raw, err := r.env.Ask(ctx, Verb, map[string]any{ReplaceRunning: true, "module": o.Module, "name": o.Name, "why": "this node's shadow answer says the vault holds another value than runs here"}) if err != nil { return "", err } var opened struct { Call string `json:"call"` SealTo string `json:"seal_to"` Secret string `json:"secret"` } if err := json.Unmarshal(raw, &opened); err != nil || opened.Call == "" || opened.SealTo == "" { return "", fmt.Errorf("the vault answered no call and no key to seal to") } sealed, err := r.env.Seal(opened.SealTo, value) if err != nil { return "", fmt.Errorf("the vault's key for the call is not a sealing key: %w", err) } raw, err = r.env.Ask(ctx, Verb, map[string]any{ReplaceRunning: true, "call": opened.Call, "sealed": sealed}) if err != nil { return "", err } var kept struct { Secret string `json:"secret"` From int `json:"from_generation"` To int `json:"to_generation"` Unchanged bool `json:"unchanged"` Fingerprint string `json:"fingerprint"` Said string `json:"said"` } if err := json.Unmarshal(raw, &kept); err != nil || kept.Secret == "" { return "", fmt.Errorf("the vault answered %s with no secret", opened.Secret) } sum := sha256.Sum256(value) if kept.Fingerprint != "sha256:"+hex.EncodeToString(sum[:]) { return "", fmt.Errorf("the vault says it holds %s at generation %d, but not the value handed in", kept.Secret, kept.To) } if kept.Unchanged { return fmt.Sprintf("handed the vault what runs of %s: it held that value already (generation %d)", kept.Secret, kept.To), nil } return fmt.Sprintf("handed the vault what runs of %s: generation %d replaced by %d, the value that runs", kept.Secret, kept.From, kept.To), nil } func wipe(b []byte) { for i := range b { b[i] = 0 } }