fix(ofp): accept empty-recipient HMAC so bootstrap_peers can connect#6330
Merged
Conversation
#3920 bound recipient_node_id into the handshake HMAC so a captured handshake cannot be replayed against a different federation node that shares the same shared_secret. But the bootstrap dialer (`connect_to_peer`) cannot know the remote node ID in advance — `bootstrap_peers` lists addresses, not identities — so it signs an empty recipient, while the receiver verified the HMAC against its own real node ID. Every bootstrap connection therefore failed with `403 HMAC authentication failed` in both directions. No existing test caught it because all of them used `connect_to_peer_with_id` with a known recipient; nothing exercised the real bootstrap path. The inbound handshake now accepts either the recipient-bound form (the strong anti-replay path, used by `send_to_peer` once the peer's node ID is known) or the empty bootstrap form, and threads whichever matched into the Ed25519 identity verification. The other authentication layers still cover the bootstrap path — nonce dedup (#3880), Ed25519 TOFU identity pinning (#3873), and the per-handshake ECDH session key (#4269) — so accepting an unbound recipient does not let an attacker who lacks the X25519 ephemeral private key actually communicate. Adds `issue_3920_bootstrap_connect_without_recipient_id_succeeds`, which dials through the real `connect_to_peer` entry point and asserts the handshake completes in both directions, and `issue_3920_wrong_nonempty_recipient_still_rejected`, which confirms a wrong non-empty recipient is still refused so the #3875 cross-node replay protection is preserved.
Review of #6330 found the HMAC-only bootstrap regression test used identity-less nodes (`PeerNode::start` passes no keypair), so it never exercised the line that threads the matched `auth_data` into Ed25519 identity verification — the production default, where every node carries a `load_or_generate` keypair. Adds `issue_3920_identity_bootstrap_connect_succeeds_and_pins`: two identity-bearing nodes complete a bootstrap (empty-recipient) handshake and pin each other's pubkey. A mutation that feeds the bound form into identity verification makes this test fail with "Ed25519 identity signature invalid" while the HMAC-only test still passes, confirming the new test is non-vacuous. Records the fix under CHANGELOG `[Unreleased]`.
houko
enabled auto-merge (squash)
June 26, 2026 06:09
This was referenced Jun 26, 2026
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.
Problem
Two LibreFang nodes on the same network, each with a matching
[network] shared_secretand the other's address inbootstrap_peers, can never connect. Both sides log, symmetrically:No configuration change fixes it — not ports, mDNS, shared_secret, or a WireGuard/Netbird overlay. The handshake is reached; HMAC verification is what fails.
Root cause (regression from #3920)
#3920 bound the recipient node ID into the handshake HMAC so a captured handshake cannot be replayed against a different federation node sharing the same
shared_secret:The bootstrap dialer cannot satisfy that binding.
bootstrap_peersis a list of addresses, and each node'snode_idis derived from its persisted Ed25519 keypair (key_mgr.load_or_generate()inbackground_lifecycle.rs) — unique per machine, not user-settable. So the kernel dials each bootstrap entry viaPeerNode::connect_to_peer, which hardcodes the recipient to the empty string:The receiver, however, verified the HMAC against its own real node ID with no fallback for the empty form:
HMAC(nonce|sender|"")never equalsHMAC(nonce|sender|<receiver id>), so every bootstrap handshake 403s, in both directions. Commit9ccf2a42(#3920) introduced both the empty-recipient bootstrap call and the strict receiver check together, and every test usedconnect_to_peer_with_idwith a known recipient, so the real bootstrap path was never exercised.Fix
The inbound handshake accepts either binding:
nonce|sender|<our node id>) — the strong anti-replay path, used bysend_to_peeronce the peer's node ID is known; ornonce|sender|) — for an initial bootstrap dial that cannot know our node ID.Whichever form authenticated is threaded into the subsequent Ed25519 identity verification (the client signed that same
auth_data). The remaining authentication layers still fully cover the bootstrap path:NonceTrackerdoes O(n)retain()on every handshake — DoS amplifier under flood; also runs before HMAC verify #3880),shared_secret+ self-assertednode_id— no per-peer identity, no PKI #3873),A wrong, non-empty recipient is still rejected, so the #3875 cross-node replay protection is preserved for the established-link path.
Changes
crates/librefang-wire/src/peer.rs: inbound handshake HMAC verification accepts the empty-recipient (bootstrap) binding as a fallback to the recipient-bound form; the matchedauth_datais reused for Ed25519 identity verification. Doc comment onconnect_to_peerupdated to describe the accepted bootstrap binding.Verification
cargo test -p librefang-wire --lib— 69 passed, 0 failed, including:issue_3920_bootstrap_connect_without_recipient_id_succeeds(new) — dials through the realconnect_to_peerentry point and asserts the handshake completes in both directions.issue_3920_wrong_nonempty_recipient_still_rejected(new) — a wrong non-empty recipient is still refused.test_handshake_hmac_includes_recipient,issue_3873_*,issue_4269_*— existing replay / identity / KEX coverage unchanged.cargo clippy -p librefang-wire --all-targets -- -D warnings— clean.rustfmt --check— clean.(Built and tested in the sanctioned Linux dev container; the change is platform-independent.)
Review follow-up (commit 2)
issue_3920_identity_bootstrap_connect_succeeds_and_pins: the HMAC-only test above uses identity-less nodes (PeerNode::startcarries no keypair), so it never exercised the line that threads the matchedauth_datainto Ed25519 identity verification — the production default, where every node loads aload_or_generatekeypair. The new test dials two identity-bearing nodes through the empty-recipientconnect_to_peerand asserts both sides pin each other's pubkey. Verified non-vacuous by mutation: feeding the bound form into identity verification makes this test fail withEd25519 identity signature invalidwhile the HMAC-only test still passes.CHANGELOG.md [Unreleased].librefang-wirelib suite: 70 passed, 0 failed;cargo clippy -p librefang-wire --all-targets -- -D warningsclean;rustfmt --checkclean.Out of scope (separate observation, not fixed here)
[network] mdns_enabledis a dead config knob: it is referenced only in config struct / default / API display / validation, with no mDNS discovery implementation anywhere incrates/(nomdns-sd,ServiceDaemon, or multicast dial). Toggling it has no effect on peering — the only discovery/dial path isbootstrap_peers. Implementing mDNS auto-discovery is feature work in the kernel mesh subsystem and belongs in its own issue/PR.