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. // 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., 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. 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() }