The engine test-dials, switches, confirms, drops and falls back across a rotation of its login

On node-logins' ready the engine fetches the new value through the vault's own hand-out (own/<node>/mesh-host/<login>, opened
with its sealing key), dials a second connection as the new login on the pinned certificate, and switches only if that
authenticates, then calls mesh-vault.node-account-confirm over the link as the new login (the vault is asked in the name of
the login the open link authenticated as, and only once that link is open). It keeps the old value until the key says the
rotation is done and drops it then, never before; an abandon, after the switch before or after the confirmation, switches
back before the new value is dropped; repeated refusals of the new login's credentials before rotation-done bring it back
to the old one, said as the finding node-account-fallback (in the journal: no route for an engine's finding to the watchman
is decided), and the same rotation is not tried again. Tests: two added, paid by merging two identity sealing tests into one;
more to be paid by deleting the identity token tests that move to mesh-sdk with the token (774/S64).

(issue 774, 774/S62, 774/S63)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 01:49:01 +01:00
parent c7468656af
commit e362c800a4
10 changed files with 495 additions and 5 deletions
+67
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
@@ -84,3 +85,69 @@ func followLogins(ctx context.Context, session *link.Session, node string, hear
cancel()
}
}
// loginSecretModule is the module the vault names this engine's login values under: own/<node>/mesh-host/<login> (ADR 0583 §3,
// insight).
const loginSecretModule = "mesh-host"
// fetchLogin asks the vault, through its existing hand-out of a machine's own secrets, for the value of login, sealed to this
// node's key, and opens it with the sealing key; no new verb is asked for it.
func fetchLogin(queue *link.Session, statePath string) func(context.Context, string) (string, error) {
unseal := sealOpener(statePath)
return func(ctx context.Context, login string) (string, error) {
raw, err := queue.AskVault(ctx, "own", map[string]any{})
if err != nil {
return "", err
}
var a struct {
Own []struct {
Module string `json:"module"`
Name string `json:"name"`
Sealed json.RawMessage `json:"sealed"`
} `json:"own"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return "", fmt.Errorf("the vault's own answer is not one: %w", err)
}
for _, o := range a.Own {
if o.Module != loginSecretModule || o.Name != login {
continue
}
sealed := string(o.Sealed)
var text string
if json.Unmarshal(o.Sealed, &text) == nil {
sealed = text
}
value, err := unseal(sealed)
if err != nil {
return "", fmt.Errorf("the value of %s cannot be opened with this node's sealing key: %w", login, err)
}
return string(value), nil
}
return "", fmt.Errorf("the vault hands this node no value for %s (own/%s/%s/%s)", login, queueNode(queue), loginSecretModule, login)
}
}
func queueNode(q *link.Session) string { return q.Membership.Node }
// newRotator is the engine's side of a rotation over this session (ADR 0583 §3-§6): the value fetched through the vault's own
// hand-out, the test-dial on a second connection, the confirmation over the new link, and the finding said in the journal.
func newRotator(queue *link.Session, statePath string, timeout time.Duration, say link.Announce) *link.Rotator {
r := &link.Rotator{Logins: queue.Membership.Logins, Say: say,
Fetch: fetchLogin(queue, statePath),
TestDial: func(ctx context.Context, login, password string) error {
return link.TestDial(ctx, queue.Membership, login, password, timeout)
},
Confirm: func(ctx context.Context) error {
login, _ := queue.Membership.Logins.Current()
if err := queue.WhenLinkedAs(ctx, login); err != nil {
return err
}
_, err := queue.AskVault(ctx, "node-account-confirm", map[string]any{})
return err
},
// The finding the watchman raises (ADR 0583 §6): said in the journal, the route an engine says a finding by.
Finding: func(name, text string) { say("finding " + name + ": " + text) }}
r.Watch()
return r
}
+2
View File
@@ -1194,10 +1194,12 @@ func runLink(ctx context.Context, opts options) error {
trust.onChange(func() { factsOfNow(aside, current.get()) })
go followTrust(aside, queue, trust, say)
// And the login it dials as, which the vault's node-logins names for this node (novox/hq ADR 0583 §1).
rotator := newRotator(queue, opts.state, opts.timeout, say)
go followLogins(aside, queue, mine.Node, func(nl delivery.NodeLogin) {
if line := membership.Logins.Heard(nl); line != "" {
say(line)
}
rotator.Heard(aside, nl)
}, say)
go nodeAgent(aside, opts, mine.Membership.Signer, trust, queue, sched, say, afterApply, current, factsOfNow)
go busyPasses(aside, func() {