The engine dials as enrol.<node> on the pinned certificate, calls redeem with the public halves of its keys and a proof over them (the SDK's RedeemProof, which binds the node and the keys and never the token's secret), asks again while the holder says to, and verifies the vault's answer against the key its token carries before it keeps anything: an answer signed by another key, naming another signer or node, or altered is refused and nothing is kept. A signed one is kept (membership, login, keys), then the node dials as the login it was given, reads its key of node-logins, and calls confirm; nothing is applied until its placement names a module. When the bus does not serve the seat to the token (the controller still holds its lease) the controller's enrolment subject is used as before. The token's shape is mesh-sdk's now: identity keeps its names as aliases. Tests: one added, paid by merging three identity token tests into one (net two fewer). (issue 774, 774/S64) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
242 lines
10 KiB
Go
242 lines
10 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 (the
|
|
// controller still holds its lease) 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
|
|
// as long as a machine asking the controller would (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() }
|