The legacy key form placement.<node> is gone from the client, so the readers and their fixtures lose it. The check noplacement now also refuses the assigner's retired names in a manifest. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
418 lines
17 KiB
Go
418 lines
17 KiB
Go
package main
|
|
|
|
// The vault takes what runs (novox/hq issue 1126, cause C4; ADR 0550 §5: a module's own secrets are the vault's).
|
|
//
|
|
// `move-in` never replaces a value the vault holds (move.go), so its first import of a machine's own secret is fixed.
|
|
// Where the controller later made that secret anew (a rotation at start, a re-issue) without handing it here, the new
|
|
// value is what runs and what its consumers hold, and the vault keeps the old one. Only the machine the value is sealed
|
|
// to can open what runs, so its node-engine hands it in through `move-in` with replace_running: true, in the two steps
|
|
// of every intake (intake.go): name the module and the secret and why, and get a call and a key made for it; then send
|
|
// the value sealed to that key. It is move-in's, not a verb of its own, so a node-engine asks it on the subject it may
|
|
// already publish and no seat promises anything new (the lead, 2026-10-28).
|
|
//
|
|
// **From the node-engine of the machine the secret is for, about its own machine alone**: the machine is the caller's,
|
|
// as the bus proved it (`node.<machine>`), never an argument; a machine named that is not the caller's is refused, and so
|
|
// is a name that is not an own secret. **Only a value the vault holds is replaced**, and never a person's (accept) or a
|
|
// provider's software's (issued) but for a covered bus account, nor one the vault changed less than RecentlyChanged ago:
|
|
// a running value is older than a value the vault just took (a re-issue the machine has not applied yet), and is never
|
|
// put back over it. The same value again changes nothing and says so; an empty one is refused.
|
|
//
|
|
// The value replaced is kept as the record's previous generation (Record.Previous), the generation is raised,
|
|
// `secret-changed` and `own-replaced` are said (the secret, the machine, both generations and why; never a value), and the
|
|
// act is written in the record of hand-outs (secret_handed). A covered bus account so replaced has its hash written again
|
|
// (accounts.go), so the bus-accounts key is of the value the bus runs.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
|
nodeseat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
|
vaultclient "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// ReplaceRunningArg is the argument of `move-in` that makes it take a running own secret over the one the vault holds.
|
|
const ReplaceRunningArg = "replace_running"
|
|
|
|
// ReplaceRunning names the act in what the vault says, and is the kind of its open calls.
|
|
const ReplaceRunning = "move-in with " + ReplaceRunningArg
|
|
|
|
// OwnReplacedEvent is what the vault says when it replaced an own secret with what runs: never a value.
|
|
const OwnReplacedEvent = "own-replaced"
|
|
|
|
// RecentlyChanged is how long after the vault changed a secret a running value is not taken over it.
|
|
var RecentlyChanged = 15 * time.Minute
|
|
|
|
// OwnTakeOf names the secret of one `move-in` with replace_running: Module and Name (or Secret whole), Node only to be checked
|
|
// against the caller's machine, and Why in the caller's words.
|
|
type OwnTakeOf struct {
|
|
Node, Module, Name, Secret, Why string
|
|
}
|
|
|
|
// OwnReplaced is what one `move-in` with replace_running did: the secret, the generation it replaced and the one it holds now, the
|
|
// fingerprint of the value held now (what the node-engine checks against the value it handed), whether the value was
|
|
// held already. Never a value.
|
|
type OwnReplaced struct {
|
|
Secret string `json:"secret"`
|
|
Node string `json:"node"`
|
|
Module string `json:"module"`
|
|
Name string `json:"name"`
|
|
From int `json:"from_generation"`
|
|
To int `json:"to_generation"`
|
|
Unchanged bool `json:"unchanged,omitempty"`
|
|
Fingerprint string `json:"fingerprint"`
|
|
Said string `json:"said"`
|
|
}
|
|
|
|
// ownTakeSecret is the secret a node-engine names, and its machine: refused for a caller that is no node-engine, for
|
|
// another machine, and for a name that is not an own secret.
|
|
func ownTakeSecret(caller string, of OwnTakeOf) (secret, node string, err error) {
|
|
node, ok := nodeEngineOf(caller)
|
|
if !ok {
|
|
if caller == "" || caller == stdio.Unproven {
|
|
caller = "an unproven caller"
|
|
}
|
|
return "", "", fmt.Errorf("%s takes a value from a machine's node-engine alone, about its own machine; %s is "+
|
|
"not a node-engine; nothing is taken", ReplaceRunning, caller)
|
|
}
|
|
if of.Secret != "" {
|
|
parts := strings.Split(of.Secret, "/")
|
|
if len(parts) != 4 || parts[0] != "own" {
|
|
return "", "", fmt.Errorf("%s is not a module's own secret (own/<machine>/<module>/<name>): %s replaces "+
|
|
"those alone; nothing is taken", of.Secret, ReplaceRunning)
|
|
}
|
|
if of.Node != "" && of.Node != parts[1] {
|
|
return "", "", fmt.Errorf("%s is not on %s; nothing is taken", of.Secret, of.Node)
|
|
}
|
|
of.Node, of.Module, of.Name = parts[1], parts[2], parts[3]
|
|
}
|
|
if of.Node != "" && of.Node != node {
|
|
return "", "", fmt.Errorf("%s may hand the vault its own machine's secrets alone, and %s/%s is %s's: nothing "+
|
|
"is taken", caller, of.Module, of.Name, of.Node)
|
|
}
|
|
secret, err = OwnSecret(node, of.Module, of.Name)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("%w; nothing is taken", err)
|
|
}
|
|
return secret, node, nil
|
|
}
|
|
|
|
// ownTakeRefusal says why the held record rec is not replaced by a running value, or "".
|
|
func ownTakeRefusal(rec Record, now time.Time) string {
|
|
switch {
|
|
case rec.Origin == OriginAccepted:
|
|
return fmt.Sprintf("%s was given by a person, and a person alone gives it anew (accept); nothing is taken", rec.Name)
|
|
case rec.Origin == OriginIssued && !Covered(rec.Name):
|
|
return fmt.Sprintf("%s is issued by its provider's software, and `issued` is the only way it enters the vault "+
|
|
"(ADR 0305 §10); nothing is taken", rec.Name)
|
|
case now.Sub(rec.ChangedAt) < RecentlyChanged:
|
|
return fmt.Sprintf("the vault changed %s at %s (generation %d, by %s), less than %s ago: a running value is "+
|
|
"taken only over an older one, so a value the vault just took is never put back; nothing is taken",
|
|
rec.Name, rec.ChangedAt.Format(time.RFC3339), rec.Generation, rec.ChangedBy, RecentlyChanged)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// OwnTakeOpen opens a call to take the running value of one of the caller's machine's own secrets.
|
|
func (v *Vault) OwnTakeOpen(caller string, of OwnTakeOf) (Opened, error) {
|
|
secret, _, err := ownTakeSecret(caller, of)
|
|
if err != nil {
|
|
return Opened{}, err
|
|
}
|
|
why := strings.TrimSpace(of.Why)
|
|
if len(why) > 300 || strings.ContainsAny(why, "\r\n\x00") {
|
|
return Opened{}, errors.New("why is not one short line; nothing is taken")
|
|
}
|
|
rec, held, err := v.Store.Read(secret)
|
|
if err != nil {
|
|
return Opened{}, err
|
|
}
|
|
refused := v.ownTakeOff()
|
|
switch {
|
|
case refused != "":
|
|
case !held:
|
|
refused = fmt.Sprintf("the vault holds no secret named %s, so there is nothing to replace; nothing is taken", secret)
|
|
default:
|
|
refused = ownTakeRefusal(rec, v.Now())
|
|
}
|
|
if refused != "" {
|
|
_ = v.recordHanding(Handing{To: caller, Name: secret, Generation: rec.Generation, Outcome: "own-take-refused", Why: refused})
|
|
return Opened{}, errors.New(refused)
|
|
}
|
|
id, key, err := v.openCall(ReplaceRunning, secret, caller, nil)
|
|
if err != nil {
|
|
return Opened{}, err
|
|
}
|
|
v.callsMu.Lock()
|
|
if c := v.pending[id]; c != nil {
|
|
c.why = why
|
|
}
|
|
v.callsMu.Unlock()
|
|
return Opened{Call: id, SealTo: key, Secret: secret}, nil
|
|
}
|
|
|
|
// OwnTakeTake opens the value sealed to the call's key and replaces the secret with it, keeping the value it replaces.
|
|
func (v *Vault) OwnTakeTake(caller, call, sealed string) (OwnReplaced, error) {
|
|
if _, ok := nodeEngineOf(caller); !ok {
|
|
return OwnReplaced{}, fmt.Errorf("%s takes a value from a machine's node-engine alone; %q is not one",
|
|
ReplaceRunning, caller)
|
|
}
|
|
c, err := v.takeCall(ReplaceRunning, call)
|
|
if err != nil {
|
|
return OwnReplaced{}, err
|
|
}
|
|
if c.by != caller {
|
|
return OwnReplaced{}, fmt.Errorf("the %s call %q was opened by another caller; nothing was taken", ReplaceRunning, call)
|
|
}
|
|
value, err := c.open(sealed)
|
|
if err != nil {
|
|
return OwnReplaced{}, err
|
|
}
|
|
defer zero(value)
|
|
parts := strings.Split(c.secret, "/")
|
|
out := OwnReplaced{Secret: c.secret, Node: parts[1], Module: parts[2], Name: parts[3]}
|
|
if strings.TrimSpace(string(value)) == "" {
|
|
why := "the value handed is empty; nothing was taken"
|
|
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Outcome: "own-take-refused", Why: why})
|
|
return OwnReplaced{}, errors.New(why)
|
|
}
|
|
now := v.Now()
|
|
changed, err := v.keepWith(c.secret, string(value), caller, "", "", keepOptions{replaceOnly: true, keepPrevious: true,
|
|
refuse: func(before Record) string { return ownTakeRefusal(before, now) }})
|
|
if err != nil {
|
|
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Outcome: "own-take-refused", Why: err.Error()})
|
|
return OwnReplaced{}, err
|
|
}
|
|
out.Fingerprint = Fingerprint(string(value))
|
|
if changed.Unchanged {
|
|
out.From, out.To, out.Unchanged = changed.Generation, changed.Generation, true
|
|
out.Said = fmt.Sprintf("the vault holds this value of %s already (generation %d): nothing changed", c.secret, changed.Generation)
|
|
_ = v.recordHanding(Handing{To: caller, Name: c.secret, Generation: changed.Generation, Outcome: "own-same", Why: c.why})
|
|
v.log("%s handed %s's running value, the one the vault holds (generation %d): nothing changed", caller, c.secret,
|
|
changed.Generation)
|
|
return out, nil
|
|
}
|
|
out.From, out.To = changed.Generation-1, changed.Generation
|
|
out.Said = fmt.Sprintf("%s is now the value that runs on %s, generation %d; generation %d is kept as the previous",
|
|
c.secret, out.Node, out.To, out.From)
|
|
if err := v.recordHanding(Handing{To: caller, Name: c.secret, Generation: out.To, Outcome: "own-replaced", Why: c.why}); err != nil {
|
|
out.Said += "; the record of hand-outs could not be written: " + err.Error()
|
|
}
|
|
v.log("replaced %s (generation %d → %d) with the value that runs, handed by %s: %s", c.secret, out.From, out.To,
|
|
caller, someoneOr(c.why, "no reason given"))
|
|
if v.Emit != nil {
|
|
body := map[string]any{"secret": c.secret, "node": out.Node, "module": out.Module, "name": out.Name,
|
|
"from_generation": out.From, "to_generation": out.To, "by": caller, "why": c.why,
|
|
"at": changed.At.Format(time.RFC3339Nano)}
|
|
if err := v.Emit(OwnReplacedEvent, body); err != nil {
|
|
v.log("%s is replaced at generation %d, but %s could not be said: %v", c.secret, out.To, OwnReplacedEvent, err)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- own_drift ----------------------------------------------------------------------------------------------
|
|
|
|
// DriftRow is one own secret a node's latest shadow answer says the vault holds another value of than runs there.
|
|
type DriftRow struct {
|
|
Secret string `json:"secret"`
|
|
Node string `json:"node"`
|
|
Module string `json:"module"`
|
|
Name string `json:"name"`
|
|
Groups []string `json:"groups"`
|
|
Held bool `json:"held"`
|
|
Generation int `json:"generation,omitempty"`
|
|
Changed time.Time `json:"changed,omitzero"`
|
|
ChangedBy string `json:"changed_by,omitempty"`
|
|
Origin string `json:"origin,omitempty"`
|
|
Previous int `json:"previous_generation,omitempty"`
|
|
// Takeable says move-in with replace_running would take the running value now; Refused why not.
|
|
Takeable bool `json:"takeable"`
|
|
Refused string `json:"refused,omitempty"`
|
|
}
|
|
|
|
// DriftNode is one node's answers as read: whose compositions it applies, and per group how many values differ.
|
|
type DriftNode struct {
|
|
Node string `json:"node"`
|
|
Composes string `json:"composes,omitempty"`
|
|
ValuesDiffer map[string]int `json:"values_differ,omitempty"`
|
|
Unread string `json:"unread,omitempty"`
|
|
}
|
|
|
|
// Drift is own_drift's answer.
|
|
type Drift struct {
|
|
Switch OwnTakeSwitch `json:"switch"`
|
|
Secrets []DriftRow `json:"secrets"`
|
|
Nodes []DriftNode `json:"nodes"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
// OwnDrift reads every node's own have key (`<node>.node`) and lists, from its latest answers to the assignments
|
|
// candidates, every own secret whose shown bodies say another value (vaultclient.OwnValuesDiffer), with what the vault holds
|
|
// of it and whether move-in with replace_running would take the running value now.
|
|
func (v *Vault) OwnDrift() (Drift, error) {
|
|
if v.Haves == nil || v.HaveKeys == nil {
|
|
return Drift{}, errors.New("the vault reads no node's have, so it cannot say what drifted")
|
|
}
|
|
keys, err := v.HaveKeys()
|
|
if err != nil {
|
|
return Drift{}, fmt.Errorf("the keys of %s could not be listed: %w", HaveRead, err)
|
|
}
|
|
sort.Strings(keys)
|
|
rows := map[string]*DriftRow{}
|
|
out := Drift{Secrets: []DriftRow{}, Nodes: []DriftNode{},
|
|
Note: "from each node's latest shadow answers; a value is never shown: move-in with replace_running from that node replaces it"}
|
|
for _, key := range keys {
|
|
node, isNode := strings.CutSuffix(key, "."+engineclient.NodeWord)
|
|
if !isNode || !namePart.MatchString(node) {
|
|
continue
|
|
}
|
|
dn := DriftNode{Node: node}
|
|
raw, err := v.Haves(key)
|
|
if err != nil || raw == nil {
|
|
dn.Unread = fmt.Sprintf("its have could not be read: %v", err)
|
|
out.Nodes = append(out.Nodes, dn)
|
|
continue
|
|
}
|
|
have, err := engineclient.DecodeHaveNode(raw)
|
|
if err != nil {
|
|
dn.Unread = err.Error()
|
|
out.Nodes = append(out.Nodes, dn)
|
|
continue
|
|
}
|
|
dn.Composes = have.Composes
|
|
for group, a := range have.Candidates {
|
|
if n := vaultclient.ValuesDiffer(bodiesOf(a)); n > 0 {
|
|
if dn.ValuesDiffer == nil {
|
|
dn.ValuesDiffer = map[string]int{}
|
|
}
|
|
dn.ValuesDiffer[group] = n
|
|
}
|
|
for _, o := range vaultclient.OwnValuesDiffer(bodiesOf(a)) {
|
|
secret, err := OwnSecret(node, o.Module, o.Name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
r := rows[secret]
|
|
if r == nil {
|
|
r = &DriftRow{Secret: secret, Node: node, Module: o.Module, Name: o.Name}
|
|
rows[secret] = r
|
|
}
|
|
r.Groups = append(r.Groups, group)
|
|
}
|
|
}
|
|
out.Nodes = append(out.Nodes, dn)
|
|
}
|
|
names := make([]string, 0, len(rows))
|
|
for n := range rows {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
if out.Switch, err = v.Store.OwnTakeSwitch(); err != nil {
|
|
return Drift{}, err
|
|
}
|
|
off := v.ownTakeOff()
|
|
for _, n := range names {
|
|
r := rows[n]
|
|
sort.Strings(r.Groups)
|
|
rec, held, err := v.Store.Read(n)
|
|
switch {
|
|
case err != nil:
|
|
r.Refused = err.Error()
|
|
case !held:
|
|
r.Refused = "the vault holds no secret named " + n
|
|
default:
|
|
r.Held, r.Generation, r.Changed, r.ChangedBy, r.Origin = true, rec.Generation, rec.ChangedAt, rec.ChangedBy, rec.Origin
|
|
if rec.Previous != nil {
|
|
r.Previous = rec.Previous.Generation
|
|
}
|
|
r.Refused = ownTakeRefusal(rec, v.Now())
|
|
if off != "" {
|
|
r.Refused = off
|
|
}
|
|
r.Takeable = r.Refused == ""
|
|
}
|
|
out.Secrets = append(out.Secrets, *r)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- own_take_switch ----------------------------------------------------------------------------------------
|
|
|
|
// OwnTakeSwitch is whether move-in with replace_running takes values, who switched it last, when and why. On until a person
|
|
// switches it off; kept in the vault's own store, so it holds over a restart.
|
|
type OwnTakeSwitch struct {
|
|
On bool `json:"on"`
|
|
By string `json:"by,omitempty"`
|
|
At time.Time `json:"at,omitzero"`
|
|
Why string `json:"why,omitempty"`
|
|
}
|
|
|
|
func (s *Store) ownTakeSwitchPath() string { return filepath.Join(s.dir, "replace-running.json") }
|
|
|
|
// OwnTakeSwitch is the switch as kept: on when nothing was ever switched.
|
|
func (s *Store) OwnTakeSwitch() (OwnTakeSwitch, error) {
|
|
raw, err := os.ReadFile(s.ownTakeSwitchPath())
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return OwnTakeSwitch{On: true}, nil
|
|
}
|
|
if err != nil {
|
|
return OwnTakeSwitch{}, err
|
|
}
|
|
var sw OwnTakeSwitch
|
|
if err := json.Unmarshal(raw, &sw); err != nil {
|
|
return OwnTakeSwitch{}, fmt.Errorf("the switch of %s is not readable: %w", ReplaceRunning, err)
|
|
}
|
|
return sw, nil
|
|
}
|
|
|
|
// SwitchOwnTake switches move-in with replace_running on or off, a person's act with why; the caller is recorded as the bus
|
|
// proved it.
|
|
func (v *Vault) SwitchOwnTake(caller string, on bool, why string) (OwnTakeSwitch, error) {
|
|
why = strings.TrimSpace(why)
|
|
if why == "" || len(why) > 300 || strings.ContainsAny(why, "\r\n\x00") {
|
|
return OwnTakeSwitch{}, errors.New("say why in one short line; nothing was switched")
|
|
}
|
|
sw := OwnTakeSwitch{On: on, By: someoneOr(caller, stdio.Unproven), At: v.Now().UTC(), Why: why}
|
|
raw, _ := json.MarshalIndent(sw, "", " ")
|
|
if err := writeWhole(v.Store.ownTakeSwitchPath(), append(raw, '\n')); err != nil {
|
|
return OwnTakeSwitch{}, err
|
|
}
|
|
v.log("%s is switched %s by %s: %s", ReplaceRunning, map[bool]string{true: "on", false: "off"}[on], sw.By, why)
|
|
return sw, nil
|
|
}
|
|
|
|
// ownTakeOff says why move-in with replace_running takes nothing now, or "".
|
|
func (v *Vault) ownTakeOff() string {
|
|
sw, err := v.Store.OwnTakeSwitch()
|
|
if err != nil {
|
|
return err.Error() + "; nothing is taken"
|
|
}
|
|
if !sw.On {
|
|
return fmt.Sprintf("%s is switched off (by %s at %s: %s); nothing is taken", ReplaceRunning, sw.By,
|
|
sw.At.Format(time.RFC3339), sw.Why)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// bodiesOf is the shown bodies of the modules a node's answer to a candidate says differ, in the form the vault client
|
|
// reads them (the node-engine's answer is the seat's type, the reading of masked values the vault's).
|
|
func bodiesOf(a nodeseat.CandidateAnswer) []vaultclient.ModuleBodies {
|
|
out := make([]vaultclient.ModuleBodies, 0, len(a.Differs))
|
|
for _, d := range a.Differs {
|
|
m := vaultclient.ModuleBodies{Module: d.Module}
|
|
for _, b := range d.Bodies {
|
|
m.Bodies = append(m.Bodies, vaultclient.Body{ID: b.ID, Composed: b.Composed, Running: b.Running})
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|