Files
mesh-catalog/modules/mesh-vault/cmd/mesh-vault/move.go
T

370 lines
15 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"
"strconv"
"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"`
}
// 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}})
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 ----------------------------------------------------------------------------------------------
// MovedAsk is one row of the controller's record of asks at a desk (its secret_ask table): no value.
type MovedAsk struct {
ID int64 `json:"id"`
Node string `json:"node"`
Module string `json:"module"`
Name string `json:"name"`
Desk string `json:"desk"`
AskedBy string `json:"asked_by"`
OpenedAt time.Time `json:"opened_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
// AsksMoved is what a copy of the record of asks did.
type AsksMoved struct {
Copied int `json:"copied"`
Already int `json:"already"`
}
// MovedAsksAtOnce bounds one call: the controller sends its record in parts.
const MovedAsksAtOnce = 500
// movedAskID is the vault's id of a row of the controller's record.
func movedAskID(id int64) string { return "controller-" + strconv.FormatInt(id, 10) }
// MoveAsks copies rows of the controller's record of asks into the vault's, as they are: each ask's opening and,
// when it ended, its end — once, so a copy run again adds nothing. From the controller alone, whose record it is.
// Every row is judged before any is written.
func (v *Vault) MoveAsks(caller string, asks []MovedAsk) (AsksMoved, error) {
if err := fromTheController(caller, "move-asks"); err != nil {
return AsksMoved{}, err
}
if len(asks) > MovedAsksAtOnce {
return AsksMoved{}, fmt.Errorf("%d asks at once is more than %d; send them in parts", len(asks), MovedAsksAtOnce)
}
type lines struct{ opened, ended AskRecord }
var all []lines
for _, a := range asks {
secret, err := OwnSecret(a.Node, a.Module, a.Name)
if err != nil {
return AsksMoved{}, fmt.Errorf("ask %d: %w; nothing was copied", a.ID, err)
}
if !namePart.MatchString(a.Desk) || a.OpenedAt.IsZero() || a.ID <= 0 {
return AsksMoved{}, fmt.Errorf("ask %d names no desk, no time it opened or no id; nothing was copied", a.ID)
}
l := lines{opened: AskRecord{ID: movedAskID(a.ID), At: a.OpenedAt.UTC(), Event: AskOpened, Secret: secret,
Node: a.Node, Module: a.Module, Name: a.Name, Desk: a.Desk, AskedBy: askedByName(a.AskedBy),
Caller: ControllerCaller}}
if a.EndedAt != nil {
if a.Outcome != "given" && !askOutcomes[a.Outcome] {
return AsksMoved{}, fmt.Errorf("ask %d ended %q, which is no outcome the record knows; nothing was "+
"copied", a.ID, a.Outcome)
}
l.ended = AskRecord{ID: movedAskID(a.ID), At: a.EndedAt.UTC(), Event: AskEnded, Secret: secret,
Outcome: a.Outcome}
}
all = append(all, l)
}
v.Store.asksMu.Lock()
defer v.Store.asksMu.Unlock()
held, err := v.Store.asks()
if err != nil {
return AsksMoved{}, fmt.Errorf("the record of asks cannot be read: %w; nothing was copied", err)
}
seen := map[string]bool{}
for _, r := range held {
if r.Event == AskOpened {
seen[r.ID] = true
}
}
out := AsksMoved{}
for _, l := range all {
if seen[l.opened.ID] {
out.Already++
continue
}
if err := v.recordAsk(l.opened); err != nil {
return out, fmt.Errorf("ask %s could not be copied, after %d: %w", l.opened.ID, out.Copied, err)
}
if l.ended.ID != "" {
if err := v.recordAsk(l.ended); err != nil {
return out, fmt.Errorf("the end of ask %s could not be copied, after %d: %w", l.opened.ID, out.Copied, err)
}
}
seen[l.opened.ID] = true
out.Copied++
}
return out, nil
}
// 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
}