Files
mesh-host/internal/ownresync/ownresync.go
T
jschoubben 653ff61662 The node agent hands the vault what runs of an own secret its shadow answer says another value of, through move-in's replace_running, while it applies the controller's compositions (issue 1126, cause C4)
A new step, internal/ownresync, after each shadow answer: for each own secret masked as another value, while the node
applies the controller's compositions and runs the one its want names, it asks the vault's move-in with
replace_running: true and the running value sealed to the call's key, once per value, and says what it asked and what
the vault answered. The answer written to the node's have key carries values_differ. Pins the group's mesh-sdk head
1062b33 (go.sum is filled once that head is pushed).
2026-10-28 09:51:48 +01:00

293 lines
10 KiB
Go

// 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"
)
// 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
}
// 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
groups := make([]string, 0, len(answers))
for g := range answers {
groups = append(groups, g)
}
sort.Strings(groups)
for _, g := range groups {
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 != "" {
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 := r.running(ctx, o)
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
}
said = fmt.Sprintf("%s: the vault's %s with replace_running refused or did not answer: %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.
func (r *Resync) running(ctx context.Context, o delivery.OwnValueDiffers) (string, string) {
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"
}
wantKey, err := delivery.WantKey(r.env.Node, o.Module)
if err != nil {
return "", err.Error()
}
raw, err := r.env.Get(ctx, delivery.AssignerWantsBucket, wantKey)
if err != nil || raw == nil {
return "", fmt.Sprintf("its want %s could not be read (%v)", wantKey, err)
}
want, err := delivery.DecodeWant(raw)
if err != nil {
return "", fmt.Sprintf("its want %s is not one: %v", wantKey, err)
}
if want.Self || want.Removed || want.Composed == "" {
return "", fmt.Sprintf("its want %s names no composition of the controller's", wantKey)
}
// 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()
}
raw, err = r.env.Get(ctx, delivery.HaveBucket, haveKey)
if err != nil || raw == nil {
return "", fmt.Sprintf("its have %s could not be read (%v)", haveKey, err)
}
have, err := delivery.DecodeHave(raw)
if err != nil {
return "", fmt.Sprintf("its have %s is not one: %v", haveKey, err)
}
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)
}
id := 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 || body.ID != id {
continue
}
if s, ok := body.Sealed.(string); ok && s != "" {
return s, ""
}
return "", fmt.Sprintf("its resource %s carries no value sealed to this machine", id)
}
return "", fmt.Sprintf("what runs holds no resource %s", id)
}
// 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
}
}