Skip to content

handshake: trust-transition preconditions, replay accounting, key-bound allowlist gate - #33

Merged
TeoSlayer merged 1 commit into
mainfrom
sec/findings-handshake
Jul 26, 2026
Merged

handshake: trust-transition preconditions, replay accounting, key-bound allowlist gate#33
TeoSlayer merged 1 commit into
mainfrom
sec/findings-handshake

Conversation

@TeoSlayer

Copy link
Copy Markdown
Contributor

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 — handleAccept grants trust on an unsolicited accept. handleAccept now requires and consumes a matching hm.outgoing entry before granting trust, mirroring the precondition processRelayedApproval already enforces on the relay path. Accepts for a handshake this node never started are dropped.

M6 — unauthenticated handshake_reject cancels a pending outgoing handshake. 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. handshake_request creates 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 KeyedTrustChecker interface; the trusted-agents auto-accept gate routes through it when the Runtime provides it.

  • Direct path: passes msg.PublicKey — the key the peer authenticated with and that the registry lookup bound to the node ID.
  • Relay path: passes "", since the relay carries no peer key. Under trustedagents.IsTrustedWithKey semantics 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 IsTrustedWithKey to the Runtime interface directly. That would break every existing Runtime implementation at compile time (notably runtime.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.go edit reaches for a method that does not exist. The uncommitted edit in the runtime repo calls a.m.IsTrustedWithKey(nodeID, pubKeyB64) bool on *handshake.Manager, which had no such method — that is what breaks the build. This PR adds it with exactly that signature (also matching the interface web4's pkg/daemon/daemon.go:2999 type-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 runtime repo can bump and commit its edit. Nothing in the runtime repo is touched here.

Deferred

id why
M1 — no direction binding in the AEAD record layer Not in this repo — there is no AEAD/record-layer code in 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.
M2 — no receive-side nonce ordering/dedup Same: not in this repo, and binding a monotonic receive counter into the nonce/AAD is a wire change with the same interop break.

Tests

New zz_findings_hardening_test.go covers each change. Six behavioral cases were confirmed to fail against the previous logic and pass after:

  • TestHandleAccept_WithoutOutgoingRequestDropped
  • TestProcessMessage_UnsolicitedAcceptOverAuthenticatedStreamDropped
  • TestHandleAccept_ConsumesOutgoingEntryOnce
  • TestProcessMessage_RejectFromMismatchedStreamIgnored
  • TestProcessMessage_RejectWithNoTransportIgnored
  • TestProcessMessage_UnverifiedMessageDoesNotConsumeReplayCapacity

Plus per-peer replay eviction, the keyed-gate match/mismatch/relay cases, and Manager.IsTrustedWithKey semantics.

Existing tests were updated where the new preconditions require an authenticated transport or an in-flight outgoing request. TestProcessMessage_ReplaySetFullDropsNewMessages became TestProcessMessage_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

…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
TeoSlayer force-pushed the sec/findings-handshake branch from 86157c1 to e5f848d Compare July 26, 2026 14:16
@TeoSlayer
TeoSlayer merged commit af2261b into main Jul 26, 2026
3 checks passed
@TeoSlayer
TeoSlayer deleted the sec/findings-handshake branch July 26, 2026 14:17
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants