Files
mesh-host/internal/link/movesecret.go
T
mesh-tools bff1ac14a1
mesh/merge-gate head bff1ac14a198 merged into trunk 49c9002c04d3 beside mesh-controller#355@8c381172 — pass: builds mesh-host → ace, g14, novox, shanks; no…
mesh/repo-check head bff1ac14a198 merged into trunk 49c9002c04d3 beside mesh-controller#355@8c381172 — pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group release/57-config-and-provisioning delivered: every member is delivered
Feature 57 (config and provisioning): release mesh-host, the reviewed release branch on top of main
2026-10-16 08:24:13 +00:00

210 lines
9.3 KiB
Go

package link
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// The move of a secret into the vault, asked over the bus (design 57's V3, issue 676; the operator's decision of
// 2026-10-14 on that issue).
//
// **Only the machine a value was sealed to can open it**, so the controller, which cannot, asks each machine's
// engine to move what was sealed to it: one row of its store at a time, on AskMoveSecretSubject, signed as a
// hand-over is and held to the same checks (believed), under a signing context of its own, naming the row — a
// module's own secret on this machine, or a pair credential with this machine at one end — its origin, the
// controller's generation, when it was made, and the value as the store holds it, sealed to this machine. The
// engine opens it with its sealing key, hands it to the vault's `move-in` in the vault's two steps, and answers
// what the vault kept only once the vault's read-back opens to the same value. Nothing is rotated; the engine
// writes nothing on this machine. The controller holds the same shapes in its own link code, with a test on each
// side holding the field names.
// AskMoveSecretSubject is where the controller asks this node's engine to move one value sealed to it into the vault.
func AskMoveSecretSubject(node string) string { return "mesh.node." + node + ".ask.move-secret" }
// MoveSecretContext is prefixed to a move ask's bytes before signing: never another ask's, nor a declaration's.
const MoveSecretContext = "novox-mesh move-secret v1\n"
// MoveSecretAsk is a move's own fields, beside a hand-over's (node, by, expires, nonce; no path): the row — module
// and name for a module's own secret on the ask's node; provider, provision, consumer, consumer module and local
// name for a pair credential — where its value came from, the controller's generation, when it was made, the value
// sealed to this machine as the store holds it, and the sealing key the store says it was sealed to.
type MoveSecretAsk struct {
Module string `json:"module,omitempty"`
Name string `json:"name,omitempty"`
Provider string `json:"provider,omitempty"`
Provision string `json:"provision,omitempty"`
Consumer string `json:"consumer,omitempty"`
ConsumerModule string `json:"consumer_module,omitempty"`
Local string `json:"local,omitempty"`
Origin string `json:"origin"`
Generation string `json:"generation,omitempty"`
MadeAt time.Time `json:"made_at"`
Sealed string `json:"sealed"`
SealedTo string `json:"sealed_to,omitempty"`
}
// MoveSecretAnswer is the engine's answer: what the vault kept, or why nothing was moved — never both.
type MoveSecretAnswer struct {
Said string `json:"said,omitempty"`
Refused string `json:"refused,omitempty"`
Secret string `json:"secret,omitempty"`
Generation int `json:"generation,omitempty"`
Unchanged bool `json:"unchanged,omitempty"`
}
// MoveSecret is one move asked and believed, as the handler is given it: the row, this machine, and who asked.
type MoveSecret struct {
MoveSecretAsk
Node, By string
}
// Own says the row is a module's own secret rather than a pair credential.
func (m MoveSecret) Own() bool { return m.Module != "" || m.Name != "" }
// MovedSecret is what the vault kept: its name, its generation, and whether it held the value already.
type MovedSecret struct {
Secret string
Generation int
Unchanged bool
}
// MoveSecretFunc moves one value into the vault: what it kept, or why not.
type MoveSecretFunc func(MoveSecret) (MovedSecret, error)
// MaxMoveSecretAsk bounds a move's request: a row's names, a time, a value sealed and a signature.
const MaxMoveSecretAsk = 64 << 10
// movedOrigins are the origins the controller's store holds.
var movedOrigins = map[string]bool{"made": true, "accepted": true, "issued": true}
func refusedMove(why string) MoveSecretAnswer {
return MoveSecretAnswer{Refused: why + "; nothing was moved"}
}
// oneName is a short name of one line.
func oneName(s string) bool {
return s != "" && len(s) <= MaxActField && !strings.ContainsAny(s, "\r\n\x00/ ")
}
// AnswerMoveSecret is the engine's judgement of one move ask's bytes: believed as a hand-over is, then the row —
// one shape, this machine's, a value to open and an origin — and only then the handler.
func AnswerMoveSecret(check HandOverCheck, handle MoveSecretFunc, body []byte) MoveSecretAnswer {
if handle == nil {
return refusedMove("this engine moves no secret into the vault")
}
ask, signed, refused := believedBytesWithin(check, MoveSecretContext, false, body, MaxMoveSecretAsk)
if refused != "" {
return refusedMove(refused)
}
var move MoveSecretAsk
if err := json.Unmarshal(signed, &move); err != nil {
return refusedMove("the signed ask is not a move's ask: " + err.Error())
}
own := move.Module != "" || move.Name != ""
pair := move.Provider != "" || move.Provision != "" || move.Consumer != "" || move.ConsumerModule != "" ||
move.Local != ""
switch {
case own == pair:
return refusedMove("the ask names neither one module's own secret nor one pair credential")
case own && (!oneName(move.Module) || !oneName(move.Name)):
return refusedMove("the ask's module and name are not short names")
case pair && (!oneName(move.Provider) || !oneName(move.Provision) || !oneName(move.Consumer) ||
!oneName(move.ConsumerModule) || (move.Local != "" && !oneName(move.Local))):
return refusedMove("the ask's provider, provision, consumer, consuming module and local name are not short names")
case pair && move.Provider != ask.Node && move.Consumer != ask.Node:
return refusedMove(fmt.Sprintf("the pair is between %s and %s, and this is %s", move.Provider, move.Consumer,
ask.Node))
case !movedOrigins[move.Origin]:
return refusedMove(fmt.Sprintf("the origin %q is not made, accepted or issued", move.Origin))
case strings.TrimSpace(move.Sealed) == "":
return refusedMove("the ask carries no value sealed to this machine")
case len(move.Generation) > MaxActField || strings.ContainsAny(move.Generation, "\r\n\x00"):
return refusedMove("the ask's generation is not one short line")
}
moved, err := handle(MoveSecret{MoveSecretAsk: move, Node: ask.Node, By: ask.By})
if err != nil {
return refusedMove(err.Error())
}
said := fmt.Sprintf("%s is in the vault at generation %d, read back the same", moved.Secret, moved.Generation)
if moved.Unchanged {
said = fmt.Sprintf("the vault held %s already, the same value (generation %d)", moved.Secret, moved.Generation)
}
return MoveSecretAnswer{Said: said, Secret: moved.Secret, Generation: moved.Generation, Unchanged: moved.Unchanged}
}
// MoveSecretReply is what a move request is answered with, and whether to answer, as HandOverReply.
func MoveSecretReply(check HandOverCheck, handle MoveSecretFunc, reply string, body []byte) ([]byte, bool) {
if reply == "" {
return nil, false
}
answer, err := json.Marshal(AnswerMoveSecret(check, handle, body))
if err != nil {
return nil, false
}
return answer, true
}
// MoveSecrets is a link that hears the controller asking this node to move a value into the vault. Optional.
type MoveSecrets interface {
HearMoveSecrets(MoveSecretFunc)
}
// ---- asking the vault ------------------------------------------------------------------------------------------
// VaultSeat is the vault's seat; its verbs are asked on `mesh.seat.<seat>.call.<verb>.<caller>`, the subject that
// names its caller (novox/hq issue 365), which the bus lets only that user publish.
const VaultSeat = "mesh-vault"
// NodeEngineUser is the bus user of a machine's node-engine, as the controller's broker names it.
func NodeEngineUser(node string) string { return "node." + node }
// CalledVaultSubject is where this node's engine asks one of the vault's verbs in its own name: each dot of its
// user's name written `~`, as the controller's broker writes a caller's token.
func CalledVaultSubject(verb, node string) string {
return "mesh.seat." + VaultSeat + ".call." + verb + "." + strings.ReplaceAll(NodeEngineUser(node), ".", "~")
}
// VaultWithin is how long the engine gives the vault to answer one step.
const VaultWithin = 15 * time.Second
// AskVault asks one of the vault's verbs in this node's engine's own name over the link open now, and answers its
// result, or its refusal as an error.
func (q *Queue) AskVault(ctx context.Context, verb string, args any) (json.RawMessage, error) {
q.init()
q.mu.Lock()
bus := q.bus
q.mu.Unlock()
if bus == nil {
return nil, fmt.Errorf("%w: this machine has no link to the bus open now", ErrNotAsked)
}
asker, ok := bus.(ToolBus)
if !ok {
return nil, fmt.Errorf("%w: the link to the bus open now cannot ask the vault", ErrNotAsked)
}
body, err := json.Marshal(args)
if err != nil {
return nil, err
}
asking, cancel := context.WithTimeout(ctx, VaultWithin)
defer cancel()
reply, err := asker.Ask(asking, CalledVaultSubject(verb, q.Membership.Node), body)
if err != nil {
return nil, fmt.Errorf("the vault's %s could not be asked: %w", verb, err)
}
var env struct {
Result json.RawMessage `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(reply, &env); err != nil {
return nil, fmt.Errorf("the vault answered %s with something that is not a tool's answer", verb)
}
if env.Error != "" {
return nil, errors.New(env.Error)
}
return env.Result, nil
}