Files
mesh-controller/internal/link/movesecret.go
T
mesh-tools 97a2d841c7 Land feature 57's controller work on its release branch
The controller plans config, templates and moved providers' provisions,
hands bus accounts to the vault and judges the new manifest words, on the
feature's SDK and node-engine validator (feature 57, config and provisioning).
2026-10-15 21:27:33 +00:00

130 lines
5.3 KiB
Go

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
}