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
210 lines
9.3 KiB
Go
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
|
|
}
|