Files
mesh-host/internal/link/enrol_seat.go
T

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`. It is the one way in: the controller's enrolment subject
// ended with the controller (issue 1126).
// 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() }