Node-engine: logins, signers, enrolment tokens and redeems, and the grants part's sealed user list come from the vault and node-enroller clients and the grants state package; no file imports go/delivery (ADR 0634 §1, §4; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 15:47:59 +01:00
parent 4bfa94dfef
commit 13e2fe14aa
22 changed files with 248 additions and 219 deletions
+35 -33
View File
@@ -7,12 +7,14 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"github.com/novox/mesh-host/internal/buckets"
"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>
@@ -40,7 +42,7 @@ var ErrNoSeat = errors.New("the seat mesh-enrolment is not served to this machin
// 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 {
func VerifyRedeemAnswer(a meshnodeenrollerclient.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)
}
@@ -51,11 +53,11 @@ func VerifyRedeemAnswer(a delivery.RedeemAnswer, node string, signer ed25519.Pub
if err != nil || len(sig) == 0 {
return errors.New("the answer is not signed")
}
kindContext, err := delivery.SignContext(delivery.KindEnrolment)
kindContext, err := meshvaultclient.SignContext(meshvaultclient.KindEnrolment)
if err != nil {
return err
}
if _, err := delivery.VerifyBy([]delivery.Signer{{Name: "the token's", Key: signer}}, kindContext, a.SignedBytes(), sig); err != nil {
if _, err := meshvaultclient.VerifyBy([]meshvaultclient.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 == "" {
@@ -83,52 +85,52 @@ func seatAnswer(reply []byte) (json.RawMessage, error) {
// 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) {
func Redeem(ctx context.Context, to Approach, node, secret string, req meshnodeenrollerclient.Redeem, timeout time.Duration) (meshnodeenrollerclient.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)
return meshnodeenrollerclient.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
return meshnodeenrollerclient.RedeemAnswer{}, err
}
user := delivery.EnrolmentUser(node)
user := meshnodeenrollerclient.User(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 meshnodeenrollerclient.RedeemAnswer{}, err
}
return delivery.RedeemAnswer{}, fmt.Errorf("cannot reach the bus at %s as %s: %w", to.Address, user, err)
return meshnodeenrollerclient.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
return meshnodeenrollerclient.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)
return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("%w: %v", ErrNoSeat, err)
case err != nil:
return delivery.RedeemAnswer{}, fmt.Errorf("the seat did not answer redeem: %w", err)
return meshnodeenrollerclient.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()
return meshnodeenrollerclient.RedeemAnswer{}, ctx.Err()
case <-time.After(AskAgainAfter):
continue
}
}
return delivery.RedeemAnswer{}, fmt.Errorf("%w: %v", ErrRefused, aerr)
return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("%w: %v", ErrRefused, aerr)
}
var answer delivery.RedeemAnswer
var answer meshnodeenrollerclient.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 meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("the seat's answer to redeem is not one: %w", err)
}
return answer, nil
}
@@ -137,7 +139,7 @@ func Redeem(ctx context.Context, to Approach, node, secret string, req delivery.
// 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)
Login(ctx context.Context) (meshvault.NodeLoginsByNodeValue, 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()
@@ -149,29 +151,29 @@ type Joining struct {
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
Redeem func(ctx context.Context) (meshnodeenrollerclient.RedeemAnswer, error)
Open func(a meshnodeenrollerclient.RedeemAnswer) (string, error)
Keep func(a meshnodeenrollerclient.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) {
func (j Joining) Run(ctx context.Context) (meshnodeenrollerclient.RedeemAnswer, error) {
answer, err := j.Redeem(ctx)
if err != nil {
return delivery.RedeemAnswer{}, err
return meshnodeenrollerclient.RedeemAnswer{}, err
}
if err := VerifyRedeemAnswer(answer, j.Node, j.Signer); err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("%w: %v; nothing was kept", ErrRefused, err)
return meshnodeenrollerclient.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)
return meshnodeenrollerclient.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"+
return meshnodeenrollerclient.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)
@@ -213,20 +215,20 @@ func DialConfirming(ctx context.Context, m Membership, login, password string, t
return &confirmingNats{conn: conn, node: m.Node, user: login}, nil
}
func (c *confirmingNats) Login(ctx context.Context) (delivery.NodeLogin, error) {
func (c *confirmingNats) Login(ctx context.Context) (meshvault.NodeLoginsByNodeValue, error) {
js, err := c.conn.JetStream()
if err != nil {
return delivery.NodeLogin{}, err
return meshvault.NodeLoginsByNodeValue{}, err
}
kv, err := js.KeyValue(delivery.NodeLoginsBucket)
kv, err := js.KeyValue(buckets.NodeLogins)
if err != nil {
return delivery.NodeLogin{}, err
return meshvault.NodeLoginsByNodeValue{}, err
}
e, err := kv.Get(c.node)
if err != nil {
return delivery.NodeLogin{}, err
return meshvault.NodeLoginsByNodeValue{}, err
}
return delivery.DecodeNodeLogin(e.Value())
return meshvaultclient.DecodeNodeLogin(e.Value())
}
func (c *confirmingNats) Confirm(ctx context.Context) error {
+24 -17
View File
@@ -6,33 +6,34 @@ import (
"crypto/ed25519"
"encoding/base64"
"errors"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"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) {
func signedBy(t *testing.T, seed byte) (meshnodeenrollerclient.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)
a := meshnodeenrollerclient.RedeemAnswer{Node: "ace", Login: "node.ace", Account: `{"sealed":"box"}`, Certificate: "PEM", Signer: base64.StdEncoding.EncodeToString(pub)}
ctx, err := meshvaultclient.SignContext(meshvaultclient.KindEnrolment)
if err != nil {
t.Fatal(err)
}
a.Signature = base64.StdEncoding.EncodeToString(delivery.SignMessage(priv, ctx, a.SignedBytes()))
a.Signature = base64.StdEncoding.EncodeToString(meshvaultclient.SignMessage(priv, ctx, a.SignedBytes()))
return a, pub
}
type standInConfirming struct {
steps *[]string
nl delivery.NodeLogin
nl meshvault.NodeLoginsByNodeValue
}
func (c standInConfirming) Login(context.Context) (delivery.NodeLogin, error) {
func (c standInConfirming) Login(context.Context) (meshvault.NodeLoginsByNodeValue, error) {
*c.steps = append(*c.steps, "read node-logins")
return c.nl, nil
}
@@ -52,18 +53,21 @@ func TestTheEngineKeepsOnlyAnAnswerSignedByTheKeyItsTokenCarries(t *testing.T) {
badNode := good
badNode.Node = "g14"
for name, c := range map[string]struct {
answer delivery.RedeemAnswer
answer meshnodeenrollerclient.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"},
"altered after signing": {func() meshnodeenrollerclient.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 },
Redeem: func(context.Context) (meshnodeenrollerclient.RedeemAnswer, error) { return c.answer, nil },
Open: func(meshnodeenrollerclient.RedeemAnswer) (string, error) {
steps = append(steps, "open")
return "pw", nil
},
Keep: func(meshnodeenrollerclient.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")
@@ -75,18 +79,21 @@ func TestTheEngineKeepsOnlyAnAnswerSignedByTheKeyItsTokenCarries(t *testing.T) {
var steps []string
login := "node.ace"
j := Joining{Node: "ace", Signer: pub,
Redeem: func(context.Context) (delivery.RedeemAnswer, error) {
Redeem: func(context.Context) (meshnodeenrollerclient.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 {
Open: func(meshnodeenrollerclient.RedeemAnswer) (string, error) {
steps = append(steps, "open")
return "pw", nil
},
Keep: func(a meshnodeenrollerclient.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
return standInConfirming{steps: &steps, nl: meshvault.NodeLoginsByNodeValue{Node: "ace", Login: login}}, nil
}}
if _, err := j.Run(ctx); err != nil {
t.Fatal(err)
+2 -2
View File
@@ -7,10 +7,10 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
"time"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go"
)
@@ -114,7 +114,7 @@ func (b OverNATS) GrantsObject(ctx context.Context, name string, limit int64) ([
}
js = bounded
}
return objectByName(ctx, js, delivery.GrantsUserListObjects, delivery.GrantsUserListObjectStream, name, limit)
return objectByName(ctx, js, grantsstate.GrantsUserListObjects, grantsstate.GrantsUserListObjectStream, name, limit)
}
// objectByName is one object of bucket (its stream stream) by its name, no larger than limit, verified against its digest.
+8 -8
View File
@@ -2,12 +2,12 @@ package link
import (
"errors"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"strings"
"sync"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Logins is which of its two logins this node-engine dials the bus as, and the password of each it holds (novox/hq ADR 0583 §1,
@@ -42,12 +42,12 @@ func NewLogins(node, first string, saved LoginsState, save func(LoginsState) err
l.values[login] = password
}
if first != "" {
if _, ok := l.values[delivery.FirstLogin(node)]; !ok {
l.values[delivery.FirstLogin(node)] = first
if _, ok := l.values[meshvaultclient.FirstLogin(node)]; !ok {
l.values[meshvaultclient.FirstLogin(node)] = first
}
}
if l.current == "" {
l.current = delivery.FirstLogin(node)
l.current = meshvaultclient.FirstLogin(node)
}
return l
}
@@ -71,7 +71,7 @@ func (l *Logins) Value(login string) (string, bool) {
func (l *Logins) Hold(login, password string) error {
l.mu.Lock()
defer l.mu.Unlock()
if n, ok := delivery.LoginNode(login); !ok || n != l.node {
if n, ok := meshvaultclient.LoginNode(login); !ok || n != l.node {
return &loginError{login: login, node: l.node}
}
l.values[login] = password
@@ -164,7 +164,7 @@ func (m Membership) User() string {
// Heard takes this node's key of the vault's node-logins as read (a key read, or the empty value of a key gone): the login
// in use becomes the one it names when its password is held. A key naming a login whose password is not held changes
// nothing and says so; no key leaves the login as it is.
func (l *Logins) Heard(nl delivery.NodeLogin) (said string) {
func (l *Logins) Heard(nl meshvault.NodeLoginsByNodeValue) (said string) {
if nl.Node != l.node || nl.Login == "" {
return ""
}
@@ -182,7 +182,7 @@ func (l *Logins) Heard(nl delivery.NodeLogin) (said string) {
}
// rotatingToUs says the key's rotating login is the one in use already: the engine switched ahead of the vault's own state.
func rotatingToUs(nl delivery.NodeLogin, current string) bool {
func rotatingToUs(nl meshvault.NodeLoginsByNodeValue, current string) bool {
return nl.Rotating != "" && nl.Rotating == current
}
+4 -5
View File
@@ -1,10 +1,9 @@
package link
import (
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 774/S61/AC1 and AC2 (ADR 0583 §1): with no node-logins key, or one that names the first login, the engine dials as node.<n>
@@ -20,10 +19,10 @@ func TestTheLoginDialledIsTheOneNodeLoginsNames(t *testing.T) {
if u, p := (Membership{Node: "ace", Password: "plain"}).Credential(); u != "node.ace" || p != "plain" {
t.Fatalf("no logins at all: %s %s", u, p)
}
if said := l.Heard(delivery.NodeLogin{Node: "ace", Login: "node.ace"}); said != "" || m.User() != "node.ace" {
if said := l.Heard(meshvault.NodeLoginsByNodeValue{Node: "ace", Login: "node.ace"}); said != "" || m.User() != "node.ace" {
t.Errorf("the first login named: %q, %s", said, m.User())
}
if said := l.Heard(delivery.NodeLogin{Node: "ace", Login: "node.ace--b"}); !strings.Contains(said, "cannot dial as it") || m.User() != "node.ace" {
if said := l.Heard(meshvault.NodeLoginsByNodeValue{Node: "ace", Login: "node.ace--b"}); !strings.Contains(said, "cannot dial as it") || m.User() != "node.ace" {
t.Errorf("a login held no value for: %q, %s", said, m.User())
}
if err := l.Hold("node.ace--b", "second-secret"); err != nil {
@@ -32,7 +31,7 @@ func TestTheLoginDialledIsTheOneNodeLoginsNames(t *testing.T) {
if err := l.Hold("node.g14", "x"); err == nil {
t.Error("another node's login was kept")
}
if said := l.Heard(delivery.NodeLogin{Node: "ace", Login: "node.ace--b"}); !strings.Contains(said, "dialled as it") {
if said := l.Heard(meshvault.NodeLoginsByNodeValue{Node: "ace", Login: "node.ace--b"}); !strings.Contains(said, "dialled as it") {
t.Errorf("said %q", said)
}
if u, p := m.Credential(); u != "node.ace--b" || p != "second-secret" || saved.Current != "node.ace--b" {
+12 -7
View File
@@ -4,10 +4,11 @@ import (
"context"
"fmt"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Rotator is the engine's side of a node's account rotation (novox/hq ADR 0583 §3-§6, issue 774). The vault begins a rotation
@@ -49,7 +50,7 @@ func (r *Rotator) say(f string, a ...any) {
}
// Heard takes this node's key of node-logins as read.
func (r *Rotator) Heard(ctx context.Context, nl delivery.NodeLogin) {
func (r *Rotator) Heard(ctx context.Context, nl meshvault.NodeLoginsByNodeValue) {
r.mu.Lock()
defer r.mu.Unlock()
if r.fallen != nil && nl.Since.String() != r.fellBackSince {
@@ -61,7 +62,7 @@ func (r *Rotator) Heard(ctx context.Context, nl delivery.NodeLogin) {
// Done (the switch happened and the old login is retired), or no rotation at all: the other login's value goes (§4),
// only now that the vault says so.
r.old, r.failures = "", 0
if other := delivery.OtherLogin(nl.Node, cur); other != "" {
if other := meshvaultclient.OtherLogin(nl.Node, cur); other != "" {
if _, held := r.Logins.Value(other); held {
if err := r.Logins.Drop(other); err != nil {
r.say("the old login %s's value is not dropped: %v", other, err)
@@ -86,18 +87,18 @@ func (r *Rotator) Heard(ctx context.Context, nl delivery.NodeLogin) {
if err := r.Logins.Drop(cur); err != nil {
r.say("the abandoned login %s's value is not dropped: %v", cur, err)
}
case nl.Login == cur && nl.Stage != delivery.RotationBegun:
case nl.Login == cur && nl.Stage != meshvaultclient.RotationBegun:
if r.fellBackSince != "" && r.fellBackSince == nl.Since.String() {
return // fallen back for this rotation: not tried again until the vault begins another
}
r.switchTo(ctx, nl)
case nl.Rotating == cur && nl.Stage != delivery.RotationConfirmed:
case nl.Rotating == cur && nl.Stage != meshvaultclient.RotationConfirmed:
r.confirm(ctx, nl.Rotating)
}
}
// switchTo fetches the new value, test-dials it and, only if it authenticates, switches and confirms (§3).
func (r *Rotator) switchTo(ctx context.Context, nl delivery.NodeLogin) {
func (r *Rotator) switchTo(ctx context.Context, nl meshvault.NodeLoginsByNodeValue) {
next := nl.Rotating
password, held := r.Logins.Value(next)
if !held {
@@ -158,7 +159,7 @@ func (r *Rotator) AuthFailed(login string) {
return
}
text := fmt.Sprintf("%s failed to authenticate %d times before the vault said rotation-done: this node dials as %s again", login, r.failures, r.old)
r.say("%s: %s", delivery.NodeAccountFallbackFinding, text)
r.say("%s: %s", NodeAccountFallbackFinding, text)
now := time.Now
if r.Now != nil {
now = r.Now
@@ -201,3 +202,7 @@ func (r *Rotator) Watch() {
}
}
}
// NodeAccountFallbackFinding is what the node-engine says when it fell back to its old login after repeated
// authentication failures with the new one, before the rotation is done; the watchman raises it (ADR 0550).
const NodeAccountFallbackFinding = "node-account-fallback"
+8 -8
View File
@@ -5,14 +5,14 @@ package link
import (
"context"
"errors"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"strings"
"testing"
"time"
natsserver "github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 774/S65/AC1 (ADR 0583 §2-§6): the engine's rotation against a real bus whose list of users a stand-in vault reloads step by
@@ -114,19 +114,19 @@ func TestARotationAgainstARealBusWhoseListIsReloadedStepByStep(t *testing.T) {
}
ctx := context.Background()
since := time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)
key := func(login, rotating, stage string) delivery.NodeLogin {
return delivery.NodeLogin{Node: "ace", Login: login, Rotating: rotating, Stage: stage, Since: since}
key := func(login, rotating, stage string) meshvault.NodeLoginsByNodeValue {
return meshvault.NodeLoginsByNodeValue{Node: "ace", Login: login, Rotating: rotating, Stage: stage, Since: since}
}
// A whole rotation: begun, the list holds both logins, ready, the switch and the confirmation, the old login retired, done.
srv, reload := start(t)
r, l, confirmed := build(t, srv)
oneAuthenticates(t, srv, "before the rotation")
r.Heard(ctx, key(first, second, delivery.RotationBegun))
r.Heard(ctx, key(first, second, meshvaultclient.RotationBegun))
oneAuthenticates(t, srv, "begun")
reload(first, second)
oneAuthenticates(t, srv, "the list holds both")
r.Heard(ctx, key(first, second, delivery.RotationReady))
r.Heard(ctx, key(first, second, meshvaultclient.RotationReady))
if cur, _ := l.Current(); cur != second {
t.Fatalf("the engine did not switch once the list held both logins: %s", cur)
}
@@ -149,7 +149,7 @@ func TestARotationAgainstARealBusWhoseListIsReloadedStepByStep(t *testing.T) {
// With the list never loaded the engine never switches: the test-dial is refused.
srv, _ = start(t)
r, l, _ = build(t, srv)
r.Heard(ctx, key(first, second, delivery.RotationReady))
r.Heard(ctx, key(first, second, meshvaultclient.RotationReady))
if cur, _ := l.Current(); cur != first || !authenticates(srv, first) {
t.Errorf("the list was never loaded and the engine is on %s", cur)
}
@@ -158,7 +158,7 @@ func TestARotationAgainstARealBusWhoseListIsReloadedStepByStep(t *testing.T) {
srv, reload = start(t)
r, l, _ = build(t, srv)
reload(first, second)
r.Heard(ctx, key(first, second, delivery.RotationReady))
r.Heard(ctx, key(first, second, meshvaultclient.RotationReady))
reload(first)
r.Heard(ctx, key(first, "", ""))
if cur, _ := l.Current(); cur != first || !authenticates(srv, first) {
+12 -12
View File
@@ -5,12 +5,12 @@ import (
"errors"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// rotRig is a Rotator over stand-ins: what the vault hands, what a test-dial answers, and what it was asked, in order.
@@ -53,8 +53,8 @@ func newRotRig() *rotRig {
return g
}
func key(login, rotating, stage string) delivery.NodeLogin {
return delivery.NodeLogin{Node: "ace", Login: login, Rotating: rotating, Stage: stage, Since: time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)}
func key(login, rotating, stage string) meshvault.NodeLoginsByNodeValue {
return meshvault.NodeLoginsByNodeValue{Node: "ace", Login: login, Rotating: rotating, Stage: stage, Since: time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)}
}
// 774/S62/AC1 and AC2 (ADR 0583 §3): at ready the engine fetches the new value and test-dials it; one that does not
@@ -63,18 +63,18 @@ func key(login, rotating, stage string) delivery.NodeLogin {
func TestTheEngineSwitchesOnlyAfterATestDialAndConfirmsAsTheNewLogin(t *testing.T) {
g := newRotRig()
ctx := context.Background()
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationBegun))
g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationBegun))
if len(g.steps) != 0 {
t.Fatalf("before ready: %v", g.steps)
}
g.dialErr = errors.New("authorization violation")
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady))
if cur, _ := g.logins.Current(); cur != "node.ace" || !strings.Contains(strings.Join(g.said, "|"), "stays on node.ace") ||
slices.Contains(g.steps, "confirm as node.ace--b") {
t.Fatalf("a test-dial that failed: %s, said %v, steps %v", cur, g.said, g.steps)
}
g.dialErr = nil
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady))
cur, pw := g.logins.Current()
if cur != "node.ace--b" || pw != "secret-of-node.ace--b" {
t.Fatalf("after a good test-dial: %s %s", cur, pw)
@@ -99,9 +99,9 @@ func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t *
ctx := context.Background()
for _, stage := range []string{"before confirming", "after confirming"} {
g := newRotRig()
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady))
if stage == "after confirming" {
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationConfirmed))
g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationConfirmed))
}
if _, held := g.logins.Value("node.ace"); !held {
t.Fatalf("%s: the old value was dropped before rotation-done", stage)
@@ -121,7 +121,7 @@ func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t *
}
}
g := newRotRig()
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
g.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady))
g.r.Heard(ctx, key("node.ace--b", "", "")) // rotation-done: the vault retired the old login
if _, held := g.logins.Value("node.ace"); held {
t.Error("the old value is kept after rotation-done")
@@ -131,7 +131,7 @@ func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t *
}
// Fallback: two failures with the new value (the bound) before rotation-done.
f := newRotRig()
f.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
f.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady))
f.logins.dialed("node.ace--b", errors.New("nats: authorization violation"))
if cur, _ := f.logins.Current(); cur != "node.ace--b" {
t.Fatalf("fell back after one failure: %s", cur)
@@ -146,7 +146,7 @@ func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t *
t.Errorf("the fall back is no health report's: %v", err)
}
steps := len(f.steps)
f.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
f.r.Heard(ctx, key("node.ace", "node.ace--b", meshvaultclient.RotationReady))
if len(f.steps) != steps {
t.Errorf("the rotation fallen back from was tried again: %v", f.steps[steps:])
}