The answer names its proposal and the revision of the node's own candidate key; a node with no key in a proposal is not asked. The watch for its own placement key is tried again until the seat's state exists, so the node switches when its key is written.
320 lines
12 KiB
Go
320 lines
12 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"
|
|
|
|
"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
|
|
}
|
|
}
|