Files
mesh-host/internal/link/enrol_seat.go
T
jschoubben a9a0f7b4e0 Say the node's facts in its haves alone, and enrol through the seat alone
The CONTROL stream the facts, the enrolment and the overlay rekey were published into was the controller's, which ended
by its unassignment (issue 1126) and whose stream is removed after group B's delivery: a publish there would fail every
facts event before its haves are written. The facts go into the haves as they did after the report, the enrolment goes
through mesh-enrolment alone, and overlay take is refused because no seat takes a rekey now.

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

242 lines
9.9 KiB
Go

package link
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Joining a mesh through the seat mesh-enrolment (novox/hq ADR 0587 §6, issue 774): the node-engine dials the bus as enrol.<node>
// with the certificate's fingerprint pinned, calls `redeem` with the public halves of the keys it made where it runs and a
// proof signed with its identity key, refuses an answer not signed by the key its token carries, keeps its membership, dials
// again as the login node-logins names for it, and calls `confirm`. The controller's enrolment subject (Enrol) stays for as long
// as the controller holds its lease.
// EnrolmentSeat is the seat that adopts a new machine, and its two verbs a node-engine calls.
const (
EnrolmentSeat = "mesh-enrolment"
EnrolmentRedeem = "redeem"
EnrolmentConfirm = "confirm"
)
// CalledSeatSubject is where a node-engine calls a seat's verb in its own name: the caller's user, each dot written `~`.
func CalledSeatSubject(seat, verb, user string) string {
return "mesh.seat." + seat + ".call." + verb + "." + strings.ReplaceAll(user, ".", "~")
}
// ErrNoSeat is the bus saying nothing serves the seat for this caller: the enrolment user may not publish the seat's call, or
// no holder answers it.
var ErrNoSeat = errors.New("the seat mesh-enrolment is not served to this machine's token")
// VerifyRedeemAnswer holds an answer to what it must be before anything is kept (ADR 0587 §6): it names the node asked for,
// carries the signing key the token carries, and is signed by that key under the context of an enrolment's answer. An answer
// signed by another key is refused, and so is one that names another signer.
func VerifyRedeemAnswer(a delivery.RedeemAnswer, node string, signer ed25519.PublicKey) error {
if a.Node != node {
return fmt.Errorf("the answer is for %q, and this machine asked as %q", a.Node, node)
}
if a.Signer != base64.StdEncoding.EncodeToString(signer) {
return errors.New("the answer names a signing key other than the one the token carries")
}
sig, err := base64.StdEncoding.DecodeString(a.Signature)
if err != nil || len(sig) == 0 {
return errors.New("the answer is not signed")
}
kindContext, err := delivery.SignContext(delivery.KindEnrolment)
if err != nil {
return err
}
if _, err := delivery.VerifyBy([]delivery.Signer{{Name: "the token's", Key: signer}}, kindContext, a.SignedBytes(), sig); err != nil {
return fmt.Errorf("the answer is not signed by the key the token carries: %w", err)
}
if a.Login == "" || a.Account == "" {
return errors.New("the answer names no login or no account")
}
return nil
}
// seatAnswer reads a seat call's reply: its result, or its refusal as an error.
func seatAnswer(reply []byte) (json.RawMessage, error) {
var env struct {
Result json.RawMessage `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(reply, &env); err != nil {
return nil, errors.New("the seat answered with something that is not a tool's answer")
}
if env.Error != "" {
return nil, errors.New(env.Error)
}
return env.Result, nil
}
// Redeem calls the seat's redeem as enrol.<node>, with the token's secret as the bus password, on the pinned certificate. It
// asks again with the same keys while the holder says to (a stopped redeem finishes; the bus node loads the account), , for
// EnrolPatience. An answer is returned as the holder gave it: the caller
// verifies it (VerifyRedeemAnswer) before it keeps anything.
func Redeem(ctx context.Context, to Approach, node, secret string, req delivery.Redeem, timeout time.Duration) (delivery.RedeemAnswer, error) {
if to.Transport != OnNATS {
return delivery.RedeemAnswer{}, fmt.Errorf("this token is for the %q bus, and the mesh's bus is %s", to.Transport, OnNATS)
}
config, err := PinnedConfig(to.Fingerprint)
if err != nil {
return delivery.RedeemAnswer{}, err
}
user := delivery.EnrolmentUser(node)
conn, err := nats.Connect(natsURL(to.Address), nats.Secure(config), nats.CustomInboxPrefix("_INBOX."+user),
nats.UserInfo(user, secret), nats.Name("mesh-host/enrol/"+node), nats.Timeout(timeout), nats.NoReconnect())
if err != nil {
if errors.Is(err, ErrWrongCertificate) {
return delivery.RedeemAnswer{}, err
}
return delivery.RedeemAnswer{}, fmt.Errorf("cannot reach the bus at %s as %s: %w", to.Address, user, err)
}
defer conn.Close()
body, err := json.Marshal(req)
if err != nil {
return delivery.RedeemAnswer{}, err
}
began := time.Now()
for {
msg, err := conn.RequestWithContext(ctx, CalledSeatSubject(EnrolmentSeat, EnrolmentRedeem, user), body)
switch {
case errors.Is(err, nats.ErrNoResponders) || (err != nil && strings.Contains(err.Error(), "permissions violation")):
return delivery.RedeemAnswer{}, fmt.Errorf("%w: %v", ErrNoSeat, err)
case err != nil:
return delivery.RedeemAnswer{}, fmt.Errorf("the seat did not answer redeem: %w", err)
}
result, aerr := seatAnswer(msg.Data)
if aerr != nil {
if strings.Contains(aerr.Error(), "call redeem again with the same keys") && time.Since(began) < EnrolPatience {
select {
case <-ctx.Done():
return delivery.RedeemAnswer{}, ctx.Err()
case <-time.After(AskAgainAfter):
continue
}
}
return delivery.RedeemAnswer{}, fmt.Errorf("%w: %v", ErrRefused, aerr)
}
var answer delivery.RedeemAnswer
if err := json.Unmarshal(result, &answer); err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("the seat's answer to redeem is not one: %w", err)
}
return answer, nil
}
}
// Confirming is this machine dialled as its own login after it redeemed: it reads its key of node-logins and calls confirm.
type Confirming interface {
// Login is this node's key of the vault's node-logins.
Login(ctx context.Context) (delivery.NodeLogin, error)
// Confirm calls the seat's confirm; the runtime's proof of the caller as node.<node> is the confirmation (ADR 0583 §3).
Confirm(ctx context.Context) error
Close()
}
// Joining is the engine's side of a redeem, in the order ADR 0587 §6 gives it, over what reaches the bus and the disk.
type Joining struct {
Node string
Signer ed25519.PublicKey
// Redeem calls the seat; Open opens the account sealed to this node's sealing key; Keep writes the membership, the login
// and the keys down; Dial dials the bus as login and answers it ready to read node-logins and confirm.
Redeem func(ctx context.Context) (delivery.RedeemAnswer, error)
Open func(a delivery.RedeemAnswer) (string, error)
Keep func(a delivery.RedeemAnswer, password string) error
Dial func(ctx context.Context, login, password string) (Confirming, error)
}
// Run joins: the answer is verified before anything is kept (an answer not signed by the key the token carries keeps nothing),
// kept, and then the machine dials as the login its key of node-logins names and confirms. Nothing is applied here: the engine
// applies once its placement names a module.
func (j Joining) Run(ctx context.Context) (delivery.RedeemAnswer, error) {
answer, err := j.Redeem(ctx)
if err != nil {
return delivery.RedeemAnswer{}, err
}
if err := VerifyRedeemAnswer(answer, j.Node, j.Signer); err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("%w: %v; nothing was kept", ErrRefused, err)
}
password, err := j.Open(answer)
if err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("the account in the answer cannot be opened: %w; nothing was kept", err)
}
if err := j.Keep(answer, password); err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("the mesh accepted this node as %q and what it was given could not be kept: %w\n"+
"That token is spent, so getting back needs a new one", answer.Node, err)
}
c, err := j.Dial(ctx, answer.Login, password)
if err != nil {
return answer, fmt.Errorf("this node is known to the mesh as %q and cannot dial as %s yet: %w", answer.Node, answer.Login, err)
}
defer c.Close()
nl, err := c.Login(ctx)
if err != nil {
return answer, fmt.Errorf("this node's key of node-logins cannot be read: %w", err)
}
if nl.Login != answer.Login {
return answer, fmt.Errorf("node-logins names %s for this node and the answer gave it %s: it is not confirmed", nl.Login, answer.Login)
}
if err := c.Confirm(ctx); err != nil {
return answer, fmt.Errorf("the enrolment is redeemed and not confirmed (a person ends it with mesh-enrolment.revoke): %w", err)
}
return answer, nil
}
// confirmingNats is a node dialled as its own login on the pinned certificate.
type confirmingNats struct {
conn *nats.Conn
node string
user string
}
// DialConfirming dials the bus as login with password, on the pinned certificate, ready to read node-logins and confirm.
func DialConfirming(ctx context.Context, m Membership, login, password string, timeout time.Duration) (Confirming, error) {
config, err := PinnedConfig(m.Fingerprint)
if err != nil {
return nil, err
}
conn, err := nats.Connect(natsURL(m.Broker), nats.Secure(config), nats.UserInfo(login, password),
nats.CustomInboxPrefix("_INBOX.node."+m.Node), nats.Name("mesh-host/"+m.Node+"/enrol"), nats.Timeout(timeout), nats.NoReconnect())
if err != nil {
return nil, fmt.Errorf("cannot dial as %s: %w", login, err)
}
return &confirmingNats{conn: conn, node: m.Node, user: login}, nil
}
func (c *confirmingNats) Login(ctx context.Context) (delivery.NodeLogin, error) {
js, err := c.conn.JetStream()
if err != nil {
return delivery.NodeLogin{}, err
}
kv, err := js.KeyValue(delivery.NodeLoginsBucket)
if err != nil {
return delivery.NodeLogin{}, err
}
e, err := kv.Get(c.node)
if err != nil {
return delivery.NodeLogin{}, err
}
return delivery.DecodeNodeLogin(e.Value())
}
func (c *confirmingNats) Confirm(ctx context.Context) error {
msg, err := c.conn.RequestWithContext(ctx, CalledSeatSubject(EnrolmentSeat, EnrolmentConfirm, c.user), []byte(`{}`))
if err != nil {
return err
}
_, err = seatAnswer(msg.Data)
return err
}
func (c *confirmingNats) Close() { c.conn.Close() }