Files
mesh-host/internal/link/movesecret.go
T
jschoubben c7468656af The engine dials as the login the vault's node-logins names
A membership may name its Logins (ADR 0583 §1): the login in use and the password of each login held, kept beside the
identity, so a restart dials as the login in use. The engine follows its own key of node-logins as a watch on every link
and dials as the login it names when it holds its value, with the node's one inbox; with no key, or a key naming the first
login, it dials node.<n> with the membership's password as before. A change of the login in use drops the link so it is
opened again as the new one, and the vault is asked in the name of the login dialled (the subject names the caller).
Tests: one added, paid by merging the pinned-broker test with the malformed-pin test (two into one).

(issue 774, 774/S61)

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

226 lines
10 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"`
// Superseded is set when the vault holds another value for the name, which is the live one: the row is done and
// is not asked again; Generation is the held value's, and Said its record in words.
Superseded bool `json:"superseded,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
// Superseded says, in words, that the vault holds another value for the name, which is the live one: nothing was
// moved, and the move is done (the vault's record of the held value, its generation in Generation).
Superseded string
}
// 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)
}
if moved.Superseded != "" {
said = moved.Superseded
}
return MoveSecretAnswer{Said: said, Secret: moved.Secret, Generation: moved.Generation, Unchanged: moved.Unchanged,
Superseded: moved.Superseded != ""}
}
// 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 CalledVaultSubjectAs(verb, NodeEngineUser(node))
}
// CalledVaultSubjectAs is CalledVaultSubject for the login the engine is dialled as (node.<n> or node.<n>--b, ADR 0583 §1):
// the bus lets only that user publish a subject naming it.
func CalledVaultSubjectAs(verb, user string) string {
return "mesh.seat." + VaultSeat + ".call." + verb + "." + strings.ReplaceAll(user, ".", "~")
}
// 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 *Session) 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, CalledVaultSubjectAs(verb, q.Membership.User()), 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
}