Skip to content

fix(ofp): nonce check after HMAC, 64KB message cap, recipient node_id in handshake HMAC#3920

Merged
houko merged 4 commits into
mainfrom
fix/ofp-3880-3876-3875
Apr 28, 2026
Merged

fix(ofp): nonce check after HMAC, 64KB message cap, recipient node_id in handshake HMAC#3920
houko merged 4 commits into
mainfrom
fix/ofp-3880-3876-3875

Conversation

@houko

@houko houko commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Test plan

  • Confirm peer with invalid HMAC cannot insert nonces into the dedup map
  • Confirm messages > 64KB are rejected before heap allocation
  • Confirm replayed auth_data from a different peer-pair is rejected

…cipient in handshake HMAC

#3880 — NonceTracker DoS: check_and_record was called before HMAC verification
in handle_inbound, allowing any unauthenticated TCP client to trigger the
DashMap retain() sweep and fill nonce capacity without knowing shared_secret.
Fix: verify HMAC first in handle_inbound; record nonce only on success.
Also applies the same order fix to connect_to_peer_with_id/send_to_peer ack
path. Additionally amortize the O(n) GC sweep: retain() now only runs when
the map reaches 80% of max_entries instead of on every insert.

#3876 — Budget drain via oversized messages: AgentMessage payloads from
federated peers had no size limit before reaching the kernel's LLM pipeline.
A peer sharing the same shared_secret could send 16 MB messages and drain
the receiver's LLM budget at no cost. Fix: add MAX_PEER_MESSAGE_BYTES = 65_536
constant and reject oversized payloads with code 413 in handle_request_in_loop
before calling handle_agent_message.

#3875 — HMAC doesn't bind recipient node_id: handshake auth_data was
nonce||sender_node_id, allowing a captured handshake to be replayed against
any other federation node sharing the same shared_secret. Fix: change
auth_data format to nonce|sender_node_id|recipient_node_id throughout
(outgoing in connect_to_peer_with_id and send_to_peer; verification in
handle_inbound which uses local node_id as the expected recipient). Add
connect_to_peer_with_id() as the binding-aware variant; connect_to_peer()
delegates to it with "" for bootstrap connections.

Closes #3880, #3876, #3875
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions github-actions Bot added area/security Security systems and auditing size/L 250-999 lines changed labels Apr 28, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The security fixes (HMAC-before-nonce, recipient binding, 64 KB cap) are correct and well-tested. One minor comment inaccuracy in test_nonce_tracker_gc_amortized — see inline note. The test itself is valid.


Generated by Claude Code

let result = tracker.check_and_record("n-0");
assert!(result.is_err());
assert!(result.unwrap_err().contains("replay"), "expected replay error");
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "Insert 4 nonces — below the 80% threshold (4 entries), no GC runs" is slightly inaccurate.

With max_entries = 5, the GC threshold is 5 * 4 / 5 = 4. During the insert loop (indices 0–3) the map has 0, 1, 2, 3 entries respectively before each insert, so the len >= threshold check is always false and GC indeed does not run during the loop — that part is correct.

However, when check_and_record("n-0") is called for the replay check, the map has 4 entries, so 4 >= 4 is true and the GC sweep does run. Because all entries were inserted moments ago none are expired, so the replay of "n-0" is still correctly detected and the test passes — but the comment says "no GC runs" without qualification.

Suggested fix:

// Insert 4 nonces. During these inserts the map has 0–3 entries, which
// is below the 80% GC threshold of 4 — so no GC sweep occurs.
for i in 0..4 {
    assert!(tracker.check_and_record(&format!("n-{i}")).is_ok());
}
// At the replay check the map has 4 entries (== threshold), so GC does
// run — but all entries are fresh, so nothing is removed and the replay
// is still detected.
let result = tracker.check_and_record("n-0");

Generated by Claude Code

@houko
houko merged commit 9ccf2a4 into main Apr 28, 2026
1 check passed
@houko
houko deleted the fix/ofp-3880-3876-3875 branch April 28, 2026 13:26
@houko houko mentioned this pull request May 1, 2026
houko added a commit that referenced this pull request Jun 26, 2026
…6330)

Bootstrap dials sign an empty recipient_node_id because `bootstrap_peers` lists addresses, not identities, but #3920 made the receiver verify the handshake HMAC against its own real node_id with no fallback — so every bootstrap connection 403'd in both directions.
The inbound handshake now accepts the empty-recipient binding alongside the recipient-bound form, and threads whichever matched into Ed25519 identity verification.
Nonce dedup (#3880), Ed25519 TOFU pinning (#3873), and the per-handshake ECDH session key (#4269) still authenticate the peer; a wrong non-empty recipient is still rejected, preserving the #3875 cross-node replay protection.

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