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..call..`, 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 }