From 95dd64c7745c9b63fc88c25b70e6b399d85b505b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 10:25:10 +0100 Subject: [PATCH] =?UTF-8?q?Every=20signed=20ask=20is=20verified=20with=20t?= =?UTF-8?q?he=20controller's=20key=20the=20engine=20trusts=20at=20that=20a?= =?UTF-8?q?sk,=20so=20after=20the=20lease=20write=20none=20signed=20by=20i?= =?UTF-8?q?t=20is=20taken=20(issue=20774,=20ADR=200563=20=C2=A77,=20ADR=20?= =?UTF-8?q?0550=20=C2=A72;=20rev-l3=20round=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mesh-host/main.go | 2 ++ internal/link/handover.go | 20 ++++++++++++----- internal/link/handover_test.go | 33 ++++++++++++++++++++++++++-- internal/link/hearing_nats.go | 39 +++++++++++++++++++++++++--------- internal/link/run.go | 6 ++++++ internal/link/session.go | 4 ++++ 6 files changed, 87 insertions(+), 17 deletions(-) diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 9a39745..c7efd1e 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1112,6 +1112,8 @@ func runLink(ctx context.Context, opts options) error { Membership: membership, // And the public halves and certificate its record says (novox/hq ADR 0555 §3), read at each facts report. Keys: recordKeys(opts.state, trust.Trusts), + // And the controller's key, read per signed ask: none once its lease is handed away (novox/hq ADR 0563 §7). + ControllerKey: trust.Controller, // And the first have this build writes is what proves it to the launcher (ADR 0539 §9, ADR 0227 §8). HaveWritten: proof.haveWritten, // And the operator's hand-over of a directory used as found, carried by the controller from the diff --git a/internal/link/handover.go b/internal/link/handover.go index 1c5188b..e6103f0 100644 --- a/internal/link/handover.go +++ b/internal/link/handover.go @@ -76,9 +76,13 @@ const MaxHandOverBy = 200 // HandOverCheck is what an ask is held to: the mesh's key, this node's name, the clock, and the nonces taken. type HandOverCheck struct { Signer ed25519.PublicKey - Node string - Now func() time.Time - Nonces *Nonces + // SignerNow, when set, is the controller's key as the engine trusts it at this ask, read per ask in place of Signer: + // nil once the controller's lease is handed away, which refuses every ask signed by its key (novox/hq ADR 0563 §7, + // ADR 0550 §2). + SignerNow func() ed25519.PublicKey + Node string + Now func() time.Time + Nonces *Nonces } // Nonces are the nonces of asks taken, each kept until its ask expires: an ask is taken once. Kept in memory — @@ -157,8 +161,14 @@ func believedBytesWithin(check HandOverCheck, context string, withPath bool, bod if err := json.Unmarshal(body, &signed); err != nil { return HandOverAsk{}, nil, "the ask is not a signed ask: " + err.Error() } - if len(check.Signer) != ed25519.PublicKeySize || - !ed25519.Verify(check.Signer, append([]byte(context), signed.Ask...), signed.Signature) { + signer := check.Signer + if check.SignerNow != nil { + if signer = check.SignerNow(); signer == nil { + return HandOverAsk{}, nil, "the controller's lease is handed away, and no ask signed by its key is taken" + } + } + if len(signer) != ed25519.PublicKeySize || + !ed25519.Verify(signer, append([]byte(context), signed.Ask...), signed.Signature) { return HandOverAsk{}, nil, "the ask is not signed by the mesh" } var ask HandOverAsk diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index 10920db..a19d009 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -148,6 +148,21 @@ func TestAHandOverAskIsBelievedOnlySignedForThisNodeUnexpiredAndOnce(t *testing. if strings.Join(given, "; ") != "/srv/notes by jo through mesh-cli on anchor; /refused by jo through mesh-cli on anchor" { t.Fatalf("the handler was given %v", given) } + // ADR 0563 §7, ADR 0550 §2: after the lease write the engine answers no ask signed by the controller's key. The key + // is read per ask from what the engine trusts now; nil (the lease handed away) refuses, and nothing is handed over. + trusted := m.Signer + following := HandOverCheck{Signer: m.Signer, SignerNow: func() ed25519.PublicKey { return trusted }, Node: m.Node, + Now: func() time.Time { return now }, Nonces: &Nonces{}} + before := len(given) + trusted = nil + late := signedAsk(t, key, with(func(a *HandOverAsk) { a.Expires = now.Add(time.Minute); a.Nonce = strings.Repeat("cd", 16) })) + if a := AnswerHandOver(following, handle, late); !strings.Contains(a.Refused, "lease is handed away") || len(given) != before { + t.Fatalf("after the lease write an ask signed by the controller was answered %+v", a) + } + trusted = m.Signer + if a := AnswerHandOver(following, handle, late); a.Refused != "" || len(given) != before+1 { + t.Fatalf("while the controller holds the lease its ask was answered %+v", a) + } // A nonce is forgotten once its ask has expired, so the set does not grow for ever. now = now.Add(HandOverGood + time.Second) check.Nonces.take("ff", now.Add(time.Second), now) @@ -177,17 +192,23 @@ func TestAHandOverIsAnsweredOnlyWhenItCarriesAReply(t *testing.T) { // handingLink is a quiet link that hears hand-overs, keeping the handler it was given. type handingLink struct { *quietLink - given chan HandOverFunc + given chan HandOverFunc + signer chan func() ed25519.PublicKey } func (l handingLink) HearHandOvers(h HandOverFunc) { l.given <- h } +// FollowSigner keeps what the link is told to verify the controller's asks with. +func (l handingLink) FollowSigner(f func() ed25519.PublicKey) { l.signer <- f } + // **The link is given the queue's hand-over** when it is served: what the controller asks reaches the engine's // judgement, and nothing else. func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { m, _ := aMember(t) - l := handingLink{quietLink: newQuietLink(), given: make(chan HandOverFunc, 1)} + l := handingLink{quietLink: newQuietLink(), given: make(chan HandOverFunc, 1), signer: make(chan func() ed25519.PublicKey, 1)} q := &Session{Membership: m, Timeout: time.Second} + // The link verifies the controller's asks with the key the engine trusts now (ADR 0563 §7). + q.ControllerKey = func() ed25519.PublicKey { return nil } q.HandOver = func(path, by string) (string, error) { return "the queue's: " + path + " by " + by, nil } ctx, stop := context.WithCancel(context.Background()) served := make(chan error, 1) @@ -200,6 +221,14 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("the link was never given the queue's hand-over") } + select { + case f := <-l.signer: + if f() != nil { + t.Fatal("the link was given another key than the queue's") + } + case <-time.After(5 * time.Second): + t.Fatal("the link was never given the key the engine trusts now") + } stop() <-served // 774 (ADR 0563 §1): a call of the node seat reaches its verb with its caller as the subject proves it, unproven on diff --git a/internal/link/hearing_nats.go b/internal/link/hearing_nats.go index cad3373..9bd0cde 100644 --- a/internal/link/hearing_nats.go +++ b/internal/link/hearing_nats.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" "github.com/nats-io/nats.go" @@ -36,11 +37,14 @@ func natsURL(address string) string { // natsLink is this node's connection as a JetStream subscription. type natsLink struct { - conn *nats.Conn - js nats.JetStreamContext - asking *nats.Subscription - asked chan struct{} - handing *nats.Subscription + // mu guards signerNow (FollowSigner). + mu sync.Mutex + signerNow func() ed25519.PublicKey + conn *nats.Conn + js nats.JetStreamContext + asking *nats.Subscription + asked chan struct{} + handing *nats.Subscription // searching hears the controller's terminal asking for a fresh setuid search (novox/hq issue 361). searching *nats.Subscription // judging hears the controller asking for a read-only judgement of this node's directories (issue 358). @@ -145,6 +149,21 @@ func (l *natsLink) hearAsks() { } } +// FollowSigner has every signed ask heard on this link verified with the key the engine trusts at that ask, the +// controller's while it holds its lease and none from its hand-over (novox/hq ADR 0563 §7, ADR 0550 §2). +func (l *natsLink) FollowSigner(f func() ed25519.PublicKey) { + l.mu.Lock() + l.signerNow = f + l.mu.Unlock() +} + +// check is how a signed ask is believed on this link: the static membership signer until FollowSigner names the trust. +func (l *natsLink) check() HandOverCheck { + l.mu.Lock() + defer l.mu.Unlock() + return HandOverCheck{Signer: l.signer, SignerNow: l.signerNow, Node: l.node} +} + // HearHandOvers listens for the controller asking this node to hand a directory used as found to the mesh // (novox/hq issue 356): a request on this machine's own subject, answered once on the reply it carried. The // ask is verified with the mesh's key before anything is read out of it (AnswerHandOver), because a responder @@ -155,7 +174,7 @@ func (l *natsLink) HearHandOvers(handle HandOverFunc) { if handle == nil { return } - check := HandOverCheck{Signer: l.signer, Node: l.node} + check := l.check() handing, err := l.conn.Subscribe(AskHandOverSubject(l.node), func(msg *nats.Msg) { if body, answer := HandOverReply(check, handle, msg.Reply, msg.Data); answer { _ = msg.Respond(body) @@ -174,7 +193,7 @@ func (l *natsLink) HearSetuidSearches(handle SetuidSearchFunc) { if handle == nil { return } - check := HandOverCheck{Signer: l.signer, Node: l.node} + check := l.check() searching, err := l.conn.Subscribe(AskSetuidSearchSubject(l.node), func(msg *nats.Msg) { if body, answer := SetuidSearchReply(check, handle, msg.Reply, msg.Data); answer { _ = msg.Respond(body) @@ -193,7 +212,7 @@ func (l *natsLink) HearDirectoryJudgements(handle JudgeDirectoriesFunc) { if handle == nil { return } - check := HandOverCheck{Signer: l.signer, Node: l.node} + check := l.check() judging, err := l.conn.Subscribe(AskJudgeDirectoriesSubject(l.node), func(msg *nats.Msg) { if body, answer := JudgeDirectoriesReply(check, handle, msg.Reply, msg.Data); answer { _ = msg.Respond(body) @@ -211,7 +230,7 @@ func (l *natsLink) HearAccessActs(handle AccessActFunc) { if handle == nil { return } - check := HandOverCheck{Signer: l.signer, Node: l.node} + check := l.check() acting, err := l.conn.Subscribe(AskAccessActSubject(l.node), func(msg *nats.Msg) { if body, answer := AccessActReply(check, handle, msg.Reply, msg.Data); answer { _ = msg.Respond(body) @@ -229,7 +248,7 @@ func (l *natsLink) HearMoveSecrets(handle MoveSecretFunc) { if handle == nil { return } - check := HandOverCheck{Signer: l.signer, Node: l.node} + check := l.check() moving, err := l.conn.Subscribe(AskMoveSecretSubject(l.node), func(msg *nats.Msg) { if body, answer := MoveSecretReply(check, handle, msg.Reply, msg.Data); answer { _ = msg.Respond(body) diff --git a/internal/link/run.go b/internal/link/run.go index e66a2fa..eac39ef 100644 --- a/internal/link/run.go +++ b/internal/link/run.go @@ -187,6 +187,12 @@ func serve(ctx context.Context, link Link, m Membership, queue *Session, say Ann } // And the controller asking this node to hand a directory used as found to the mesh (novox/hq issue // 356), answered on the link the question came over. + // Every signed ask is verified with the key trusted at that ask, none after the lease write (ADR 0563 §7). + if f, ok := link.(interface { + FollowSigner(func() ed25519.PublicKey) + }); ok && queue.ControllerKey != nil { + f.FollowSigner(queue.ControllerKey) + } if h, ok := link.(HandOvers); ok { h.HearHandOvers(queue.HandOver) } diff --git a/internal/link/session.go b/internal/link/session.go index 2c42dc5..e33314d 100644 --- a/internal/link/session.go +++ b/internal/link/session.go @@ -2,6 +2,7 @@ package link import ( "context" + "crypto/ed25519" "encoding/json" "errors" "fmt" @@ -48,6 +49,9 @@ type Session struct { // MoveSecret moves one value sealed to this machine into the vault, asked by the controller during the move of // the secrets (design 57's V3, issue 676). Nil answers every such ask with a refusal. MoveSecret MoveSecretFunc + // ControllerKey is the controller's key as the engine trusts it now: nil once its lease is handed away (novox/hq + // ADR 0563 §7). Each link verifies every signed ask with it, read per ask; nil ControllerKey keeps the membership's. + ControllerKey func() ed25519.PublicKey // Acts is the node seat node-engine this engine holds (novox/hq ADR 0563): served on every link, and its asks to the // operator asked and heard over it. Nil serves no seat. Acts *HandActs