Files
mesh-catalog/modules/mesh-vault/cmd/mesh-vault/move.go
T
jschoubben f8e4c1b0ae Stop serving the vault's move-asks, which only the controller called
move-asks copied the controller's record of asks into the vault's once;
the controller is not placed (ADR 0590) and nothing else calls it. move-in
stays: the node-engine calls it. The drift test names the node's own
composition now that the controller composes for none (issue 1126).

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 12:29:56 +01:00

301 lines
13 KiB
Go

package main
// The move of the controller's secrets into the vault (novox/hq ADR 0349 clause 7; design 57's V3, issue 676;
// the operator's decision of 2026-10-14 on that issue).
//
// **Only a machine the value was sealed to can open it.** The controller's store holds every value sealed to a
// machine's sealing key (and, for some, to the operator key); the controller cannot open one. So each machine's
// node-engine opens what was sealed to it and hands it in, in the two steps of the intake (intake.go): `move-in`
// names the secret, where it came from and its generation in the controller's store, and is answered a call and
// a key made for it; the second step carries the value sealed to that key and a key of the node-engine's own,
// and is answered what the vault kept, read back from its own store and sealed to that key — the node-engine
// opens it and compares it with what it handed in, which is the move's proof.
//
// **From the node-engine of a machine the value was sealed to, and no other caller.** The caller is the bus user
// the runtime proved, `node.<machine>` (the controller's broker names a node-engine so): a module's own secret
// was sealed to its one machine, a pair credential to both its ends.
//
// **Copied as it is, and never over a value the vault holds.** Nothing is rotated (ADR 0349's rule for a move):
// the value keeps its origin, the controller's generation and when it was made. The same value again changes
// nothing — the other end of a pair, or the move run again — and a different one is refused, so a value the
// vault holds, rotated or given since, is never put back to the controller's older one.
//
// The controller's record of asks at a desk holds no value, and the controller copies it as it is (`move-asks`).
//
// Which secret each row of the controller's store is, by name in the vault's store:
//
// own/<node>/<module>/<name> a row of module_secret
// pair/<provider>/<provision>/<consumer>/<module>[/<local>] a row of secret, its local name last when it has one
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"slices"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal"
"golang.org/x/crypto/nacl/box"
)
// NodeEnginePrefix starts the bus user of a machine's node-engine: `node.<machine>`.
const NodeEnginePrefix = "node."
// MovedFrom is where a value the move took came from: the node-engine that handed it in, and the generation the
// controller's store stamped it with (ADR 0158, ADR 0305 §5), kept as it was. Never a value.
type MovedFrom struct {
By string `json:"by"`
At time.Time `json:"at"`
Generation string `json:"generation,omitempty"`
}
// MoveOf is what one row of the controller's store is: a module's own secret (node, module, name) or a pair
// credential (provider, provision, consumer, consumer module, local name), where it came from, its generation
// and when it was made.
type MoveOf struct {
Node, Module, Name string
Provider, Provision, Consumer, ConsumerModule, Local string
Origin, Generation string
MadeAt time.Time
// SealingKey is the caller's machine's sealing key as its node-engine states it (a raw X25519 public key in
// base64), kept as that machine's: what the core's credentials are sealed to (core.go, issue 763). Optional.
SealingKey string
}
// MovedIn is what the vault kept, and that value read back from its store sealed to the node-engine's key.
type MovedIn struct {
Name string `json:"name"`
Generation int `json:"generation"`
Unchanged bool `json:"unchanged,omitempty"`
// ReadBack is the value the vault's store holds now, sealed to the key the second step sent (NaCl anonymous
// box, base64).
ReadBack string `json:"read_back"`
// Refused is set, with no read-back, when nothing was taken because the vault holds another value for the name: its
// reason ("holds-another-value") and the held value's record, never the value (the sweep of 2026-10-30, issue 774:
// the refusal was words alone). Why is the same in words.
Refused *MoveRefused `json:"refused,omitempty"`
Why string `json:"why,omitempty"`
}
// MoveRefused is why a move took nothing, as fields.
type MoveRefused struct {
Reason string `json:"reason"`
Held *HeldAnother `json:"held,omitempty"`
}
// RefusedHeldAnother is MoveRefused's reason when the vault holds another value for the name.
const RefusedHeldAnother = "holds-another-value"
// PairSecret is the vault's name for one row of the controller's pair credentials.
func PairSecret(provider, provision, consumer, consumerModule, local string) (string, error) {
parts := map[string]string{"the provider's machine": provider, "the provision": provision,
"the consumer's machine": consumer, "the consuming module": consumerModule}
if local != "" {
parts["the local name"] = local
}
for what, v := range parts {
if !namePart.MatchString(v) {
return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v)
}
}
name := "pair/" + provider + "/" + provision + "/" + consumer + "/" + consumerModule
if local != "" {
name += "/" + local
}
return name, nil
}
// secret is the row's name in the vault and the machines it was sealed to.
func (m MoveOf) secret() (string, []string, error) {
own := m.Node != "" || m.Module != "" || m.Name != ""
pair := m.Provider != "" || m.Provision != "" || m.Consumer != "" || m.ConsumerModule != "" || m.Local != ""
switch {
case own && !pair:
name, err := OwnSecret(m.Node, m.Module, m.Name)
return name, []string{m.Node}, err
case pair && !own:
name, err := PairSecret(m.Provider, m.Provision, m.Consumer, m.ConsumerModule, m.Local)
return name, []string{m.Provider, m.Consumer}, err
}
return "", nil, errors.New("name a module's own secret (node, module, name) or a pair credential (provider, " +
"provision, consumer, consumer_module and local when it has one), one of the two")
}
// movedOrigins are the controller's words for where a value came from, as the vault's.
var movedOrigins = map[string]string{"made": OriginMade, "accepted": OriginAccepted, "issued": OriginIssued}
// nodeEngineOf is the machine whose node-engine caller is, if it is one.
func nodeEngineOf(caller string) (string, bool) {
node, ok := strings.CutPrefix(caller, NodeEnginePrefix)
return node, ok && namePart.MatchString(node) && !strings.Contains(node, ".")
}
// MoveOpen opens a call to take one value of the controller's store from the node-engine of a machine it was
// sealed to.
func (v *Vault) MoveOpen(caller string, m MoveOf) (Opened, error) {
node, ok := nodeEngineOf(caller)
if !ok {
if caller == "" {
caller = "an unproven caller"
}
return Opened{}, fmt.Errorf("move-in takes a value from a machine's node-engine alone, which opens what was "+
"sealed to its machine; %s is not a node-engine", caller)
}
secret, sealedTo, err := m.secret()
if err != nil {
return Opened{}, err
}
if !slices.Contains(sealedTo, node) {
return Opened{}, fmt.Errorf("%s was sealed to %s, not to %s: only a machine it was sealed to moves it in",
secret, strings.Join(sealedTo, " and "), node)
}
if movedOrigins[m.Origin] == "" {
return Opened{}, fmt.Errorf("the origin %q is not one the controller's store holds (made, accepted or "+
"issued); nothing is taken", m.Origin)
}
if len(m.Generation) > 120 || strings.ContainsAny(m.Generation, "\r\n\x00") {
return Opened{}, errors.New("the generation is not one short line; nothing is taken")
}
if m.SealingKey != "" {
stated, err := sealingKey(m.SealingKey)
if err != nil {
return Opened{}, err
}
if err := v.recordNodeKey(node, stated, caller); err != nil {
return Opened{}, err
}
}
id, key, err := v.openCall("move-in", secret, caller, nil)
if err != nil {
return Opened{}, err
}
v.callsMu.Lock()
if c := v.pending[id]; c != nil {
moved := m
c.move = &moved
}
v.callsMu.Unlock()
return Opened{Call: id, SealTo: key, Secret: secret}, nil
}
// readBackKey is the key a read-back is sealed to: a raw X25519 public key in base64, the box the node-engine's
// own sealing key opens. A PEM, which a private key could be, is refused.
func readBackKey(key string) (*[32]byte, error) {
if privateKeyIn(key) || strings.Contains(key, "-----") {
return nil, errors.New("the key to read back to is a raw X25519 public key in base64, never a PEM and never " +
"a private key; nothing was taken")
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(key))
if err != nil || len(raw) != 32 {
return nil, errors.New("the second step names no key to read back to (`read_back_to`, a raw X25519 public " +
"key in base64); nothing was taken")
}
var pub [32]byte
copy(pub[:], raw)
return &pub, nil
}
// MoveTake opens the value sealed to the call's key and keeps it as it is, unless the vault holds another; then
// answers what the store holds, read back and sealed to readBackTo.
func (v *Vault) MoveTake(caller, call, sealed, readBackTo string) (MovedIn, error) {
if _, ok := nodeEngineOf(caller); !ok {
return MovedIn{}, fmt.Errorf("move-in takes a value from a machine's node-engine alone; %q is not one", caller)
}
to, err := readBackKey(readBackTo)
if err != nil {
return MovedIn{}, err
}
c, err := v.takeCall("move-in", call)
if err != nil {
return MovedIn{}, err
}
if c.by != caller || c.move == nil {
return MovedIn{}, fmt.Errorf("the move-in call %q was opened by another caller; nothing was taken", call)
}
value, err := c.open(sealed)
if err != nil {
return MovedIn{}, err
}
defer zero(value)
if len(value) == 0 {
return MovedIn{}, errors.New("the value handed in is empty; nothing was taken")
}
m := c.move
changed, err := v.keepWith(c.secret, string(value), caller, "", movedOrigins[m.Origin], keepOptions{
neverReplace: true, madeAt: m.MadeAt, movedFrom: &MovedFrom{By: caller, Generation: m.Generation}})
var another *HeldAnother
if errors.As(err, &another) {
// Answered, not failed: the caller reads the reason and the held record. A caller that reads no `refused` finds
// no read-back and fails as loudly as before.
v.log("did not move in %s from %s: %v", c.secret, caller, err)
return MovedIn{Name: c.secret, Generation: another.Generation,
Refused: &MoveRefused{Reason: RefusedHeldAnother, Held: another}, Why: err.Error()}, nil
}
if err != nil {
return MovedIn{}, err
}
rec, held, err := v.Store.Read(c.secret)
if err != nil || !held {
return MovedIn{}, fmt.Errorf("%s was kept but cannot be read back from the vault's store: %v", c.secret, err)
}
back, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
return MovedIn{}, fmt.Errorf("%s was kept but does not open with the vault's key: %w", c.secret, err)
}
boxed, err := box.SealAnonymous(nil, []byte(back), to, rand.Reader)
back = ""
if err != nil {
return MovedIn{}, err
}
v.log("moved in %s (generation %d) from %s, origin %s, the controller's generation %q", c.secret,
changed.Generation, caller, movedOrigins[m.Origin], m.Generation)
return MovedIn{Name: c.secret, Generation: rec.Generation, Unchanged: changed.Unchanged,
ReadBack: base64.StdEncoding.EncodeToString(boxed)}, nil
}
// ---- move-asks ----------------------------------------------------------------------------------------------
// StateNodeKey records the sealing key the node-engine of caller's machine states in its own name (novox/hq ADR 0372,
// issue 786), at its start and whenever the key changes, whether or not it ever moved a secret in. The machine is the
// caller's, as the bus proved it (`node.<machine>`), never an argument. A first key is handled like a changed one: the
// machine's core secrets, sealed to no key until now or to the old one, are said changed so the controller asks again
// (ADR 0349 §16). The same key again changes nothing and says nothing.
func (v *Vault) StateNodeKey(caller, key string) error {
node, ok := nodeEngineOf(caller)
if !ok {
if caller == "" || caller == stdio.Unproven {
caller = "an unproven caller"
}
return fmt.Errorf("node-key takes a machine's sealing key from that machine's node-engine alone, in its own "+
"name; %s is not a node-engine", caller)
}
stated, err := sealingKey(key)
if err != nil {
return err
}
held, had, err := v.Store.NodeKey(node)
if err != nil {
return err
}
if had && held == stated {
return nil
}
if err := v.recordNodeKey(node, stated, caller); err != nil {
return err
}
all, err := v.Store.List()
if err != nil {
return fmt.Errorf("%s's key is recorded, but its core secrets could not be listed to say them changed: %w", node, err)
}
for _, r := range all {
parts := strings.Split(r.Name, "/")
if len(parts) == 4 && parts[0] == "own" && parts[1] == node && slices.Contains(CoreModules, parts[2]) {
v.announce(Changed{Name: r.Name, Generation: r.Generation, At: v.Now().UTC(), By: caller})
}
}
return nil
}