Files
mesh-controller/internal/link/movesecret.go
T

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
}