mesh-host enrol redeems through the seat mesh-enrolment and refuses an answer the token's key did not sign
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
This commit is contained in:
@@ -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 {
|
||||
|
||||
+10
-107
@@ -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) }
|
||||
|
||||
@@ -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.<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() }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user