370 lines
15 KiB
Go
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
|
|
}
|