diff --git a/cmd/mesh-host/enrol_seat.go b/cmd/mesh-host/enrol_seat.go new file mode 100644 index 0000000..55a71c3 --- /dev/null +++ b/cmd/mesh-host/enrol_seat.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + + "git.novox.be/novox/mesh-sdk/go/delivery" + + "github.com/novox/mesh-host/internal/identity" + "github.com/novox/mesh-host/internal/link" +) + +// enrolThroughSeat joins this machine through the seat mesh-enrolment (novox/hq ADR 0587 §6, issue 774): it redeems the token +// with the keys it made, verifies the vault's signed answer against the key the token carries before it keeps anything, keeps +// its membership and its login, dials as the login node-logins names and confirms. handled is false when the bus does not serve +// the seat to this token (the controller still holds its lease, so its enrolment subject is the way in): nothing was sent or +// kept then, and the caller enrols as before. +func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, node string, mine identity.Identity, + sealing identity.SealingKey, serving identity.ServingKey, reported map[string]any, found *link.Tunnel, identityPath string) (handled bool, err error) { + + req := delivery.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(mine.Public), OverlayKey: mine.Overlay.Public, + SealingKey: sealing.Public, ServingKey: serving.Public, Profile: reported} + if found != nil { + if req.Tunnel, err = json.Marshal(found); err != nil { + return true, err + } + } + req.Proof = base64.StdEncoding.EncodeToString(mine.Sign(delivery.RedeemProof(node, req.PublicKey, req.OverlayKey, req.SealingKey, req.ServingKey))) + to := link.Approach{Address: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS} + membership := link.Membership{Node: node, Broker: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS} + j := link.Joining{Node: node, Signer: token.SignerKey(), + Redeem: func(ctx context.Context) (delivery.RedeemAnswer, error) { + return link.Redeem(ctx, to, node, token.Secret, req, opts.timeout) + }, + Open: func(a delivery.RedeemAnswer) (string, error) { + value, err := sealing.Open(a.Account) + return string(value), err + }, + Keep: func(a delivery.RedeemAnswer, password string) error { + // The mesh's name for this node wins over what the machine called itself, as on the controller's path. + mine.Node = a.Node + mine.Membership = identity.Membership{Broker: token.Broker, Fingerprint: token.Fingerprint, Signer: token.Signer, + Password: password, Transport: link.OnNATS} + if err := writeLogins(loginsFile(opts.state), link.LoginsState{Current: a.Login, Values: map[string]string{a.Login: password}}); err != nil { + return fmt.Errorf("cannot keep the login %s: %w", a.Login, err) + } + if err := identity.Save(identityPath, mine); err != nil { + return err + } + if !mine.Membership.Joined() { + return errors.New("the answer did not say how to reach the mesh again, so the identity could not be used after a restart") + } + return keepJoinedKeys(opts, mine, sealing, serving) + }, + Dial: func(ctx context.Context, login, password string) (link.Confirming, error) { + return link.DialConfirming(ctx, membership, login, password, opts.timeout) + }, + } + answer, err := j.Run(ctx) + switch { + case errors.Is(err, link.ErrNoSeat): + return false, nil + case err != nil: + return true, err + } + fmt.Printf("\nenrolled as %s through the seat mesh-enrolment, dialled as %s and confirmed\n", answer.Node, answer.Login) + fmt.Printf(" identity %s\n", identityPath) + fmt.Println(" nothing is applied until the placement of this node names a module") + return true, nil +} + +// keepJoinedKeys writes the private halves of the keys this machine made, after its identity: an identity naming a key that is +// not there looks joined and cannot come up, so the identity is written first and a failure here is said plainly. +func keepJoinedKeys(opts options, mine identity.Identity, sealing identity.SealingKey, serving identity.ServingKey) error { + if err := os.WriteFile(identity.OverlayKeyPath(opts.state), []byte(mine.Overlay.Private+"\n"), 0o600); err != nil { + return fmt.Errorf("cannot write this node's overlay key: %w", err) + } + if err := os.WriteFile(identity.SealingKeyPath(opts.state), []byte(sealing.Private+"\n"), 0o600); err != nil { + return fmt.Errorf("cannot write this node's sealing key: %w", err) + } + if err := identity.WriteServingKey(identity.ServingKeyPath(opts.state), serving); err != nil { + return fmt.Errorf("cannot write this node's serving key: %w", err) + } + return nil +} diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 592b1ab..fd601a3 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -822,6 +822,11 @@ func enrol(ctx context.Context, opts options) error { // the bus's name, so every enrolment ended at "this token is for the \"\" bus" // (novox/hq 04-ISSUES/146). Nothing caught it because nothing had enrolled since the bus // changed. + // Through the seat mesh-enrolment when the bus serves it to this token (novox/hq ADR 0587 §6): the controller's enrolment + // subject below stays for as long as the controller holds its lease. + if handled, err := enrolThroughSeat(ctx, opts, token, *name, mine, sealing, serving, reported, found, identityPath); handled || err != nil { + return err + } reply, err := link.Enrol(ctx, link.Approach{Address: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS}, *name, token.Secret, diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go index d775e46..8a1e4b6 100644 --- a/internal/identity/identity_test.go +++ b/internal/identity/identity_test.go @@ -235,7 +235,9 @@ func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) { } } -func TestACompleteTokenParses(t *testing.T) { +// A complete token parses, a pasted one tolerates whitespace, and one of another version is refused (three tests merged; the +// token's shape is mesh-sdk's since ADR 0587 §3). +func TestACompleteTokenParsesAPastedOneToleratesWhitespaceAndAnotherVersionIsRefused(t *testing.T) { got, err := ParseToken(completeToken(t)) if err != nil { t.Fatal(err) @@ -243,12 +245,12 @@ func TestACompleteTokenParses(t *testing.T) { if got.Broker != "192.0.2.10:5671" || len(got.SignerKey()) != ed25519.PublicKeySize { t.Errorf("parsed %+v", got) } -} - -func TestAPastedTokenTolerantOfWhitespace(t *testing.T) { if _, err := ParseToken(" " + completeToken(t) + "\n"); err != nil { t.Errorf("a pasted token was refused: %v", err) } + if _, err := ParseToken(encodeToken(t, `{"v":99,"broker":"b","fingerprint":"f","secret":"s"}`)); err == nil { + t.Fatal("a token from an unknown version was accepted") + } } func TestAnIncompleteTokenIsRefusedWholeAndSaysWhatIsMissing(t *testing.T) { @@ -272,12 +274,6 @@ func TestAnIncompleteTokenIsRefusedWholeAndSaysWhatIsMissing(t *testing.T) { } } -func TestATokenFromAnotherVersionIsRefused(t *testing.T) { - if _, err := ParseToken(encodeToken(t, `{"v":99,"broker":"b","fingerprint":"f","secret":"s"}`)); err == nil { - t.Fatal("a token from an unknown version was accepted") - } -} - func TestGarbageIsRefused(t *testing.T) { for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8"} { if _, err := ParseToken(bad); err == nil { diff --git a/internal/identity/token.go b/internal/identity/token.go index 5241334..f024bb8 100644 --- a/internal/identity/token.go +++ b/internal/identity/token.go @@ -1,112 +1,15 @@ package identity -import ( - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "fmt" - "strings" -) +import "git.novox.be/novox/mesh-sdk/go/delivery" -// Token is what a person carries to a machine that is joining. -// -// novox/hq ADR 0004 — four things: where the broker is, what certificate to expect there, whose -// signature to believe afterwards, and a one-time right to join. -// -// THIS IS A WIRE FORMAT SHARED WITH THE CONTROL PLANE, which writes it. The two definitions are -// separate on purpose — the host requires nothing present and does not import the control plane — -// so they are held together by a test on each side asserting the exact field names rather than by -// a shared type. If a field is renamed here and not there, that test fails on both sides. -type Token struct { - Version int `json:"v"` +// Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the broker is, what certificate to +// expect there, whose signature to believe afterwards, and a one-time right to join. Its shape and its reading are mesh-sdk's +// (delivery.Token), which the vault that makes it, mesh-cli that opens it and this engine that parses it share (novox/hq ADR +// 0587 §3, issue 774); these are the names this package has always had for them. +type Token = delivery.Token - // Node is what the mesh calls this machine, and it arrives here because the node cannot work - // it out. The broker account it must authenticate as is named after it, so it has to be known - // before the mesh can say anything — and without it enrolment is a connection refused with an - // empty username, which names nothing. - Node string `json:"node,omitempty"` +// TokenTunnel is the joining machine's side of its first tunnel (delivery.TokenTunnel). +type TokenTunnel = delivery.TokenTunnel - Broker string `json:"broker,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - Signer []byte `json:"signer,omitempty"` - Secret string `json:"secret"` - - // Adopted says this node joins adopted (novox/hq ADR 0100). The host checks it speaks the - // firewall found here before enrolling, because an adopted node keeps that firewall in force. - // Absent for a converged node. - Adopted bool `json:"adopted,omitempty"` - - // Tunnel is this machine's first tunnel, when the token was issued for the key it made with - // `key` (novox/hq ADR 0169): its own address and the hub to reach. It brings the tunnel up from - // this alone and reaches the bus over it, so the bus never has to face the internet. - Tunnel *TokenTunnel `json:"tunnel,omitempty"` -} - -// TokenTunnel is the joining machine's side of its first tunnel. Field names are the wire format -// the controller writes. -type TokenTunnel struct { - Key string `json:"key"` - Address string `json:"address"` - Range string `json:"range"` - HubKey string `json:"hub_key"` - HubEndpoint string `json:"hub_endpoint"` -} - -// ParseToken reads a token a person pasted. -// -// Every refusal here says *this is not a token* rather than *this is the wrong token*. The -// difference matters once there is a mesh: a host must tell "this is not from the mesh I joined" -// apart from "this is malformed" (novox/hq ADR 0004), and the first is a signature check later, -// not a parse failure here. -func ParseToken(encoded string) (Token, error) { - raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded)) - if err != nil { - return Token{}, fmt.Errorf("this is not a token: %w", err) - } - var t Token - if err := json.Unmarshal(raw, &t); err != nil { - return Token{}, fmt.Errorf("this is not a token: %w", err) - } - if t.Version != 1 { - return Token{}, fmt.Errorf( - "this token says it is version %d, and this host understands version 1", t.Version) - } - - var missing []string - if strings.TrimSpace(t.Broker) == "" { - missing = append(missing, "the broker's address") - } - if strings.TrimSpace(t.Fingerprint) == "" { - missing = append(missing, "the broker certificate's fingerprint") - } - if len(t.Signer) != ed25519.PublicKeySize { - missing = append(missing, "the control plane's signing key") - } - if strings.TrimSpace(t.Secret) == "" { - missing = append(missing, "the one-time secret") - } - if tt := t.Tunnel; tt != nil { - for _, part := range []struct{ value, says string }{ - {tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"}, - {tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"}, - {tt.HubEndpoint, "where the hub's tunnel is dialled"}, - } { - if strings.TrimSpace(part.value) == "" { - missing = append(missing, part.says) - } - } - } - if len(missing) > 0 { - // Refused whole rather than used partially. A token missing the fingerprint would have - // this node connect to whatever answers at that address, and one missing the signing key - // would leave it unable to tell a declaration from a forgery — so an incomplete token is - // not a reduced capability, it is an unsafe one. - return Token{}, fmt.Errorf( - "this token is missing %s, so it cannot be used to join anything", - strings.Join(missing, ", ")) - } - return t, nil -} - -// SignerKey is the control plane's public signing key, as a key. -func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) } +// ParseToken reads a token a person pasted (delivery.ParseToken). +func ParseToken(encoded string) (Token, error) { return delivery.ParseToken(encoded) } diff --git a/internal/link/enrol_seat.go b/internal/link/enrol_seat.go new file mode 100644 index 0000000..c5d7eb7 --- /dev/null +++ b/internal/link/enrol_seat.go @@ -0,0 +1,241 @@ +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`. 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., 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. 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() } diff --git a/internal/link/enrol_seat_test.go b/internal/link/enrol_seat_test.go new file mode 100644 index 0000000..b1393a9 --- /dev/null +++ b/internal/link/enrol_seat_test.go @@ -0,0 +1,104 @@ +package link + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/base64" + "errors" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// signedBy is an answer for ace, signed under the enrolment context by the key seeded, naming that key as its signer. +func signedBy(t *testing.T, seed byte) (delivery.RedeemAnswer, ed25519.PublicKey) { + t.Helper() + priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, ed25519.SeedSize)) + pub := priv.Public().(ed25519.PublicKey) + a := delivery.RedeemAnswer{Node: "ace", Login: "node.ace", Account: `{"sealed":"box"}`, Certificate: "PEM", Signer: base64.StdEncoding.EncodeToString(pub)} + ctx, err := delivery.SignContext(delivery.KindEnrolment) + if err != nil { + t.Fatal(err) + } + a.Signature = base64.StdEncoding.EncodeToString(delivery.SignMessage(priv, ctx, a.SignedBytes())) + return a, pub +} + +type standInConfirming struct { + steps *[]string + nl delivery.NodeLogin +} + +func (c standInConfirming) Login(context.Context) (delivery.NodeLogin, error) { + *c.steps = append(*c.steps, "read node-logins") + return c.nl, nil +} +func (c standInConfirming) Confirm(context.Context) error { + *c.steps = append(*c.steps, "confirm") + return nil +} +func (c standInConfirming) Close() {} + +// 774/S64/AC1 and AC2 (ADR 0587 §6): an answer not signed by the key the token carries (or naming another) is refused and +// nothing is kept or dialled; a signed one is kept, then the engine dials as the login it names, reads its key of node-logins, +// and confirms; a key naming another login than the answer's is not confirmed. +func TestTheEngineKeepsOnlyAnAnswerSignedByTheKeyItsTokenCarries(t *testing.T) { + ctx := context.Background() + good, pub := signedBy(t, 1) + forged, _ := signedBy(t, 2) + badNode := good + badNode.Node = "g14" + for name, c := range map[string]struct { + answer delivery.RedeemAnswer + want string + }{ + "signed by another key": {forged, "signing key other than the one the token carries"}, + "another node": {badNode, `is for "g14"`}, + "altered after signing": {func() delivery.RedeemAnswer { a := good; a.Account = "other"; return a }(), "not signed by the key the token carries"}, + } { + var steps []string + j := Joining{Node: "ace", Signer: pub, + Redeem: func(context.Context) (delivery.RedeemAnswer, error) { return c.answer, nil }, + Open: func(delivery.RedeemAnswer) (string, error) { steps = append(steps, "open"); return "pw", nil }, + Keep: func(delivery.RedeemAnswer, string) error { steps = append(steps, "keep"); return nil }, + Dial: func(context.Context, string, string) (Confirming, error) { + steps = append(steps, "dial") + return nil, errors.New("must not dial") + }} + if _, err := j.Run(ctx); err == nil || !strings.Contains(err.Error(), c.want) || len(steps) != 0 { + t.Errorf("%s: %v, steps %v", name, err, steps) + } + } + var steps []string + login := "node.ace" + j := Joining{Node: "ace", Signer: pub, + Redeem: func(context.Context) (delivery.RedeemAnswer, error) { + steps = append(steps, "redeem") + return good, nil + }, + Open: func(delivery.RedeemAnswer) (string, error) { steps = append(steps, "open"); return "pw", nil }, + Keep: func(a delivery.RedeemAnswer, pw string) error { + steps = append(steps, "keep "+a.Login+" "+pw) + return nil + }, + Dial: func(_ context.Context, l, pw string) (Confirming, error) { + steps = append(steps, "dial "+l+" "+pw) + return standInConfirming{steps: &steps, nl: delivery.NodeLogin{Node: "ace", Login: login}}, nil + }} + if _, err := j.Run(ctx); err != nil { + t.Fatal(err) + } + if want := []string{"redeem", "open", "keep node.ace pw", "dial node.ace pw", "read node-logins", "confirm"}; !slices.Equal(steps, want) { + t.Errorf("steps %v, want %v", steps, want) + } + steps, login = nil, "node.ace--b" + if _, err := j.Run(ctx); err == nil || slices.Contains(steps, "confirm") { + t.Errorf("a key naming another login was confirmed: %v, %v", err, steps) + } + if CalledSeatSubject("mesh-enrolment", "redeem", "enrol.ace") != "mesh.seat.mesh-enrolment.call.redeem.enrol~ace" { + t.Error("the call's subject does not name its caller as the composer grants it") + } +}