242 lines
9.9 KiB
Go
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() }
|