package link import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "time" "github.com/nats-io/nats.go" "github.com/novox/mesh-controller/internal/broker" ) // The move of one value into the vault, asked of a machine's node-engine (design 57's V3, issue 676; the operator's // decision of 2026-10-14 on that issue). // // The controller cannot open a value it holds; the machine it was sealed to can. So the controller asks that // machine's engine on its own subject, one row of its store at a time, signed as a hand-over is under a context of // its own: the row, where its value came from, the controller's generation, when it was made, and the value as the // store holds it, sealed to that machine. The engine opens it, hands it to the vault's move-in sealed to the call's // key, and answers what the vault kept once the vault's read-back opens to the same value. The engine holds the // same shapes in its own link code (mesh-host internal/link MoveSecretAsk, MoveSecretAnswer); a test on each side // holds the field names. // 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: module and name for a module's own secret on the // machine asked; provider, provision, consumer, consuming module and local name for a pair credential. 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, read back the same, or why nothing was moved. 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"` } // MoveSecretWithin is how long the controller waits for one move: the engine asks the vault twice. const MoveSecretWithin = 45 * time.Second // AskMoveSecret asks one machine's node-engine to move one row's value into the vault, and reads its answer. An // error is the ask not reaching an engine, or an answer that is not one; a refusal is the engine's, in the answer. func AskMoveSecret(ctx context.Context, conn *nats.Conn, signer Signer, node, by string, row MoveSecretAsk, timeout time.Duration) (MoveSecretAnswer, error) { if conn == nil { return MoveSecretAnswer{}, errors.New("this controller is not on the bus") } if signer == nil { return MoveSecretAnswer{}, errors.New("no signing key, so nothing can be asked of an engine") } nonce := make([]byte, 16) if _, err := rand.Read(nonce); err != nil { return MoveSecretAnswer{}, err } raw, err := json.Marshal(struct { HandOverAsk MoveSecretAsk }{HandOverAsk{Node: node, By: by, Expires: time.Now().UTC().Add(HandOverGood), Nonce: hex.EncodeToString(nonce)}, row}) if err != nil { return MoveSecretAnswer{}, err } signature, err := signer.Sign(ctx, append([]byte(MoveSecretContext), raw...)) if err != nil { return MoveSecretAnswer{}, fmt.Errorf("cannot sign the ask: %w", err) } body, err := json.Marshal(SignedHandOver{Ask: raw, Signature: signature}) if err != nil { return MoveSecretAnswer{}, err } asking, cancel := context.WithTimeout(ctx, timeout) defer cancel() refused, stop := refusalsOf(conn, broker.AskMoveSecretSubject(node)) defer stop() type replied struct { msg *nats.Msg err error } done := make(chan replied, 1) go func() { msg, err := conn.RequestWithContext(asking, broker.AskMoveSecretSubject(node), body) done <- replied{msg, err} }() var reply *nats.Msg select { case r := <-done: reply, err = r.msg, r.err case why := <-refused: cancel() return MoveSecretAnswer{}, fmt.Errorf("the bus refused the controller asking %s to move a value: %v", node, why) } switch { case errors.Is(err, nats.ErrNoResponders): return MoveSecretAnswer{}, fmt.Errorf("nothing on %s answers a move: its node-engine is not running, is not on "+ "the bus, or is older than this ask (issue 676); nothing was moved", node) case errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout): return MoveSecretAnswer{}, fmt.Errorf("%s did not answer a move within %s; whether the vault took it is not "+ "known — moving it again changes nothing if it did", node, timeout) case err != nil: return MoveSecretAnswer{}, err } var answer MoveSecretAnswer if err := json.Unmarshal(reply.Data, &answer); err != nil { return MoveSecretAnswer{}, fmt.Errorf("%s answered a move with something unreadable: %w", node, err) } if (answer.Refused == "") == (answer.Secret == "") { return MoveSecretAnswer{}, fmt.Errorf("%s answered a move with neither what the vault kept nor a refusal", node) } return answer, nil }