Files
mesh-host/internal/link/enrol_seat_test.go
T
jschoubben 9ae30a7bbb 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
2026-11-02 01:49:01 +01:00

105 lines
4.0 KiB
Go

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")
}
}