handshake: trust-transition preconditions, replay accounting, key-bound allowlist gate - #33
Merged
Merged
Conversation
…nd allowlist gate Five hardening changes to the port-444 handshake manager. No wire-format change: HandshakeMsg, its fields, and the signed challenge strings are untouched, so deployed daemons stay interoperable. handleAccept now requires and consumes a matching hm.outgoing entry before granting trust, mirroring the precondition processRelayedApproval already enforces on the relay path. handshake_reject joins handshake_accept / handshake_revoke in the transport-binding check: all three act on existing local state, so the authenticated sender node ID must match the claimed one. Only handshake_request, which creates fresh state, stays exempt. The replay set is now written after a message clears authentication rather than before, and capacity is enforced by eviction instead of refusal. Entries are attributed to a peer, a peer over maxReplayPerPeer displaces its own oldest entry, and a full set drops expired entries first and the globally oldest one after that. The trusted-agents auto-accept gate routes through the new optional KeyedTrustChecker interface when the Runtime provides it, passing the key the peer authenticated with (direct path) or the empty key (relay path, which carries none). Runtimes that only implement IsTrusted keep the node-ID-only answer, so existing adapters compile unchanged. Manager.IsTrustedWithKey is added: approved peers whose trust record carries a public key must present a constant-time match; records with no key recorded (relay-established, not yet backfilled) answer on node ID alone. Tests: new regression coverage per change in zz_findings_hardening_test.go (all six behavioral cases fail against the previous logic), plus existing tests updated to supply an authenticated transport and an in-flight outgoing request where the new preconditions require them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TeoSlayer
force-pushed
the
sec/findings-handshake
branch
from
July 26, 2026 14:16
86157c1 to
e5f848d
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the open handshake-repo rows from the findings register: H1, M3, M4, M5, M6, M21. M1 and M2 are deferred — see below.
No wire-format change.
HandshakeMsg, its JSON fields, and the signed challenge strings are untouched, so daemons on v1.13.5 stay interoperable.What changed
H1 —
handleAcceptgrants trust on an unsolicited accept.handleAcceptnow requires and consumes a matchinghm.outgoingentry before granting trust, mirroring the preconditionprocessRelayedApprovalalready enforces on the relay path. Accepts for a handshake this node never started are dropped.M6 — unauthenticated
handshake_rejectcancels a pending outgoing handshake.handshake_rejectjoinshandshake_accept/handshake_revokein the transport-binding check. All three act on existing local state, so the authenticated sender node ID must match the claimed one.handshake_requestcreates fresh state and stays exempt.M3 + M5 — replay-nonce recorded before signature verification / replay-set poisoning. The replay set is consulted before verification (cheap duplicate rejection) but only written after a message clears authentication and transport binding. Capacity is now enforced by eviction instead of refusal: entries are attributed to a peer, a peer over
maxReplayPerPeer(256) displaces its own oldest entry, and a full set drops expired entries first and the globally oldest one after that. The set no longer fails global-closed.M4 — pubkey pin built but not wired into auto-accept. New optional
KeyedTrustCheckerinterface; the trusted-agents auto-accept gate routes through it when theRuntimeprovides it.msg.PublicKey— the key the peer authenticated with and that the registry lookup bound to the node ID."", since the relay carries no peer key. Undertrustedagents.IsTrustedWithKeysemantics an unpinned entry (every shipped entry today) still matches, so behavior is unchanged; a pinned entry will not auto-accept over the relay. That is deliberate fail-closed, and worth a look before pins are added to the shipped list.Deviation from the proposed fix: the register suggests adding
IsTrustedWithKeyto theRuntimeinterface directly. That would break every existingRuntimeimplementation at compile time (notablyruntime.HandshakeRuntime) and force a lockstep landing. The optional-interface + type-assert form is strictly additive: adapters that implement it get the pin, adapters that do not keep the node-ID-only answer.M21 — uncommitted
runtime/handshake.goedit reaches for a method that does not exist. The uncommitted edit in theruntimerepo callsa.m.IsTrustedWithKey(nodeID, pubKeyB64) boolon*handshake.Manager, which had no such method — that is what breaks the build. This PR adds it with exactly that signature (also matching the interfaceweb4'spkg/daemon/daemon.go:2999type-asserts for). Semantics: approved peers whose trust record carries a public key must present a constant-time match; records with no key recorded (relay-established, key not yet backfilled) answer on node ID alone. A compile-time guard in the tests pins the signature.Once this lands and is tagged, the
runtimerepo can bump and commit its edit. Nothing in theruntimerepo is touched here.Deferred
handshake(the port-444 protocol is JSON over an already-established stream). The change also alters nonce construction on the wire, so a patched node and a v1.13.5 node could not decrypt each other. Needs a default-off negotiated flag in the repo that owns the record layer.Tests
New
zz_findings_hardening_test.gocovers each change. Six behavioral cases were confirmed to fail against the previous logic and pass after:TestHandleAccept_WithoutOutgoingRequestDroppedTestProcessMessage_UnsolicitedAcceptOverAuthenticatedStreamDroppedTestHandleAccept_ConsumesOutgoingEntryOnceTestProcessMessage_RejectFromMismatchedStreamIgnoredTestProcessMessage_RejectWithNoTransportIgnoredTestProcessMessage_UnverifiedMessageDoesNotConsumeReplayCapacityPlus per-peer replay eviction, the keyed-gate match/mismatch/relay cases, and
Manager.IsTrustedWithKeysemantics.Existing tests were updated where the new preconditions require an authenticated transport or an in-flight outgoing request.
TestProcessMessage_ReplaySetFullDropsNewMessagesbecameTestProcessMessage_ReplaySetFullStillDispatches, asserting the new eviction behavior and that the set stays at its cap.go build ./... && go vet ./... && go test -race -count=1 ./...green.🤖 Generated with Claude Code