238 Commits
Author SHA1 Message Date
Johnathan Corgan 069d450983 Merge branch 'master' into next 2026-07-29 02:24:38 +00:00
Johnathan Corgan 22e71b55a3 Merge branch 'master' into next 2026-07-29 00:36:24 +00:00
Johnathan Corgan e8800c3a34 Merge branch 'master' 2026-07-28 17:40:34 +00:00
Johnathan Corgan 60fb3af359 Merge branch 'master'
Reconciles the tick profiler with this line's extra tick step. `next`
drives `resend_pending_fmp_rekey_msg3` from the tick arm and the master
line does not, so the step table gains a variant here and the arm gains
one more instrumented call.

The pinned row-count test is what caught it: it failed on the merge
rather than letting the emitted table and the call sites drift apart
silently, which is the failure mode it was written for.
2026-07-28 01:39:47 +00:00
Johnathan Corgan 746218b6bb Merge branch 'master' into next 2026-07-27 17:57:36 +00:00
Johnathan Corgan 540532de44 Make the test backdating seams fail loudly instead of moving time forward
Windows unit tests have been reding on next at a rekey-drain assertion that
reads like a defect in the drain logic. The drain logic is correct; the test
never reached the state it asserts on.

All three test-only backdating seams shared the fallback
checked_sub(age).unwrap_or_else(Instant::now). When the requested backdate
falls outside the monotonic clock's representable range, that assigns the
present instant, moving the timestamp forward rather than backward. A fresh
Windows CI runner has been up for less than the 600 seconds the drain test
asked for, so the window looked brand new, check_rekey correctly declined to
release the demoted session, and the assertion fired against innocent code.

The seams now route through a shared helper that panics naming the field, the
requested age and the real cause. The drain test asks for 30 seconds, three
times the ten-second window it needs to clear, rather than sixty times it.

Both properties were established by breaking them. Backdating by five seconds,
inside the window, reds the test, so it still depends on expiry rather than
passing vacuously at the smaller margin. Forcing the subtraction to fail
produces the new panic and its explanation rather than a silent reset. Full
cargo test --lib green at 1742, fmt and clippy clean.

No step ran on Windows, so the platform mechanism is inferred from the split
between runners and confirmed only when that job goes green on this tip.
2026-07-26 18:50:53 +00:00
Johnathan Corgan b6c1977e16 Merge branch 'master' into next
Carries today's maint batch up: the local CI image-scoping work, which
gives each run its own build context and test image tag instead of
writing the shared mutable one, and the retirement of the bloom-storm
chaos scenario.

Resolved as the earlier per-commit merges did, in the two files where
this line has diverged — the node test module list and the static suite
compose file. Parity holds at 21 legs a side, matching master.
2026-07-26 17:17:32 +00:00
Johnathan Corgan 0f3f716142 Merge branch 'master' into next 2026-07-25 19:16:01 +00:00
Johnathan Corgan 679c9aaeb2 Retire the Docker mixed-profile suite in favour of the in-process test
Everything this suite asserted is now covered in-process over loopback by
mixed_profile_nodes_converge_and_forward: mixed Full, NonRouting and Leaf
convergence, the peer degrees, every direct reachability pair, and the
Leaf-to-Full pair that routes through the Full node between them. The
in-process test covers a superset, asserting that multi-hop path in both
directions where the Docker suite covered one.

Retired on evidence that the multi-hop assertion discriminates rather than on a
green run: reverting the leaf root-election gate reds it in roughly a third of
runs, with the leaf reporting itself as tree root. That was the property the
suite was being kept for, and it is the property now demonstrated elsewhere.

Removed from the local runner's suite list, its runner function, the default
flow and the single-suite dispatch, and from the workflow matrix and its five
steps. Parity reports 22 legs a side, one fewer than before and equal across
the two runners. The suite's script, topology and compose profile stay on disk
and it remains runnable by hand; the retirement is recorded with the others in
the runner's deliberately-not-run block.
2026-07-25 17:36:47 +00:00
Johnathan Corgan da57a57150 Raise the rekey interval in the handshake tests above the validation floor
The rekey tests configured a one-second interval to put the timer arm within
reach, then backdated the session two minutes so the jitter draw could not
decide the outcome. That worked, but it asks for a configuration the daemon now
refuses to build: an interval at or below the jitter bound saturates to zero on
a negative draw and rekeys on sight for roughly half of sessions, which
validation rejects.

Thirty seconds clears the bound and leaves the existing backdate a margin of
seventy-five seconds against the worst draw, so the tests keep the property
they were relying on. The helper that drives these handshakes now records why
the value cannot simply be made smaller again.

Surfaced by merging the deployed line up, which is the first point at which
these tests met the new validation.
2026-07-25 17:24:07 +00:00
Johnathan Corgan e30f3b7ddb Merge branch 'master' into next
Clean merge, no conflicts. The hop-limit helper corrections and the rekey
config validation arrive from the deployed line and apply here unchanged.

Checked rather than assumed, since this branch changed the same subsystem in
the same session: next keeps its own split of the rekey cadence, where the
config flag rides into the core as RekeyCfg::initiate and check_rekey no longer
returns early, and the incoming validation of the same config block sits
alongside it without overlap.
2026-07-25 17:17:23 +00:00
Johnathan Corgan 026a9c6abf Cover the mixed-profile multi-hop path in the in-process mesh test
The in-process mixed-profile test asserted convergence, peer degrees and every
direct pair, but deliberately left out the Leaf-to-Full pair that routes
through the Full node between them: a leaf holding a small enough address used
to self-elect as tree root and partition the mesh, so the assertion would have
been flaky. That defect is fixed, so assert the pair here, in both directions.
The leaf's peer degree is already pinned at one in the same test, so neither
direction can be satisfied by a direct link.

Failures in this test are root-election partitions keyed on the random
per-node address ordering, which are undiagnosable from a bare assertion
message. Each of the three failure paths now reports every node's address,
elected root and self-rooted flag.

Reverting the self-election gate reds the new pair in roughly a third of runs
with the leaf reporting itself as root, and the assertion is clean over
repeated runs with the gate in place.
2026-07-25 17:10:44 +00:00
Johnathan Corgan 3d390fbafd Make rekey.enabled mean "initiate rekeys" and nothing else
The responder half of the establish decision was gated on this node's own
node.rekey.enabled. With the rekey now declared in the msg3 negotiation
payload, that flag was the only thing that could divert a msg3 whose marker
matched the session we hold, and it diverted it to a msg2 resend while the
initiator had already installed its pending session and would cut over on its
own timer regardless. A pair configured with the flag true on one end and
false on the other therefore parted company at the initiator's cutover and
carried no traffic in either direction until the link-dead timer.

Drop the flag from that gate. The arm now turns on the sender's declaration
matching the session we hold, which is the only signal that was ever
authoritative for the decision, and the snapshot field it read is gone since
every initiator-side use reads the config directly.

Removing the gate exposed a second reading of the same flag. check_rekey
returned early when rekey was disabled, and the drain-expiry arm of the rekey
poll is the only thing that releases a demoted session and its index. A node
that does not initiate had never accepted a rekey before, so it never
accumulated a drain; now that it does, the early return would pin the previous
session and its allocator index forever. The flag rides into the core as
RekeyCfg::initiate instead: the polled cutover and the trigger are gated on it,
the drain is not, and the shell runs the cadence unconditionally.

The cutover arm has to stay gated. The peer snapshot carries no rekey role, so
a pending session stored as responder satisfies that arm exactly as one we
initiated does; ungating it would move a non-initiating node's cutover off the
peer's k-bit flip and onto the poll.

Covered by a two-ended test asserting the pair converges on the same session
indices under an asymmetric setting, a node-level test that a non-initiating
responder actually releases the demoted session and index once the drain window
expires, and a core test pinning which arms a non-initiating snapshot yields.
The existing msg2-resend test reached its arm only through the removed gate and
now reaches it through the health conjunct instead.
2026-07-25 17:05:00 +00:00
Johnathan Corgan 30e1dcb989 Name this branch's third reader of the address-to-link map
The doc comment merged up from the maintenance line lists the map's live
readers, and on this branch there is one more: the anonymous-beacon dial
dedup in poll_transport_discovery, which has no counterpart there.

It reads the map with an address supplied by transport discovery, and the
shared-media dial paths resolve before registering, so it compares a key
written in the same form it reads and is not exposed to the mismatch the
comment warns about.
2026-07-25 01:56:10 +00:00
Johnathan Corgan b920200569 Merge branch 'master' into next 2026-07-25 01:53:15 +00:00
Johnathan Corgan 4a448cbe21 Merge branch 'master'
Carries the rekey counter-arm boundary test and the removal of the
closure-based trigger test. Applied cleanly with no adaptation: the
trigger predicate is byte-identical on both lines, and the claim the new
test rests on was re-measured here rather than assumed to carry — widening
the bound to `p.counter >= 1` reds exactly one test on this line too.
2026-07-25 00:40:14 +00:00
Johnathan Corgan 537f0b32c7 Merge branch 'master'
Carries the in-process rekey continuity test and the retirement of the
three rekey Docker suites onto the XX line.

The continuity test was written against master's IK handshake, where the
responder decided rekey-versus-fresh-dial from session age and the test
had to backdate both sessions past a 30s acceptance gate. Here the
responder classifies from the rekey marker the initiator declares in its
msg3 negotiation TLV and reads no clock at all, so the backdating is
dropped: it would age the session past nothing, and leaving it in would
tell a reader a gate exists that does not.

Checked that the test still discriminates rather than merely observing a
session index change. Suppressing the declaration so the responder falls
back to the cross-connection path reds the post-cutover assertion in 8 of
16 runs, which is the address-ordering coin flip the original data-plane
drop had; the healthy tree is 0 of 16 red. The 30s backdate makes no
difference to either rate, which is what confirms it was vestigial here.

The suite wiring keeps mixed-profile, which master does not have, and
drops the three rekey suites from the local suite list, the runner
functions, the default flow, the --only dispatch and the GitHub matrix
together, so check-ci-parity.sh stays green at 23 legs a side. The
retirement note is retargeted at this branch's coverage: the establish
decision tests live in src/proto/fmp/tests/core.rs and src/peer/machine.rs
here, not in the establish_chartests.rs file master keeps.
2026-07-24 23:58:42 +00:00
Johnathan Corgan aa204cf729 Swap on a dial that crosses a rekey, and cover the marker's two ends
The rekey marker's cross-connection arm declined to swap while a rekey of
ours was in flight, reasoning that the swap rewrites the session and both
indices while touching no rekey state, so it would orphan that rekey. The
reasoning was one-sided and the arm was wrong: this is half of a two-sided
tie-break whose other half reads only whether a peer exists and the address
ordering, and cannot see our rekey. Declining here while the peer's outbound
half swaps anyway leaves the two ends on four distinct indices with neither
holding a pending session, dead both directions until the link-dead timer.
Measured against the pre-marker tree, which converges: it holds the crossing
dial as pending and adopts it at cutover.

The orphaned-rekey hazard is real but belongs to the executor. The swap now
abandons the rekey it displaces and frees that index first, exactly as the
rekey-responder path already does when it loses the dual-rekey tie-break, so
the classifier can take the arm unconditionally.

Two further failure paths of the marker are tightened. A malformed marker
tore the handshake down without freeing the index msg1 allocated, and that
arm sits ahead of the ACL gate, so any peer able to complete a msg3 could
grow the allocator set one entry at a time. A rekey with no session index to
declare silently omitted the marker, which puts a real rekey back on the
cross-connection path; it is unreachable today and is now asserted rather
than skipped.

On the test side, the marker's wire contract gains coverage: the accessor
round-trips, an unknown TLV field leaves it alone in both directions, and a
well-formed marker of the wrong length is an error rather than an absence,
which is the case the container codec cannot see and the whole reason the
accessor is fallible.

Two node-level tests now assert both ends rather than one. A rekey fired by
message count on a zero-age session is the mechanism behind the datagram
drops, and nothing exercised it: it fails on the pre-marker tree and passes
here. The mutual-dial test gains an index-pair assertion, since every
per-end predicate it checked holds when the ends have diverged.

One existing test had stopped reaching its own arm. Driving a bare second
handshake into a rekey-disabled peer now lands on the cross-connection arm,
which that flag does not gate, and every assertion held there too; it is
retargeted onto a declared rekey, which is the real route to the duplicate
arm, and asserts the session is left alone so the two arms are told apart.

Documentation that described the removed session-age floor is rewritten
around the declaration.
2026-07-24 21:48:49 +00:00
Johnathan Corgan 911a000d85 Declare a rekey explicitly in msg3 instead of inferring it from session age
A rekey and a fresh dial both arrive as a new msg1 on a new link, so the
responder has to tell them apart at msg3. It cannot: the distinguishing
fact is internal to the initiator and nothing on the wire carries it.
Session age stood in for it and could not do the job - a
message-count-triggered rekey fires on a young session, so a real rekey
landed below the floor, was resolved as a cross-connection, and left the
two ends of the link on different session indices.

Carry the answer instead. The initiator adds a negotiation TLV to its
rekey msg3 naming the session it replaces, using the index the receiver
allocated so the receiver can match it against its own. The shell
resolves that into a three-valued claim - none, matching, mismatched -
and the classifier branches on it.

A mismatched marker resends msg2 rather than rejecting: the initiator has
already installed its pending session and will cut over on its own timer,
and the reject path sends nothing back, so tearing down would strand the
link in the way this defect already does. A fresh dial arriving while our
own rekey is in flight also resends, because the cross-connection swap
rewrites both indices and touches nothing else, which would leave that
rekey pointing at a session the counterpart no longer holds. The age
floor was excluding that case incidentally.

The floor and the session-age snapshot field are gone with it, so the
establish snapshot no longer reads the clock at all.

Both node-level rekey tests now drive a real rekey through the
initiator's own trigger. They previously reached the rekey arms by
backdating a session and re-handshaking, which tested the age proxy this
replaces; one of them was reaching its assertions through the duplicate
arm rather than the arm it names. Age them well past the trigger, since
the jitter is +/-15s and a smaller margin makes firing depend on the draw.

Incomplete: no TLV round-trip test, no two-ended convergence test, no
rekey-crossed-with-fresh-dial case, and no integration run yet.
2026-07-24 20:27:55 +00:00
Johnathan Corgan 2223763dab Let the host shell override RUST_LOG for the static test harness
The static suites hardcoded RUST_LOG=info, so raising the log level for a
node under test meant editing the compose file. Take it from the
environment with info as the default, matching the passthrough the same
block already uses for the worker-pool overrides.

Rendering is unchanged when RUST_LOG is unset, which is how CI invokes it.
2026-07-24 19:11:27 +00:00
Johnathan Corgan 694d03375b Stop a leaf node from self-electing as tree root
A leaf-profile node that held the smallest NodeAddr would self-elect as
tree root, but its peers refuse a non-full node as a parent, so it formed
an isolated second root and partitioned the mesh: a multi-hop session from
the leaf to a non-adjacent full node then failed because the far node could
not route a handshake reply back into the leaf's separate coordinate tree.

Gate tree-state self-election on a new self_is_leaf flag, set from the node
profile at construction. A leaf now attaches under its full upstream and
holds that subtree's coordinate for its own routing. That coordinate's
self < root would be rejected by the root-min wire check, but a leaf never
announces it; it reaches peers only via the coordinates carried on the
leaf's session frames, and the upstream already advertises the leaf in its
bloom filter, so the far node routes back through the upstream.

Also fix Node::with_identity to derive the node profile, leaf-only flag,
and bloom state from the config, matching Node::new; it previously
hardcoded the full profile and silently dropped a configured leaf or
non-routing profile.

Covered by sans-IO decision tests (leaf does not self-elect; leaf keeps its
coordinate under a larger-rooted parent) and an end-to-end multi-hop
regression, each confirmed to fail if the gate is reverted.
2026-07-24 14:42:08 +00:00
Johnathan Corgan bcfe9270bb Add an in-process mixed-profile lifecycle test over loopback
Brings up a Full/Full/NonRouting/Leaf mesh matching the mixed-profile
Docker topology, asserts each node peers to the degree its role and
topology dictate (A=3, B=2, C=2, D=1), and verifies end-to-end data
between every directly reachable pair (Full-Full, Full-NonRouting,
Full-Leaf). Adds run_tree_test_with_profiles, a per-node-profile sibling
of run_tree_test.

The Leaf-to-Full multi-hop the Docker suite also checks is deliberately
left out for now: a Leaf node's multi-hop session initiation on the XX
line is not yet reliable, so the Docker mixed-profile suite is retained
for that path rather than replaced with a flaky assertion.
2026-07-24 05:02:05 +00:00
Johnathan Corgan e1543d201a Merge branch 'master' into next
# Conflicts:
#	.github/workflows/ci.yml
#	testing/ci-local.sh
2026-07-24 02:45:37 +00:00
Johnathan Corgan 28421c4b0e Merge master: retire cost/congestion chaos scenarios
Bring the six retired cost-based parent-selection chaos scenarios up to
next, resolving the ci-local.sh suite-list conflict (next carries the extra
mixed-profile and admission-cap suites). next already has the equivalent
sans-IO cost and transport-drop coverage, so src is unchanged; this takes
only the scenario removals and the CI-list, README and comment updates.
2026-07-23 23:46:26 +00:00
Johnathan Corgan 45406292aa Merge branch 'master' into next 2026-07-23 19:52:08 +00:00
Johnathan Corgan e6d99d62c4 Merge branch 'master' into next
# Conflicts:
#	testing/ci-local.sh
2026-07-23 14:13:24 +00:00
Johnathan Corgan 53ec14c2d5 Merge branch 'master' 2026-07-23 05:29:07 +00:00
Johnathan Corgan c805a4492e Merge branch 'master' 2026-07-23 04:16:51 +00:00
Johnathan Corgan 719b587920 Merge branch 'master'
# Conflicts:
#	CHANGELOG.md
2026-07-23 03:24:23 +00:00
Johnathan Corgan 32b40221b0 Merge branch 'master' 2026-07-23 03:03:05 +00:00
Johnathan Corgan 308e2ce3b3 Merge branch 'master' into next 2026-07-23 02:42:58 +00:00
Johnathan Corgan 64f68fbc77 Suffix the mixed-profile test's container names where it builds them
Giving the mixed-profile services a run-scoped container_name broke this
script's two ping sites, which assemble the name as "fips-$from" rather than
writing it out. A grep for the literal name finds neither, which is the same
trap the earlier naming sweep hit on the other static scripts.

The failure was loud — link convergence passed 3/2/2/1 and then every ping
failed, because docker exec could not find the container — but it is worth
noting that these names were unsuffixed until now, so this suite was never
safe to run twice on one host.
2026-07-23 02:20:00 +00:00
Johnathan Corgan e9cb3f20a5 Merge branch 'master' into next 2026-07-23 02:09:27 +00:00
Johnathan Corgan 630a04c432 Merge branch 'maint'
The static compose's mixed-profile services are next-only, so the floating
subnet arrived without them: four services kept ipv4_address pins on a
network that no longer declares a subnet, which docker rejects outright at
`up`. They now float with the rest, take the per-run config directory, and
carry the run name suffix their container_name was missing.

Phase 3 of the admission-cap test conflicted for a real reason rather than a
textual one. This line asserts size-agnostically — sustained inbound from
each denied peer plus "was never promoted" — because the XX handshake
message sizes differ from IK and may change again. That assertion is kept;
what comes across from maint is the address handling underneath it, which
matters here for the same reason: with the subnet floating, a restarted
container can change address, and matching on a single one silently
under-counts the very evidence the assertion rests on.
2026-07-23 02:08:52 +00:00
Johnathan Corgan 5851c7f36f Give the next-only mixed-profile suite a suite array
The parity guard now checks that every suite the local runner can dispatch has a backing suite array, so a suite dispatched without one is visible to nobody. On next, mixed-profile was dispatched by a bare call with no array, so the guard reported it as present on the GitHub matrix and missing from the local runner even though the local runner does run it.

This adds MIXED_PROFILE_SUITES, dispatches through it in the same idiom the admission-cap suite uses so the array is the single source of truth for whether the suite runs, and lists it in the suite listing. It exists only on next, so it is authored here directly rather than merged up.
2026-07-22 23:18:08 +00:00
Johnathan Corgan d8ded7000f Merge branch 'master' into next 2026-07-22 23:17:43 +00:00
Johnathan Corgan b56ec14d14 Merge master into next, bringing the chaos suite's failure reporting with it
The chaos simulation was equally unable to report a failure on this line: scenarios collided on globally-scoped container names and a shared config directory, the runner swallowed whatever they raised, and the harness discarded their exit codes before anything read them. The merged commits scope names and directories per scenario, give an aborted run its own exit code and let it reach the summary, stop a failed scenario harvesting whichever containers hold its names, and keep the daemon's reason for a failure instead of discarding it.

Testing only. ci-local.sh merged automatically; run_chaos and ci_teardown come across byte for byte and this line's own mixed-profile handling is untouched, the two having never overlapped.
2026-07-22 10:09:13 +00:00
Johnathan Corgan bd3b639570 Merge branch 'master' into next
# Conflicts:
#	CHANGELOG.md
2026-07-22 03:46:15 +00:00
Johnathan Corgan feb1e02e9c Verify the peer static on both FMP handshake paths, not just at rekey
Under Noise XX the peer static key is learned during the handshake rather
than pinned in advance, and neither of the two paths that learn one was
checking it against what we already knew. That let an attacker who could
observe and inject on path substitute their own identity, on both a fresh
dial and an established link.

On a fresh dial we recorded who we meant to reach and then overwrote it
with whoever answered, without ever comparing the two. Promotion, the ACL
check and the peer registry all then ran on the answering identity, so an
attacker who raced the real peer to msg2 became the peer and the intended
node was never reached. On an established link, a rekey msg2 was matched
to its peer only by the session index we had put in the cleartext msg1
header, so anyone who saw that header could answer with their own static
and take the link over at the cutover.

Both are now compared before anything is committed. The dial-time
expectation moves into its own field with no setter, so the handshake
cannot overwrite it the way it used to; the identity learned from msg2
keeps landing where it always did. Anonymous dials still promote whoever
answers, which is what shared-media discovery means, and that branch is
chosen locally when we dial rather than from anything on the wire, so it
cannot be reached as an exemption. Both comparisons are decisions made in
the synchronous core alongside the existing classifiers.

The gates sit ahead of every mutation, not merely ahead of the session
install, so a forged msg2 no longer poisons the recorded peer epoch and
never earns a msg3. A rejected dial is rescheduled from the dial-time
expectation rather than the answering identity, so refusing an impostor
does not silently retire a configured peer from the dial schedule.

Only this branch was exposed. The released lines use Noise IK, where the
initiator pins the responder static before it dials and a wrong key fails
the AEAD outright.

No wire change: both gates compare a value we already learn against one
we already hold, and legitimate handshakes behave exactly as before.

Six tests cover it, driving the real cadence and dial paths with a third
node answering the intercepted message. Each was checked to fail when the
decision is forced to accept. Local CI 37/37.
2026-07-21 19:40:50 +00:00
Johnathan Corgan deb5ded8eb Merge master into next after the v0.4.1 rollover
master carries only maint's 0.4.2-dev version bump linkage; next keeps its
own 0.6.0-dev development version. Recorded as a linkage merge so later
forward-merges of real fixes land cleanly.
2026-07-19 19:06:16 +00:00
Johnathan Corgan d0a0824768 Merge master into next after the v0.4.1 release
Same split as the maint-to-master merge: version identity stays with the
receiving branch, project state flows up.

Kept next's: Cargo.toml and Cargo.lock at 0.6.0-dev, the status badge, and
the paragraph identifying next as the wire-format-breaking line that will not
interoperate with v0.2.x, v0.3.x, or v0.4.x peers. Only the shipped-release
pointer inside it moved from v0.4.0 to v0.4.1.

The changelog conflict was additive rather than competing, and resolving it
either way would have lost real content. Next's [Unreleased] Fixed section
carries the XX rekey divergence and dual-initiation work; master brought the
[0.4.1] section. Git could not tell these were adjacent rather than rival, so
both were kept in order, with next's Breaking block and its own [Unreleased]
entries untouched.

Took from master: the bloom FPR default change and its duplicate-definition
fix, the docs describing them, the v0.4.1 release notes, the v0.4.0 date
correction, and the root RELEASE-NOTES.md mirror.

Checked before merging that no incoming content names the Noise handshake
pattern, since next is XX where master is IK. The two "Noise IK" strings on
master both predate v0.4.0 and next already carries its own wording for them,
so nothing needed rewording here.

Quartet green: 1698 tests passed, clippy clean with -D warnings.
2026-07-19 19:03:40 +00:00
Johnathan Corgan 7089a2949b Merge branch 'master' into next 2026-07-19 07:40:07 +00:00
Johnathan Corgan 0dea111fab Merge branch 'master' into next 2026-07-19 07:17:26 +00:00
Johnathan Corgan 564b31a914 Merge branch 'master' into next
Conflict in the admission-cap suite: this line's copy is the XX variant,
with a different phase 3 and an extra phase the other line does not have.
Took the polled final read from the merge, kept this variant's more
specific failure message and its enforcement-evidence phase.

Also templated two container references that phase and the peer-list read
own. They are unique to this line, so the pass that introduced the per-run
name suffix never saw them, and this suite would have looked up containers
under names that no longer exist once a run sets the suffix.
2026-07-19 06:48:17 +00:00
Johnathan Corgan f1879b8fe9 Merge branch 'master' into next 2026-07-19 06:45:39 +00:00
Johnathan Corgan afdae094dc Merge branch 'master' into next
Conflict in the FMP core doc pass: this line has no inbound establish
view at all, so the block that master edited does not exist here and the
merge saw a modify-versus-absent conflict. Resolved by keeping this
line's side - the establish view is master-line only and tracks the
handshake-pattern split, so its absence here is correct rather than an
omission to repair.

The lifecycle view correction and the comment rewrites in the node
module carry across unchanged.
2026-07-19 06:15:21 +00:00
Johnathan Corgan 60b1d27c0d Merge branch 'refactor-node' into refactor-node-next 2026-07-19 04:25:36 +00:00
Johnathan Corgan 6e54161022 Merge branch 'next' into refactor-node-next 2026-07-19 04:25:36 +00:00
Johnathan Corgan cb96a601c2 Merge branch 'master' into next 2026-07-19 04:25:25 +00:00
Johnathan Corgan 26bd1ada5b tests: guard the identity carrier and the inbound denial teardown
Two properties that the connection-state consolidation depends on had
no test on this line, because the pattern here is Noise XX and the
natural place to assert them under IK is msg1 -- which under XX carries
only ephemeral keys and so proves neither.

The first is that a learned peer identity lands on the surviving
carrier, the control machine's own connection state, rather than
staying locked inside the Noise handle. Every reader that names a peer
mid-handshake goes there: the promotion hand-off reads it to decide who
is being admitted, and the stale-connection sweep reads it to decide
whether a reaped leg is retried or torn down. XX learns identity once
per role at two different messages, so the guard asserts both -- the
initiator learning the responder from msg2, and the responder learning
the initiator from msg3. The initiator half starts from a deliberately
wrong dial-time expectation, so a missing write cannot pass by leaving
a pre-seeded value in place.

The second is that a peer the access list turns away leaves nothing
behind. Under XX the inbound admission gate cannot sit at msg1, since
no static key has crossed the wire yet, so it sits at msg3 -- by which
point the responder has built a control machine, allocated a session
index, opened a link, and completed the Noise session. All of that has
to come back down on the denial. A machine left registered is
unreachable by every teardown path and holds a peering-budget slot
forever; an index left mapped misroutes the next frame to arrive on it.

Both guards were confirmed to fail when the write they pin is dropped
and when the teardown they pin is skipped. Tests only; no production
behavior changes.
2026-07-19 03:30:12 +00:00
Johnathan Corgan b0cef7a221 Merge branch 'refactor-node' into refactor-node-next
Brings the handshake-leg deletion onto the XX line: the leg struct
and src/peer/connection.rs are gone, the Noise handles and crypto
methods now live on PeerMachine, and every leg accessor reads the
machine's own ConnectionState.

The incoming branch speaks Noise IK, this one speaks XX, so the crypto
methods could not be taken as they arrived. Git added the IK versions
to machine.rs with no conflict marker, and complete_handshake_msg3
-- which XX needs and IK has no counterpart for -- was absent
from the merged tree entirely. All four are re-expressed here: the
incoming structure (the Option<HandshakeCrypto> handle, the self.conn
reads, the borrow scoping that hoists carrier writes out of the leg
borrow) carrying this line's XX bodies. start_handshake is the one
that mattered most: its signature is identical on both lines, so it
compiled silently, and left alone it would have emitted an IK msg1
through the two-argument new_initiator and panicked every anonymous
dial on an expect for an identity XX does not have at that point. It
now uses the one-argument XX form and no expect.

handle_msg1 keeps this line's flow, not the incoming one's. The two
functions share a name but not a shape -- the admission gate moved
out of msg1 into msg3 when the line went to XX, and identity is
unknown until msg3. The machine is built above the crypto because
it now drives it, but it stays a local until the late insert, so a
rejected msg1 still leaves no registry trace and the drop-the-local
error arms are unchanged. Parking it at SentMsg2 after the index is
allocated needed a transition the birth constructor could not provide,
so that constructor now delegates to it.

Node::start_handshake is re-expressed the same way and for the same
reason: keeping the machine a local until all fallible setup has
succeeded preserves the existing error arms exactly, with no machine
disposal to add.

The two leg-to-carrier mirror blocks collapse. With one carrier
the writes they mirrored are self-assignment. Their content
is preserved: the completion touch comes from the relocated
complete_handshake, on the same clock, and the negotiated profile
from process_fmp_negotiation, which now takes the machine directly
and writes through set_conn_peer_profile.  Worth recording, because
the collapse was justified on a narrower claim that does not hold:
complete_handshake writes only the touch. It never wrote the profile
on either line. The two writes land on the same carrier at the same
point, so the collapse is neutral, but it is discharged jointly by
two methods rather than by complete_handshake alone.

Three inbound tests are dropped rather than carried. Each asserts
a property at msg1 that XX does not have there: an ACL decision, a
no-registry-trace guard on that decision, and an identity learn on the
carrier. Under XX msg1 is ephemeral-only, so none of the three has a
landing site at that step. A comment at each drop site records where
the property actually lives on this line. One of them was a deletion
here that the incoming branch had merely modified, so its removal
is this line's own prior decision rather than a new one. The same is
true of the craft_and_send_msg1 helper and of HandshakeSeed::inbound,
both left without callers once those tests went.

Also carried: peer_actions.rs had no conflict and no incoming change,
but three sites reached deleted Node accessors and are repointed at
the machine; the EstablishView snapshot is not resurrected, as this
line has never had it; and the connection-state doc keeps this line's
XX wording with the registry-independence invariant grafted in.
2026-07-19 02:42:24 +00:00
Johnathan Corgan a3c53a3b3c Merge branch 'refactor-node' into refactor-node-next
Re-establish ancestry after both branches independently merged their
respective mainline.
2026-07-18 20:42:45 +00:00
Johnathan Corgan 1e6b4cb918 Merge branch 'next' into refactor-node-next
Bring the Android-ready core (target_os gating and app-owned TUN seam)
onto this branch so subsequent work builds on the current mainline tree.
2026-07-18 20:42:45 +00:00
Johnathan Corgan df52ce459c Merge branch 'refactor-node' into refactor-node-next
Forward-merge the state-carrier collapse (connection start/activity timestamps,
peer-identity telemetry, handshake resend buffers, index/transport bookkeeping,
and the session-index shadow all moved onto the peer machine's own connection
state) onto the XX handshake.

Next-specific resolutions folded into the merge: the responder learns its transport
id and peer index at msg1 and they are written directly onto the machine there (the
XX machine has no msg1 step, so the promote read would otherwise be unset); the
negotiated peer profile is mirrored onto the machine carrier at both the initiator
(msg2) and responder (msg3) negotiation sites; and the outbound carrier writes land
on both the identified and anonymous dial paths. The leg's transport/index/buffer
copies remain as dead duplicates until the leg dissolves; the machine copy is the
sole live source.
2026-07-18 18:24:44 +00:00
Johnathan Corgan 91ba660213 Merge branch 'refactor-node' into refactor-node-next
Re-express the handshake-state carrier collapse onto the XX code: the leg's
handshake_state field is deleted and the displayed state is derived from the
peer machine's phase, with failure carried on the machine (a send_failed flag
that preserves the handshake phase) rather than on the leg. The next projection
maps the SentMsg2 responder phase to received_msg1 and the anonymous-dial
Discovered phase to sent_msg1; the three initiator send-failure sites carry
failure via send_failed. Telemetry strings, wire bytes, index allocation, and
stale-connection reaping are byte-identical to next.
2026-07-18 04:08:26 +00:00
Johnathan Corgan 7ea5718270 Merge branch 'master' into next 2026-07-18 02:57:30 +00:00
Johnathan Corgan f7fa257349 node: classify inbound msg3 via a single machine decision site
handle_msg3 now routes on the InboundDecision returned by the machine's
inbound_msg3 call, instead of computing establish_inbound in the shell and
handling the terminal arms inline. The DualRekeyWon and ResendMsg2 arms'
index free moves to the machine's FreeIndex action (run through the
executor); the ResendMsg2 stored-msg2 resend stays inline. The pre-decision
reject gates and the proceed-arm bodies are unchanged.

Behavior-neutral: the same establish_inbound decision over the same snapshot
and wire, evaluated once in the machine, with a byte-identical free-count on
every arm and no change to wire sends, machine state, or telemetry.
2026-07-17 21:02:05 +00:00
Johnathan Corgan 7e5619ad32 peer: return the inbound msg3 decision from the machine arm
on_inbound_msg3 becomes inbound_msg3, returning the InboundDecision
alongside the machine-phase actions so a caller can route on it; the step
arm delegates and drops the decision. The signature is unchanged, so
step-driven callers are untouched.

Also drop two emissions that are dead on the live path: the ResendMsg2
arm's SendHandshake (the stored-msg2 resend is a driver mechanism the
executor would otherwise reframe) and the dormant inbound-msg1 arm's
retransmit timer. No live behavior changes.
2026-07-17 20:43:08 +00:00
Johnathan Corgan b1adbe3af7 Merge branch 'refactor-node' into refactor-node-next
Carries the inbound establish-decision reshape from the IK line. Next's XX
inbound surface already computes that decision on its own machine, so the
merge records the shared history without changing next's tree.
2026-07-17 20:19:33 +00:00
Johnathan Corgan f65cd07048 Merge branch 'refactor-node' into refactor-node-next 2026-07-17 07:43:36 +00:00
Johnathan Corgan 4fa565e528 Merge branch 'refactor-node' into refactor-node-next
Carries the leg-embed storage move: PeerMachine.leg replaces the
Node.connections map. Hand-resolved onto the XX surfaces keeping this
line's semantics with the new storage: the msg3 termination arms and
executor swap/rekey-responder teardowns keep their exact cleanup sets
and index-free behavior; the msg2 cross-connection extract takes the
leg before the unconditional machine dispose; msg1 and anonymous leg
births embed the leg at machine insertion; the rekey-vs-establish gate
tests leg-absence. The merge also drops the now-redundant explicit
machine inserts next to the seeded test seam, whose auto-merged
combination clobbered leg-carrying machines.
2026-07-17 05:07:36 +00:00
Johnathan Corgan ba3c552dce node: give anonymous outbound legs a persistent control machine
The anonymous-discovery dial paths drove no control machine: the leg was
born bare in start_handshake and a throwaway machine appeared only at
msg2. Anonymous legs now birth a persistent identity-less machine
alongside the leg (new_outbound widened to Option<PeerIdentity>, backed
by the existing identity-less outbound connection state), and handle_msg2
crystallizes the learned identity onto it before stepping. The msg2
missing-machine arm becomes defensive recovery (debug-assert + insert,
matching the IK line), and the self-connect drop arm disposes the
machine it previously leaked — reachable from identified dials too,
since the handshake completion overwrites the expected identity without
comparing it to the dial-time expectation. The self-connect arm's
index/link/pending-entry leak is pre-existing and stays as-is.

With every leg now born with a machine, the leg-to-machine direction of
the debug coherence check switches on for this line. The add_connection
test seam seeds direction-faithful machines (an outbound-anonymous
seeded connection previously got an inbound-shaped one), and the
hand-rolled dial sites in tests seed dial machines mirroring
production. New tests pin the identity-less birth, the msg2 identity
crystallization through promote, and the self-connect disposal.
2026-07-17 03:25:28 +00:00
Johnathan Corgan d6785f2970 node: make the inbound handshake machine persistent across its leg's life
The inbound control machine was a msg3-time throwaway: handle_msg3 built
a transient, stepped it once for the decision, and discarded it, with
promote_connection birthing a fresh established() machine. Every
handshake leg now gets one persistent machine for its whole life:

- handle_msg1 births the machine parked in the sent-msg2 phase alongside
  the window leg (msg1 crypto and the msg2 build/send stay inline; the
  msg2-send-failure cleanup disposes it).
- handle_msg3 steps that persistent machine instead of a transient. The
  decision path is unchanged: the msg3 handler never reads machine state
  and overwrites the identity-plane fields from the wire outcome before
  dispatching, so every arm's decision and actions are byte-identical.
- promote_connection stops rebirthing: the executor feeds the promotion
  result back into the machine (the shape the IK line already uses) and
  the machine crystallizes to Established in place.
- Every path that tears down an inbound window leg now disposes its
  machine through remove_peer_machine: the seven inline msg3 termination
  arms, the six executor swap/rekey-responder consumption sites, both
  promote-failure arms, the single-peer leaf reject, the losing side of
  a cross-connection, and the msg1 send-failure cleanup. The
  promote-failure disposal also fixes a pre-existing leak: an outbound
  leg whose promotion fails had its connections entry consumed before
  the error, so the stale reaper could never reach its dial machine.

Index-free behavior is unchanged at every site. Two test helpers that
hand-roll outbound dials now insert the dial machine production
inserts, and new tests pin the machine's birth phase, its post-promote
crystallized state, and disposal on the failure and rekey-responder
arms.
2026-07-17 02:54:00 +00:00
Johnathan Corgan 3f9de1adcd Merge branch 'refactor-node' into refactor-node-next
Carries the debug-build peer-map coherence check. The leg-to-machine
direction is gated OFF on this line (const flipped in the merge): the
inbound msg1-to-msg3 window legs and anonymous-discovery outbound legs
legitimately run machine-less here, so only the machine-to-carrier
leak-tripwire direction asserts until every leg is born with a
persistent machine.
2026-07-17 02:13:31 +00:00
Johnathan Corgan 4779a3970d Merge branch 'refactor-node' into refactor-node-next
Carries the dead-code deletion (PeerSlot enum, PeerConnection resend
API) and the module-doc liveness corrections. Hand-resolved the doc
conflicts with next-appropriate wording: the executor module doc names
the live msg3 decision-machine path (PromoteToActive /
SwapToInboundSession / RekeyRespondTrigger) while keeping the still-true
msg1-inline dormancy note; the peer_machines field doc reflects next's
promote-time established() births; the machine module doc drops the
stale unwired claims, including the provisional-and-unwired note on the
msg3 trigger variants which are live.
2026-07-17 01:41:21 +00:00
Johnathan Corgan 5a8da01f24 Merge branch 'master' into next
Fold in a next-only wording fix for the OpenWrt 802.11s mesh backhaul:
next's FMP link handshake is Noise XX, not the Noise IK on master, so the
fips-mesh-setup header and the how-to now name it pattern-agnostically
("the Noise handshake"), matching the shipped fips.yaml comment and
staying accurate on this branch.
2026-07-16 05:56:36 +00:00
Johnathan Corgan 252b77683e Merge branch 'refactor-node' into refactor-node-next
Re-express the rekey-msg2 and cross-connection observation feeds onto
the XX cores. The rekey-msg2 observation lands inside the block that
installs the pending session (where XX also generates msg3), so it fires
only when the install actually ran. The cross-connection observation
sits at the outbound resolution arms and targets the promoted peer's
machine; the inbound session-swap effects already own their executor
path here and are untouched. Crypto effect bodies are byte-unchanged.
2026-07-16 00:04:31 +00:00
Johnathan Corgan 2fad6be5e6 node: execute inbound session-swap effects through the per-peer machine
Move the two Noise-XX inbound establish effects — the cross-connection
session swap and the rekey-respond session install — out of the inline
handle_msg3 arms and into their per-peer machine executor actions
(SwapToInboundSession, RekeyRespondTrigger). The inbound establish
decision now falls through to the shared machine-drive arm, matching the
promote/restart arms.

Carry the session index on the InboundMsg3 event and seed it into the
transient machine before dispatch. The swap actions build their index
from the machine's connection state, which is unset on a fresh inbound
transient; without the seed the actions would emit empty and the swap
would silently no-op while leaking the allocated index.

Give the inbound reject and msg2-resend machine arms their terminal
Failed state and index free, matching the inline arms that own those
paths, so a later timer cannot free the index or report loss for a peer
whose handshake has already resolved.

Preserve exactly: the three distinct index frees and their paired index
and pending-outbound map removals; the link-teardown asymmetry (the
cross-connection path drops the address mapping, the rekey-respond path
keeps it); the session take-then-install ordering; and every reject kind
on the error sub-paths.
2026-07-15 23:12:48 +00:00
Johnathan Corgan efbfb32e07 Merge branch 'refactor-node' into refactor-node-next
Fold the handshake timer lifecycle (msg1 retransmit + timeout/stale
reap) through the per-peer machine, re-expressed onto the XX handshake
surface.

- Route peer-machine removal through remove_peer_machine so each link's
  timer store is dropped together with its machine (choke-point).
- Home the msg1-retransmit decision on the machine-armed retransmit
  timer; advance the resend counter only on send success and relocate
  the operator-visible resend count onto the machine.
- Reap outbound handshake timeouts via a presence-scan over the machine
  HandshakeTimeout timer, reading the threshold from config each tick so
  the reap stays neutral for any handshake_timeout_secs.

Cancel both dial-armed handshake timers on outbound promote so a
promoted machine carries no stale timer entry. Preserve the guard that
suppresses a msg1 resend at a peer already promoted via the inbound
cross-connection path.

The XX inbound HandshakeTimeout presence-scan is neutral only because
the inbound establish path sends msg2 inline and never dispatches the
inbound machine event, so no inbound leg ever populates a
HandshakeTimeout timer. A future change that drives inbound establish
through the machine must re-verify the reap equivalence for inbound
legs.
2026-07-15 21:38:52 +00:00
Johnathan Corgan 8abcd6ff8c Merge branch 'refactor-node' into refactor-node-next
Bring the outbound-handshake-through-the-machine series onto the next-branch
Noise-XX cores. Re-expressed rather than transcribed: next has no outbound
decision core, so the promote drives PeerEvent::OutboundMsg2 (not the IK Msg2
snapshot event) and cross-connection resolution stays inline. Hybrid provenance
-- identified outbound legs persist the control machine at dial and drive
through it; anonymous-discovery legs (identity unknown until XX msg2) retain the
inline transient-at-msg2 path. ReportLost{kind} routing, the connection-oriented
dial via the machine (OpenTransport + TransportConnected), and the prepare/send
msg1 split all carried over.

Behavior-neutral on next: the XX msg2 body (Noise completion, FMP, ACL, msg3) is
untouched; anonymous legs are unchanged; the dial-persisted machine promotes
with our_index unset.
2026-07-15 15:52:29 +00:00
Johnathan Corgan 4620d9f82e Merge branch 'refactor-node' into refactor-node-next
# Conflicts:
#	src/node/dataplane/peer_actions.rs
2026-07-15 01:30:14 +00:00
Johnathan Corgan 7e4358782e node: free the inbound session index on the msg3 reject and resend paths
On two inbound msg3 outcomes the msg1-allocated inbound session index
was dropped without returning it to the allocator: losing the dual-rekey
tie-break (we keep our in-progress rekey and drop their msg3), and a
duplicate handshake that resolves to a msg2 resend. Under repeated hits
this slowly leaks index space. Free the index on both arms, matching the
sibling reject arms.

Add a test-only helper to backdate a peer's session-established instant,
and two regression tests that drive a real second handshake into each
arm and assert the index is returned to the allocator (both fail if the
free is removed).
2026-07-15 01:26:54 +00:00
Johnathan Corgan 21b93f4116 Merge branch 'refactor-node' into refactor-node-next
Forward-merge the action-contract round-trip test. This line's action type
carries two extra establish-trigger variants (SwapToInboundSession,
RekeyRespondTrigger), so the merge resolution extends the test's sample set
and its wildcard-free exhaustiveness match to cover all of them — the test
proves every variant, including the two XX-only triggers, round-trips
unchanged through the async channel.
2026-07-14 23:52:43 +00:00
Johnathan Corgan a6f7fc2d2f Merge branch 'refactor-node' into refactor-node-next
Forward-merge the promote-failure warning target pin. The peer-action
executor diverges between the two lines (IK vs XX establish arms), so the
pin was applied to this line's own two promote-Err warns during the merge
resolution: outbound "Failed to promote connection" and inbound "Failed to
promote inbound connection" now emit under fips::node::handlers::handshake,
matching the sibling max-peers reject log already pinned to that target.
2026-07-14 23:29:34 +00:00
Johnathan Corgan 22a5f1f807 Merge branch 'refactor-node' into refactor-node-next
# Conflicts:
#	src/node/dataplane/dispatch.rs
#	src/node/dataplane/peer_actions.rs
#	src/node/handlers/handshake.rs
#	src/node/handlers/mmp.rs
#	src/node/handlers/rekey.rs
#	src/node/mod.rs
#	src/peer/machine.rs
2026-07-14 01:40:01 +00:00
Johnathan Corgan 1e934e7603 node: drive net-new outbound promote through the peer machine
Route the outbound side's msg2-completion promote through the per-peer
state machine and its action executor, mirroring how the net-new inbound
promote is driven. A transient outbound machine emits the promote action;
the executor performs the promotion and, on success only, clears the
pending-outbound entry (threaded in through the action context). The
outbound cross-connection path stays inline as before.
2026-07-13 23:26:30 +00:00
Johnathan Corgan 40d1a5bca7 node: pin the max-peers inbound-reject log to the handshake tracing target
The debug log for rejecting an over-cap inbound connection is emitted from
the peer-action executor, under that module's tracing target. Nodes that
raise only the handshake module to debug (leaving other modules at info) no
longer surfaced the rejection, so cap-enforcement events were invisible in
their logs. Re-pin the log to the handshake target, matching the adjacent
promoting-inbound log, so the rejection stays visible under a
handshake=debug filter regardless of the executor module's own level.
2026-07-13 23:24:56 +00:00
Johnathan Corgan cde1af2d1b node: drive inbound establish decision and net-new promote through the peer machine
Route XX inbound establishment through the per-peer machine: handle_msg3 hands
the establish decision to a transient machine and drives the net-new promote
(Promote + RestartThenPromote) through the executor's PromoteToActive action,
which transcribes the shared promote block (promote_connection plus the
msg2/tree-announce/bloom follow-ups). The four non-promote decisions (Reject,
ResendMsg2, CrossConnect, RekeyRespond) stay inline byte-for-byte; the two
session-swap arms are deferred to a later non-neutral step.

The decrypt-worker register stays inside promote_connection (not relocated),
no promotion-resolved feedback is fed back, and the transient machine is never
inserted into peer_machines (the persistent machine is created once inside
promote_connection). Behavior matches the inline path exactly, and operator
diagnostics are preserved: the relocated promoting-inbound log keeps its
handshake tracing target, and the epoch-mismatch restart breadcrumb is
re-emitted.
2026-07-13 22:26:19 +00:00
Johnathan Corgan 09d0d41639 node: drive rekey cadence cutover and drain through the peer machine
Route the initiator rekey cutover and drain-completion through the
per-peer machine and executor instead of running them inline in
check_rekey. The batch poll_rekey and its per-peer snapshots stay
shell-side and unchanged, so the cross-peer phase ordering that governs
the shared index-allocator sequence is preserved; the machine only
consumes each decided action in that order.

The routed cutover and drain reproduce the inline bodies exactly (the
executor's swap-send-state and complete-drain arms), including the
info-level cutover log and the decrypt-worker re-register. Initiating a
rekey stays inline; the machine is fed a rekey-initiated observation
afterward to keep its control state coherent. A guard falls back to the
inline bodies if a machine is somehow absent.
2026-07-13 21:29:50 +00:00
Johnathan Corgan c15e63e101 node: drive the link-dead peer reap through the per-peer machine
Route the liveness reap through the per-peer machine: on a link-dead
timeout, the mmp reap advances the peer's machine with a link-dead event
and executes the resulting actions, instead of calling remove_active_peer
and note_link_dead inline.

The effect is unchanged — the machine emits InvalidateSendState then
ReportLost, which the executor maps back to remove_active_peer(peer)
then note_link_dead(peer, now), the same two calls in the same order.
The reap log stays shell-side so it keeps the handlers::mmp target, and
a guard falls back to the inline path if a machine is somehow absent.
2026-07-13 20:52:58 +00:00
Johnathan Corgan 5498b2a6a3 node: populate the per-peer machine map at establishment
Insert a per-peer machine into peer_machines at each promote site and
remove it on teardown, so every established peer has exactly one machine
entry keyed by its link id. Nothing drives the machine yet — the entry
is inert — but this correspondence is what the rekey and liveness-reap
folds depend on.

Inserts pair with the two promote_connection arms (normal promote and
cross-connection-won); removes pair with remove_active_peer and the
cross-connection loser teardown. A new PeerMachine::established
constructor parks the machine in the post-handshake Established state.
Behavior is unchanged: nothing reads the map on a live path.
2026-07-13 20:33:29 +00:00
Johnathan Corgan 42de582ef0 node/dataplane: add the per-peer machine action executor (shadow)
Add the executor that consumes the per-peer machine's actions, plus the
peer_machines map that homes the machines on the node. Both are shadow:
nothing populates the map or invokes the executor yet.

The rekey-cutover, drain, liveness-reap, and index-plane action bodies
are ported to reproduce the next-line handlers exactly (the cutover log
matches check_rekey's info-level line, kept under the handlers::rekey
target so operator and test log filters still see it). The establishment
actions (promote, cross-connect swap, rekey-respond) are deferred stubs
until the inbound establish path is wired.
2026-07-13 20:13:16 +00:00
Johnathan Corgan 0eaae58dc8 peer: add the per-peer FMP control state machine (XX, unwired)
Re-derive the per-peer FMP lifecycle state machine against the next-line
XX establishment cores, as an unwired module. It ports the master-line
machine's structure but adapts it to the XX handshake:

- inbound establishment splits into a msg1 event (allocate the index,
  send msg2, defer) and a msg3 classify event (identity crystallizes,
  establish_inbound runs), matching XX's identity-at-msg3 timing;
- the inbound decision handles all six InboundDecision variants including
  the XX-only CrossConnect;
- the outbound establish_outbound path and the two-phase authorize are
  dropped (no analog on the XX line);
- session-carrying establish effects (cross-connect swap, rekey-respond
  pending session) are represented as plain-data trigger actions; the
  session surgery is done shell-side when the machine is wired.

Nothing drives the machine yet; it is dead code pending the executor and
handler wiring. Unit tests cover the XX dispatch: inbound establish to
Established, dual-init tie-break, cross-connect at msg3, rekey cutover
key-consistency, and liveness reporting.
2026-07-13 19:47:56 +00:00
Johnathan Corgan 49677cc426 Merge branch 'refactor-node' into refactor-node-next
Forward-merge the per-peer FMP decomposition from the master line onto
the next line. The establishment and rekey machine-drive on refactor-node
is built on the IK establishment cores (establish_outbound, identity
learned at msg1) that the next line replaced with the XX msg3 model, so
it cannot be carried textually; it is re-derived against next's XX cores
in follow-on commits on this branch.

This merge commit carries only the two changes that are neutral and
next-compatible as-is:

- OwnedFd hygiene for the connected-UDP socket (RawFd -> OwnedFd, drop the
  hand-rolled unsafe Drop; OwnedFd's own Drop closes the descriptor).
- The two-tier send-state boundary: the send-critical subset of ActivePeer
  is regrouped into a PeerSendState struct, with accessors kept stable so
  no caller changes. next's XX establishment, rekey, and liveness-reap
  behavior is unchanged (verified: exact field partition, identical
  accessor signatures and semantics, identical init values, 3-arg mmp
  construction).

The per-peer machine, its action executor, and the peer_machines map are
not carried here; they are re-derived against next's XX establishment
surface in subsequent commits on this branch.
2026-07-13 18:56:41 +00:00
Johnathan Corgan d58bd083fa Merge branch 'refactor-node' into refactor-node-next 2026-07-13 08:49:05 +00:00
Johnathan Corgan c08888afe6 Merge branch 'refactor-node' into refactor-node-next 2026-07-13 08:25:56 +00:00
Johnathan Corgan fa3f88b433 Merge branch 'refactor-node' into refactor-node-next 2026-07-13 08:00:54 +00:00
Johnathan Corgan b7c3d8935c Merge branch 'refactor-node' into refactor-node-next
Forward-merge the peering homeostatic reconciler onto the XX/v2 handshake
line. The three scattered peering mechanisms (auto-connect and retry,
overlay discovery, and opportunistic transport-neighbor growth) are now
unified in the sans-IO reconciler on both lines.

Hand-resolved on the first-contact surface: the reconciler opportunistic
layer emits a connect for anonymous, identity-unknown transport legs
unconditionally (bypassing the connect budget and per-peer cap, gated only
by the addr_to_link dedup), reproducing the XX dial-before-identity-known
behavior; named legs route through the reconciler with inputs identical to
the master line. handle_msg1/msg3 keep the XX path; the peer-loss reflexes
use the relocated wrappers. Behavior-neutral versus the pre-merge next line.
2026-07-13 06:40:05 +00:00
Johnathan Corgan 79f79cf6ac Merge branch 'refactor-node' into refactor-node-next 2026-07-13 00:50:40 +00:00
Johnathan Corgan de72bf76ea Merge branch 'refactor-node' into refactor-node-next
Forward-merge the dataplane/session concept-home reorganization onto
the next line. The moved files carry next's (XX-line) content; the one
hand-resolved surface is src/peer/active.rs, where the source-location
citation sits in different surrounding text than on the master line —
resolved by keeping next's body and applying the same node/session.rs
-> node/session/mod.rs citation update.
2026-07-12 19:24:07 +00:00
Johnathan Corgan a7de830b8d Merge branch 'master' into next
Forward-merge the fipstop routing-stats label flip and the three fixes
carried up from maint: drop the redundant parent_switched counter, keep
the tighter path_mtu on a LookupResponse, and reuse one shared secp256k1
context. Clean auto-merge into next's post-refactor structure.
2026-07-12 16:55:50 +00:00
Johnathan Corgan 6f8590cd92 docs: flag the bloom-filter design doc as describing the retired v1.5
The code on next is currently v1 after the v1.5 removal during the
sans-IO refactor, but this doc still described the abandoned v1.5 design
as implemented, which is misleading. Add an interim status banner
pointing at v2 as the pending target until the doc is rewritten to v2.
2026-07-12 15:36:54 +00:00
Johnathan Corgan 033957f23d Merge branch 'refactor-transport' into refactor-next
Forward-merge the transport neighbor-beacon rename (ethernet + BLE
discovery -> neighbor module/buffer rename; ethernet config discovery
flag -> listen with serde alias). Hand-resolved the module files against
next's identity-out-of-beacon beacon rewrite by starting from next's
bodies and re-applying the rename by identifier, so next's beacon logic
(no-arg build_beacon, bool parse_beacon, computed BEACON_SIZE, one-arg
add_peer, deleted BLE add_peer_with_pubkey) is preserved unchanged.
2026-07-11 23:20:50 +00:00
Johnathan Corgan 3f214c8f63 Merge branch 'refactor-transport' into refactor-next
Brings the connected-UDP fast-path plane relocation (ConnectedPeerSocket/
PeerRecvDrain into peer/connected_udp; udp keeps open_connected_fd in io.rs)
and the darwin_sockopts -> sockopts_macos rename onto the next line. Clean
three-way auto-merge; behavior-neutral.
2026-07-11 21:42:25 +00:00
Johnathan Corgan ccd47bd742 Merge branch 'refactor-transport' into refactor-next
Brings the canonical per-transport layout normalization (udp/ethernet
byte-layer renamed to io, ethernet addr.rs home, tcp pool.rs extraction)
onto the next line. Clean three-way auto-merge; behavior-neutral.
2026-07-11 20:32:52 +00:00
Johnathan Corgan 775c7ae4c0 Merge branch 'refactor-transport' into refactor-next 2026-07-11 18:46:00 +00:00
Johnathan Corgan 14edddc14a Merge branch 'master' into next 2026-07-11 05:05:22 +00:00
Johnathan Corgan 81baeebf22 Merge branch 'master' into next 2026-07-11 02:15:24 +00:00
Johnathan Corgan b9eb725e5a Merge FMP wire + PromotionResult relocations into next
Forward-merge the master-line FMP-establish relocations onto next: the FMP
link wire codec into proto/fmp/wire.rs and PromotionResult into
proto/fmp/core.rs. The wire move was hand-resolved against next's wire
format (msg3/Msg3Header/build_msg3, FMP_VERSION=1, no spin-bit flag), so
next's behavior is unchanged. Behavior-neutral; full lib suite green at
next baseline.
2026-07-10 17:34:49 +00:00
Johnathan Corgan f1ff410fd2 Merge the nostr sans-IO state-machine refactor into the next line
Forward-merge the AdvertMachine / TraversalMachine / classify_punch_packet
sans-IO extraction. Clean auto-merge; the next-line XX-handshake runtime.rs
deltas sit outside the traversal/replay/election sites.
2026-07-10 03:28:20 +00:00
Johnathan Corgan 6954ebbf34 Merge the nostr rendezvous reorg into the next line
Forward-merge the src/nostr / src/mdns rendezvous reorganization
(relocation out of src/discovery/, the Discovery->Rendezvous rename, the
RendezvousDriver consolidation, and the trace-target reference updates)
onto the next branch. The sole conflict was in the LAN poll method's
doc-comment and signature: kept next's XX-handshake wording and applied
the lan_rendezvous rename. Merged tree builds clean; next lib suite 1583
passing, fmt/clippy clean.
2026-07-09 23:48:53 +00:00
Johnathan Corgan d2331ecb78 Merge refactor-sans-io: disambiguate the "discovery" name
Forward-merge the mesh-lookup "discovery" disambiguation onto the next
line: the lookup handler module and Node.lookup field renames, the lookup
metric types, the dual-emitted lookup/discovery metric family, and the
node.discovery -> node.lookup/node.rendezvous config split with its
deprecation shim.

Conflicts resolved by keeping next's XX handshake path (the inbound
responder stores pending_inbound for msg3 rather than promoting on msg1)
and renaming its reset_discovery_backoff call sites to reset_lookup_backoff;
the CHANGELOG Unreleased entries from both lines are combined.
2026-07-09 15:43:57 +00:00
Johnathan Corgan 1ec7a4c680 Merge refactor-sans-io: relative-tolerance powi test drift into the next line 2026-07-09 01:30:28 +00:00
Johnathan Corgan 0c948bd707 Merge refactor-sans-io: portable powi guard test into the next line 2026-07-09 01:20:00 +00:00
Johnathan Corgan c5d48e5ef4 Merge refactor-sans-io: rename proto/discovery to proto/lookup into the next line
Forward-merge the mesh discovery module rename (directory, exported types, and
internal lookup-stem terminology) onto the next wire-format line. next's
divergent lookup wire and core content is preserved under the new proto/lookup
path, with the same terminology sweep applied to its next-only lines.
2026-07-08 21:54:24 +00:00
Johnathan Corgan c0b2822d56 Merge refactor-sans-io: sans-IO proto cleanup into the next line
Forward-merge the sans-IO cleanup series (bloom fpr, discovery const/RNG-injection,
mmp state decomposition, mmp shell dissolution, STP clock-free classify core, STP
declaration split, shared proto/coord relocation, shared rate limiter/backoff,
typed proto errors dropping thiserror, shared bounds-checked codec reader/writer,
core/alloc import sweep, no_std-shaped filter math) onto the next wire-format line.

Reconciled master structure against the next wire semantics: the mmp role-module
split adopts next slim report format (spin-bit stays dropped, sender/receiver
build next reports), the discovery and fmp codecs keep next TLV and profile
negotiation while moving to the typed error, and the parent-eval handler keeps the
Full/Leaf profile filter under the new ParentEval/is_switch_suppressed seam.
2026-07-08 17:52:24 +00:00
Johnathan Corgan fff1d6285a Merge refactor-sans-io: FSP sans-IO extraction and protocol teardown
Forward-merge the FSP session-protocol sans-IO migration and the src/protocol
teardown from the master-refactor line onto next. Converge proto/fsp across
both lines (core.rs byte-identical), keeping next's born-on-next residue: the
additive SessionSetup/SessionAck wire variants, the spin-bit-less unit
FspInnerFlags, and the XX handshake shell residue (negotiation-payload
piggyback, initiator identity check, min-size length checks) in
handlers/session.rs. src/protocol is removed on next as well; ProtocolError,
LinkMessageType, and SessionDatagram reach their proto/ homes.
2026-07-08 10:18:18 +00:00
Johnathan Corgan 45f65e7ca1 Merge refactor-sans-io: converge next bloom onto v1-sans-IO
Forward-merge the master-line bloom relocation into the next line,
discarding next's v1.5 bloom draft (RLE codec, XOR-diff delta,
FilterNack/0x21, adaptive sizing) and converging both lines onto the
identical proto/bloom v1-sans-IO module. This is a deliberate,
temporary wire regression on the next line: the v2 bloom is rebuilt
fresh, sans-IO from day one, on both lines later. Nothing outside the
bloom feature depended on the v1.5-specific surface. proto/bloom is now
byte-identical across both integration lines.
2026-07-07 23:22:22 +00:00
Johnathan Corgan 6a028064ef Merge refactor-sans-io: STP sans-IO spanning-tree on the next line
Forward-merge the STP sans-IO migration. The classify / proto-stp structure,
clock injection, crypto field-partition, and BTree collections come from the
master line; next's non-full/leaf parent-candidacy skip is preserved by passing
self.non_full_peers() at the four shell call sites (handle_tree_announce,
check_periodic_parent_reeval, the MMP first-RTT re-eval, and the greedy-tree
find_next_hop fallback) instead of an empty set, with non_full_peers returning a
BTreeSet. next's four non_full_peers skip tests fold into proto/stp/tests.
2026-07-07 17:22:25 +00:00
Johnathan Corgan 584edfac20 Merge refactor-sans-io: MMP sans-IO reporting on the next line 2026-07-07 11:30:40 +00:00
Johnathan Corgan 3b46bbe17d Merge refactor-sans-io: FMP sans-IO connection-lifecycle on the next line 2026-07-07 07:32:02 +00:00
Johnathan Corgan 78eadbcd65 Merge refactor-sans-io: routing sans-IO on the next line 2026-07-07 04:29:58 +00:00
Johnathan Corgan 74ab6d58ba Merge refactor-sans-io: reconcile discovery sans-IO core with the FMP v2 delta
Forward-merge the master-side sans-IO refactor (discovery migration + no_std
reductions, collapsed to one commit) into the next-side branch. The discovery
decision core meets next's FMP profile/LookupRequest delta here: next's four
transit-forward predicates (Leaf no-forward, Full-profile, min_mtu, tree/fallback)
are folded into the pure core planners via an extended RoutingView seam
(node_is_leaf / peer_is_full / peer_meets_mtu, new ForwardOutcome::LeafNoForward),
and next's v2 LookupRequest API delta (origin_coords removed, tlv_entries added)
is reconciled across the discovery test tree, with next's four TLV wire tests
ported into the relocated wire test module.

The master-side branch now also carries the no_std+alloc reductions (BTreeMap,
alloc::sync::Arc, backoff-reset log moved to the shell, extern crate alloc),
which come through cleanly on the pilot-only core files.

Validated: cargo fmt / clippy (-D warnings) / test --lib all green, including
next's own transit MTU-pruning integration tests driving the refactored core
through the full shell path.
2026-07-05 22:01:06 +00:00
Johnathan Corgan bfc1cd780f Merge branch 'master' into next 2026-07-02 22:03:52 +00:00
Johnathan Corgan ceb60c9976 Merge branch 'master' into next 2026-06-29 16:47:43 +00:00
Johnathan Corgan f7a0f906fa Merge branch 'master' into next 2026-06-29 14:23:57 +00:00
Johnathan Corgan 56bdea153b Merge master into next after the v0.4.0 rollover
Brings the v0.4.0 release line up to date on next without disturbing
the in-flight wire-format work; next keeps its own 0.6.0-dev version.
Linkage merge so later forward-merges land cleanly.
2026-06-27 18:35:51 +00:00
Johnathan Corgan 330c6aa9af Open 0.6.0-dev cycle on next after v0.4.0 release
Bump the version to 0.6.0-dev and update the status badge, and correct
the 0.4.0 changelog heading to the actual release date.
2026-06-27 18:30:26 +00:00
Johnathan Corgan c8c2ae96eb Merge branch 'master' into next (openwrt .apk WAN-port default for DSA boards) 2026-06-26 03:42:23 +00:00
Johnathan Corgan 5bc42b4deb Merge branch 'master' into next (OpenWrt Blossom upload resilience) 2026-06-21 14:40:50 +00:00
Johnathan Corgan e305e1c4e4 Merge branch 'master' into next (v0.4.0 release content)
Forward-merge the v0.4.0 content finalize from master. The only master
commit merged touches the CHANGELOG, release notes, and README badge.

CHANGELOG reconciliation: adopt the regrouped, stamped [0.4.0] release
section verbatim from master (a frozen release record, identical across
branches), keep next's top-of-file Breaking block, and retain next's
XX-handshake / XX-rekey items in [Unreleased] as 0.5.0 work (the XX path
does not exist on master, which is IK). Release notes adopt master's
refreshed versions. README stays at v0.5.0-dev on next (master's badge
bump to v0.4.0 is master-line only).

Claude-Session: https://claude.ai/code/session_01A2pYfSypNmmG4HyHwZuLex
2026-06-21 14:02:23 +00:00
Johnathan Corgan d59e4d18f9 Merge branch 'master' into next 2026-06-20 14:58:16 +00:00
Johnathan Corgan 5b767827bb Merge branch 'master' into next 2026-06-18 15:38:10 +00:00
Johnathan Corgan 82ba8e656f Merge branch 'master' into next 2026-06-17 18:31:13 +00:00
Johnathan Corgan db7ad32db8 Merge branch 'master' into next 2026-06-17 16:43:18 +00:00
Johnathan Corgan ec123107ef Merge branch 'master' into next 2026-06-17 15:40:54 +00:00
Johnathan Corgan 86ec7a37e8 Merge branch 'master' into next 2026-06-14 18:54:23 +00:00
Johnathan Corgan 784e22ab1f Merge branch 'master' into next
Forward-merge the v0.4.0 pre-release content (dependency refresh, CI
deb-install + AUR-build legs, docs refresh, doc-comment fixes, and the
Phase 4 source-content squash: CHANGELOG, release notes, README,
fips.yaml, Cargo.toml metadata, reference docs) up the one-way flow.

Conflict fixups (keep next's identity, fold in master's improvements):
- Version: keep next's 0.5.0-dev (Cargo.toml/lock).
- README status: keep next's v0.5.0-dev / wire-format-breaking framing
  and the Breaking-section pointer; fold in the Nym transport and the
  "global, public test mesh of thousands of nodes" description.
- docs/reference/cli-fips.md: keep next's 0.5.0-dev version example.
- CHANGELOG: keep next's XX-handshake admission entry (no early cap gate
  on XX) and drop master's IK early-cap-at-handle_msg1 entry, which
  describes IK-only behavior that does not apply on next; take master's
  OR-union mesh-size rewrite (next carries the OR-union code); restore
  the Tor connect_refused and MMP receiver-report entries that the
  Fixed-section consolidation would otherwise have dropped.
- lifecycle.rs: keep the mDNS/LAN handshake doc-comment as Noise XX
  (next unifies on XX), not master's XX-to-IK correction.

Quartet green on the merged tree: fmt, build, clippy -D warnings, and
cargo test --lib (1434 passed).
2026-06-14 18:24:32 +00:00
Johnathan Corgan 0e5f7c90f4 Merge branch 'master' into next 2026-06-13 23:16:48 +00:00
Johnathan Corgan d97c07fd8d Merge branch 'master' into next
Carries the Nym mixnet transport + single-container demo.

Forward-merge adaptation: the Nym end-to-end SOCKS5 send/recv test was
authored against master's IK FMP framing (msg1 wire size 114, version
nibble 0). next uses the XX handshake and FMP v1, so the TCP read path
rejected the test frame (UnknownVersion / HandshakeSizeMismatch),
timing the test out. Adapted build_msg1_frame to next's framing (wire
size 41, ver=1/phase=1 header byte). next-only; master keeps its IK
form.
2026-06-13 19:44:44 +00:00
Johnathan Corgan 65d44ab59a Merge master into next: fipstop SSH/tmux garble fix 2026-06-13 02:55:02 +00:00
Johnathan Corgan 0aada5c156 Merge master into next: demote rekey-abort logs to debug 2026-06-13 01:37:55 +00:00
Johnathan Corgan 2ea215f480 Merge branch 'master' into next: fipstop TUI overhaul and disconnect notify-peer fix 2026-06-12 23:53:54 +00:00
Johnathan Corgan 63b1821542 Merge branch 'master' into next: control read-snapshot plane
Forward-merge the off-loop control read-snapshot plane (every show_* read
query served from published snapshots; show_* removed from the rx_loop).

Adapt the per-entity MMP projection to next's MMP model, which no longer
carries a spin bit: drop the spin_bit_initiator row field, its tick-side
population, and the spin_bit_role output, so the off-loop show_mmp render
stays byte-identical to next's metrics-only on-loop handler.
2026-06-10 17:57:22 +00:00
Johnathan Corgan cefdc549f7 Merge branch 'master' into next 2026-06-10 02:43:47 +00:00
Johnathan Corgan 906e01b388 Merge branch 'master' into next 2026-06-10 00:05:23 +00:00
Johnathan Corgan 6aded6e365 Merge branch 'master' into next 2026-06-09 11:33:51 +00:00
Johnathan Corgan 8985ea66db Merge branch 'master' into next 2026-06-08 20:26:30 +00:00
Johnathan Corgan 878c40c91f Merge master into next (single-uplink-leaf tree-attachment re-push fix; macOS resolver ::1 fix) 2026-06-08 18:19:10 +00:00
Johnathan Corgan aed64f6d80 Merge master into next (MMP stale-report rejection; convergence-gate near-budget hold) 2026-06-08 00:19:37 +00:00
Johnathan Corgan 9ce4f2e908 Merge master into next (mesh-size OR-union estimate; log-level hygiene)
Brings the OR-union mesh-size estimator fix and the per-peer / capacity-cap
log-level demotions forward to next.

Conflict resolution: kept next's XX handshake structure and re-applied the
intent at next's own sites. compute_mesh_size auto-merged (next already
carries the config() accessor). The K-bit cutover promotion log demotion
applied to next's richer index-bearing form in encrypted.rs.

master's log changes lived in handle_msg1's IK-only identity/promotion
blocks, which have no XX equivalent there (next defers identity and
promotion to handle_msg3). next's equivalents were demoted in place:
"Connection promoted to active peer" and "Peer restart detected (epoch
mismatch)" info -> debug, and the duplicate "Inbound peer promoted to
active" removed. The two XX-only restart variants ("during FMP rekey" and
"during promotion") were also demoted info -> debug so the restart-detected
class is uniformly at debug on next.
2026-06-06 19:32:44 +00:00
Johnathan Corgan a3b9954797 Merge master into next (libclang build-prereq doc + transport mutex-poison recovery)
# Conflicts:
#	src/transport/ble/discovery.rs
#	src/transport/ethernet/discovery.rs
2026-06-06 13:40:28 +00:00
Johnathan Corgan 84c690cecd docs: reconcile the next changelog with superseded XX-line work
Two next-line entries had been left describing states that a later commit
reverted. Collapse the XX max_peers early-gate add/remove churn into its
net final state: on the XX handshake an over-cap inbound connection is
rejected solely by the late promotion check (no early gate), logged at
debug. Replace the stale "rekey jitter disabled on next" note with the
actual state: jitter is re-enabled (REKEY_JITTER_SECS = 15) and the three
XX rekey-path divergence defects that had blocked it are fixed
(authenticated-decrypt cutover instead of bare K-bit, msg3 retransmission
with per-link rekey serialization, and a jitter-aware handle_msg3
session-age partition). Merged-in IK-line entries are left untouched.
2026-06-05 21:44:37 +00:00
Johnathan Corgan 95b475d59b Merge branch 'master' into next 2026-06-05 21:30:14 +00:00
Johnathan Corgan 1c28c2a736 node: enforce the inbound max_peers cap solely at promotion on XX
At the inbound peer cap the XX FMP handshake had two cap checks: an early
gate in handle_msg3 and the late promote_connection check. On XX the
peer's identity is not known until the third handshake message, by which
point all three messages have already crossed the wire, so the early gate
saved no wire bytes and governed exactly the same net-new-peer set as the
late check (known and pending-outbound peers return earlier via the
cross-connection paths). Remove the early gate and rely on the promotion
check, and log the resulting MaxPeersExceeded rejection at debug instead
of warn so a cap'd node under sustained inbound pressure does not emit
WARN spam for expected policy rejections.

Re-enable the two previously-ignored inbound-cap unit tests, rewritten to
drive a full XX three-message handshake and assert the path-agnostic
invariant (no promotion over cap, peer count unchanged, no
connection/link/index leak) plus the known-peer reconnect bypass.

Update the admission-cap integration suite's enforcement-event
discriminator to match: drop the removed early-gate string and count the
new debug-level promotion-cap rejection.
2026-06-05 18:52:37 +00:00
Johnathan Corgan 66db3e14a7 Merge branch 'master' into next 2026-06-05 17:17:14 +00:00
Johnathan Corgan 2bc3a505f6 Merge branch 'master' into next 2026-06-05 04:47:00 +00:00
Johnathan Corgan 97f496afc4 Merge master into next (FMP cutover authenticate-before-promote; next already carries the XX form, no code change) 2026-06-04 21:44:36 +00:00
Johnathan Corgan 40c06b28e5 Merge master into next: FMP rekey msg1 resend cap + rekey-aware link-dead heartbeat (with next msg3-budget suppression clause) 2026-06-04 18:52:30 +00:00
Johnathan Corgan ea3e7f8c21 fmp: fix jitter × XX rekey session divergence, re-enable rekey jitter
Re-enables the rekey timer jitter on the XX FMP rekey path
(REKEY_JITTER_SECS 0 -> 15), which had been disabled because it produced
reproducible post-rekey routing loss (~50% Phase-5 ping failure) with no
crypto errors. The failures were session divergence: under jitter the two
directions of a link rekey close together in time, and three distinct
defects in the FMP rekey state machine could leave the two endpoints
committed to different Noise sessions, starving the receiver until the 30s
heartbeat dead-timer tore the link down (tree parent loss -> routing
failure). All three are fixed here.

1. Promote on authenticated decrypt, not the bare K-bit. The K-bit-flip
   handler promoted whatever pending session existed the instant the header
   bit flipped; under interleaved rekeys that could be a stale pending from
   an earlier epoch. Trial-decrypt the inbound frame against the pending
   session and promote only if it authenticates, mirroring the FSP cutover
   discipline; deliver that plaintext through the canonical path and leave
   the pending untouched otherwise.

2. Retransmit FMP rekey msg3 until confirmed. FMP sent msg3 once; a lost
   datagram left the responder without the new session. Retain the msg3
   payload and resend over the existing link until a peer frame
   authenticates against the pending or post-cutover current session,
   abandoning after the configured handshake-resend budget (mirrors FSP).
   Also serialize per-link rekeys: do not start a new rekey while one awaits
   cutover or is still retransmitting msg3.

3. Partition the handle_msg3 paths by rekey age. An inbound msg3 on a
   different link took the initial-handshake cross-connection tie-breaker
   when the session was under a fixed 30s old, otherwise the rekey
   responder. A rekey resets the session-age clock, so under jitter a
   rekey-aged session is frequently under 30s and its concurrent rekey msg3
   was swallowed by the cross-connection branch, which discarded the peer's
   rekey session with no pending slot while the peer cut over to it anyway.
   Bound the cross-connection branch by the same jitter-aware age floor the
   responder uses, so the two paths partition with no overlap.

Verified at jitter=15: rekey integration suite 70/70 across repeated runs
locally and on GitHub CI, rekey-accept-off 71/71, rekey-outbound-only
75/75; lib 1369/0, clippy and fmt clean. At zero jitter the acceptance
floor equals the previous 30s constant, so default-cadence behavior is
unchanged.
2026-06-03 13:43:11 +00:00
Johnathan Corgan 3bf49de01f Merge sole-store context change into the next-side
Bring the immutable-state single-store change onto the Noise XX line.
The shared NodeContext is now the sole store; this merge applies the
next-only adaptations the master-side change couldn't carry:

- Remove node_profile from the Node struct (next-only field) so it lives
  solely in NodeContext; migrate its readers (tree/bloom/discovery/
  handshake negotiation) onto the node_profile() accessor. The two FMP
  negotiation sites hoist node_profile() into a local to avoid borrowing
  &self while a connection is mutably borrowed.
- leaf_only sets both is_leaf_only and node_profile via the context swap.
- Preserve the XX handshake/rekey structure (no identity-in-msg1; XX/XK
  initiator/responder constructors) while applying the startup_epoch()
  accessor migration.
- Tests: route profile selection through a make_test_node_with_profile
  helper (profile is immutable, set via Config flags) instead of poking
  the removed field.

cargo test --lib 1369/0; clippy -D warnings and release build clean.
2026-06-02 17:30:15 +00:00
Johnathan Corgan e181df0ac4 node: extract immutable state into a shared context and atomic metric registry
Store node counters in an atomic metric registry read through &self, and
introduce a shared NodeContext bundle holding the effectively-immutable
fields (config, identity, startup epoch, node profile, capability limits).
Source the immutable config and identity reads across the receive hot
path, the XX handshake/session/rekey state machines, and the discovery,
tree, bloom, retry, and lifecycle modules through the context accessors.
Includes the bloom delta/full/NACK/resize and byte-total counters in the
registry. The Node fields and the context are rebuilt in lockstep at every
mutation site.
2026-06-02 13:05:03 +00:00
Johnathan Corgan 88e48fdc68 Merge the fast-path refactor into the next line 2026-06-02 13:05:03 +00:00
Johnathan Corgan 3b27a1604d Merge branch 'master' into next 2026-05-30 20:03:13 +00:00
Johnathan Corgan 1914b20485 Merge branch 'next' into refactor-hotpath-next 2026-05-30 02:42:32 +00:00
Johnathan Corgan bbd4de9e76 Merge master into refactor-hotpath-next
Bring master's runtime peer-list refresh, opt-in mDNS LAN discovery, and
the receive-path reject-reason / reloadable-config refactor into the XX
handshake integration branch. The peer-restart-epoch detection and
stale-FSP-session teardown authored against Noise IK are re-authored onto
the Noise XX rekey path: complete_rekey_msg2 now also surfaces the remote
startup epoch, and the stale session is cleared once the XX rekey
completes (after msg3 is sent). Connect-budget and path-refresh work is
threaded through the XX anonymous-discovery branch.
2026-05-30 02:42:01 +00:00
Johnathan Corgan fccd760ad7 Merge branch 'refactor-hotpath' into refactor-hotpath-next
# Conflicts:
#	src/node/tests/acl.rs
2026-05-29 02:38:26 +00:00
Johnathan Corgan a965b1ec67 Merge branch 'refactor-hotpath' into refactor-hotpath-next 2026-05-29 01:23:41 +00:00
Johnathan Corgan b643eb3a89 testing: port admission-cap integration test to the Noise XX handshake
The admission-cap suite asserted IK wire sizes (inbound Msg1 len 84,
outbound Msg2 len 104) to verify the inbound max_peers cap. On the XX
handshake those sizes differ and Msg2 is always sent before identity is
known, so the IK discriminator matched nothing and the suite failed
despite the cap working correctly.

Replace the wire-size discriminator with a protocol-agnostic cap-holds
invariant: each denied peer must keep re-initiating handshakes
(sustained inbound, size-agnostic), no denied peer may be promoted to an
active session, the node must log cap-enforcement events, and the capped
node must hold exactly max_peers. Raise node-c's handshake logging to
debug in the mesh profile so the early-gate drop is visible alongside the
late-check rejection.

Also record in the changelog that for peers the node also dials, the cap
is enforced by the retained late check rather than the early msg3 drop.
2026-05-29 00:20:29 +00:00
Johnathan Corgan e1ae261eb2 node: wire the next-side XX handshake and rekey rejection sites
Adapt the typed RejectReason coverage to the Noise XX handshake: wire the
msg1/msg2/msg3 state-machine rejection sites in handlers/handshake.rs and
the rekey-initiator outbound sites in handlers/rekey.rs, and add the
XX-specific HandshakeStats counters. The shared RejectReason scaffold and
the branch-agnostic clusters come from the merged refactor-hotpath work;
this commit carries only the next-side delta.
2026-05-28 21:21:45 +00:00
Johnathan Corgan 939e2cbd4f Merge branch 'refactor-hotpath' into refactor-hotpath-next
# Conflicts:
#	src/node/bloom.rs
#	src/node/handlers/handshake.rs
2026-05-28 21:21:12 +00:00
Johnathan Corgan 726461c193 node/tests: reword handshake regression-test doc-comments
Drop an internal tracker reference from the doc-comments of the
should_admit_msg1 and udp.outbound_only rekey regression tests; the
prose now describes the scenario directly.
2026-05-28 21:13:26 +00:00
Johnathan Corgan cc417a7bef node: free the connection index when XX msg3 processing fails
The msg3-processing-failure cleanup read our_index from the connection
after removing it from the connections map, so the lookup always returned
None and the allocated index was never released, slowly leaking index
slots across failed XX handshakes. Capture the index before the remove,
matching the idiom used in the other cleanup paths in this file.
2026-05-28 21:13:26 +00:00
Johnathan Corgan 8e72326ec2 node/tests: mark leaf-nonrouting handshake test as parallel-load flake-class
The XX leaf-nonrouting rejection test shares the localhost-UDP rcvbuf
overflow failure mode of the other large-network tests under parallel
cargo test --lib load. Mark it #[ignore] for the default run; it stays
runnable with --ignored or --test-threads=1.
2026-05-28 20:19:50 +00:00
Johnathan Corgan a6e4481c1f Merge branch 'master' into next 2026-05-28 20:19:19 +00:00
Johnathan Corgan 0a84a25db1 Merge master into next (inbound admission cap silent-drop; XX-adapted gate at handle_msg3)
Carries the inbound max_peers admission-cap silent-drop work from
master across the IK→XX structural boundary.

On master (Noise IK), the gate fires at handle_msg1 after the peer's
identity is extracted from the IK msg1 payload, before Msg2 is built
and sent. Wire savings of Msg2 (~104 B) plus the responder crypto
per cap-denied attempt motivate the gate placement.

On next (Noise XX), peer identity is not learned until msg3, by
which point Msg1, Msg2, and Msg3 have all crossed the wire. The IK
gate's wire-savings rationale does not apply on XX. The equivalent
gate is placed in handle_msg3 after the peer's static-key + signature
verification (identity now known) and before promote_connection
(ActivePeer construction, peers_by_index insert, link transition).
The value on XX is local CPU / allocation savings and cleaner peer-
side semantics: no fake-promotion whose subsequent data frames fail
decryption on this side, replaced by a silent drop the peer's
existing auto-reconnect backoff handles cleanly.

Bypass logic is the same as the IK version: known-active peers
(reconnect / cross-connection / restart) and pending-outbound peers
skip the cap so admitting them doesn't grow peers.len(). The late
cap check inside promote_connection is intentionally retained as
defense-in-depth.

Cleanup ordering for the gate's silent-drop path corrects a latent
issue in the adjacent msg3-processing-failure path: our_index is
captured before the connection is removed so the allocator slot is
actually freed (the pre-existing path's get-after-remove returns
None, leaking the index).

The two IK-shaped unit tests at handle_msg1 are marked #[ignore] on
this branch with reason. Their wire-bytes discriminator ("Msg2 must
NOT be sent at cap") is structurally false on XX, and the XX-shaped
equivalents need full three-message-handshake test scaffolding that
isn't authored here. XX-adapted unit + integration test coverage for
the handle_msg3 gate is a follow-up.

CHANGELOG [Unreleased] entry adjusted to describe the handle_msg3
placement on this branch (preserving the master-side framing on the
mainline-merge half of the entry would have been misleading on this
branch).

Three conflicts auto-resolved: the structural handshake.rs span
(~253 lines, IK identity-tied logic discarded on this branch's XX
msg1 path), and three textual ci-local.sh conflicts (admission-cap
suite list / dispatch added alongside the existing mixed-profile
suite list / dispatch).
2026-05-27 17:01:59 +00:00
Johnathan Corgan 05124367db Merge master into next (outbound admission gate + mesh-size parent skip) 2026-05-26 17:31:47 +00:00
Johnathan Corgan df342f268a Merge master into next (periodic TreeAnnounce re-broadcast + harness) 2026-05-26 15:11:40 +00:00
Johnathan Corgan 61ac4c6a32 Merge master into next (cross-conn-won decrypt-worker unregister fix) 2026-05-25 17:23:04 +00:00
Johnathan Corgan 49735980b8 Merge master into next (cross-init tiebreaker + tick-arm connect-on-send fix) 2026-05-25 05:13:00 +00:00
Johnathan Corgan 09df743485 Merge master into next (interop harness, mesh-lab harness, forwarding debug log) 2026-05-23 14:48:46 +00:00
Johnathan Corgan 60c42daa82 Merge master into next (FSP rekey overlapping-epoch, drain-erase fix) 2026-05-23 02:06:04 +00:00
Johnathan Corgan d6729086de Merge master into next (data-plane perf overhaul, packaging fixes)
Forward-merges PR #91 (off-task encrypt/decrypt worker pools, GSO,
connected UDP), the macOS #102 package-integrity fix, and the AUR
#98 fips-dns fix from master.

Six textual conflicts resolved: wire/noise imports, the K-bit-flip
and rekey-cutover handlers, ActivePeer fields, and an advert test
fixture. One semantic conflict: PR #91's worker-pool code on master
referenced FLAG_SP and MmpPeerState.spin_bit, which next removed in
4aded9a2 (spin bit superseded by the MMP receiver-report timestamp
echo; FMP flags bit 2 reclaimed). The worker acceleration and its
fmp_flags round-trip test are kept; the spin-bit references are
dropped to honor next's wire-format change.
2026-05-21 01:54:47 +00:00
Johnathan Corgan d1b2f31a3b Merge master into next (rekey-test ping retry, platform-warning cleanup, CHANGELOG backfills)
# Conflicts:
#	CHANGELOG.md
2026-05-18 01:47:51 +00:00
Johnathan Corgan c80b3eb0e3 Merge master into next (coord cache surgical invalidation)
# Conflicts:
#	CHANGELOG.md
2026-05-17 01:12:44 +00:00
Johnathan Corgan 6bb17be8b8 rekey: disable timer jitter on next pending XX-path investigation
Set `REKEY_JITTER_SECS` to 0 on next. The symmetric per-session
jitter mechanism added on maint/master works cleanly on the IK
FMP rekey path on those branches, but on next's XX FMP rekey
path it produces reproducible post-cutover routing-convergence
failures: ~50% Phase 5 per-pair-ping loss in the rekey integration
suite across multiple runs, with all crypto, transport, and
link-state log assertions still green (no decrypt failures, no
panics, no link teardowns, no rekey msg2 failures, no FSP
decryption failures during rekey).

The XX rekey path itself is sound — pre-merge it had been passing
18/18 across six consecutive CI runs. Isolation experiment
confirms the trigger: with REKEY_JITTER_SECS=0, the same suite
on next passes 70/70 cleanly with FMP cutover counts (18-21)
in line with master's baseline (24). With the constant at 15
and the test suite's 35s nominal rekey interval, FMP cutover
counts climb to 27-40 and Phase 5 fails consistently.

Two attempted fixes to absorb the jitter-induced state churn on
the XX path (post-msg3 addr_to_link restore; optimistic dampening
at msg1 receipt) were tried and reverted — neither restored
Phase 5 reliability. Diagnosis of the underlying XX cutover
state-cleanup behaviour that fails to absorb variable-interval
rekeys is deferred. The const and surrounding draw/redraw
machinery (peer::active::ActivePeer, node::session::SessionEntry,
draw_rekey_jitter at session.rs:20, redraws at the four cutover
sites) are kept in place; flipping the const back to a positive
value is the re-enable path once the investigation lands.

CHANGELOG entry under `### Changed`.
2026-05-14 21:16:43 +00:00
Johnathan Corgan a014296639 node/tests: adapt rekey-jitter tests to XX HandshakeState signature
The two rekey-jitter unit tests added on the IK-rekey branches
construct a HandshakeState via:

  HandshakeState::new_initiator(keypair, remote_pubkey)

On next, the XX rewrite changed the initiator constructor to take
only the local keypair:

  HandshakeState::new_initiator(keypair)

XX does not learn the responder's static pubkey until msg2, so
the IK-shaped signature does not apply. The merge from master
brought the test bodies verbatim; this drops the second argument
in both call sites so the tests compile against the XX API. The
test semantics (rekey-jitter range and mean checks) are unchanged
and orthogonal to the handshake-pattern choice.

Same shape as 8a6477b adapting the bootstrap-handoff tests to
XX's three-way handshake.
2026-05-14 18:59:14 +00:00
Johnathan Corgan 74b08b9b6f Merge master into next (rekey jitter, Phase 5 settle, acl-allowlist, CI concurrency)
# Conflicts:
#	CHANGELOG.md
2026-05-14 18:54:05 +00:00
Johnathan Corgan 418c9d61e0 ci: add mixed-profile suite to integration matrix on next
The `mixed-profile` integration suite (Full / NonRouting / Leaf
node-role mix introduced by the forklift-upgrade work) is wired
into `testing/ci-local.sh` but was silently absent from the GitHub
Actions integration matrix on next. Regressions to node-role
behavior could land via GitHub CI without the suite running.

Adds a matrix entry plus the matching step block (mirroring the
rekey pattern: generate configs + inject config + compose up +
test + log-collect on failure + compose down always). master
correctly omits the suite from both ci-local and ci.yml since
mixed-profile is forklift-only, so this change never forward-merges
to master.
2026-05-14 15:58:28 +00:00
Johnathan Corgan 358a06be06 Merge master into next (MTU, rekey baseline, CONTRIBUTING)
# Conflicts:
#	CHANGELOG.md
2026-05-14 00:02:08 +00:00
Johnathan Corgan 183ad148de Fix XX rekey dual-init race: tie-break also when pending_new_session set
The dual-initiation tie-breaker in the rekey arm of handle_msg3 (and
the FSP analogue in handle_session_setup) only fired when
rekey_in_progress was true. With Noise IK (one-message rekey on
master/maint) that is sufficient: msg1 IS the rekey, so both sides
being mid-handshake is the only state where the race can fire.

With Noise XX (three-message rekey on next), set_pending_session runs
when the initiator has processed msg2 and sent msg3, and that call
clears rekey_in_progress. Both sides' set_pending_session can run
before either peer's msg3 has landed. Each side then receives the
peer's msg3 in the post-pending state with rekey_in_progress=false,
falls into the "drop because pending_new_session is set" guard, and
discards the peer's handshake. Each side commits its own initiator
session at K-bit cutover. The two sessions use different Noise key
material, so the link breaks asymmetrically after cutover.

Unify both checks into a single tie-breaker that fires when either
rekey_in_progress() OR pending_new_session().is_some() is true. The
existing smaller-NodeAddr rule applies uniformly; abandon_rekey()
already clears both states and returns whichever index needs
freeing.

Mirrored to the FSP rekey msg1 path in handle_session_setup for the
same race shape.

Logging:

- The previously-silent drop in the rekey arm of handle_msg3 logs at
  info as a tie-break decision rather than as a silent drop.
  Pending_new_session is added as a log field on the tie-break
  win/lose lines so a reader can distinguish which of the two race
  states fired.
- Cutover-complete and K-bit-flip log lines gained our_addr and
  their_addr fields so the two endpoints' logs of the same handshake
  can be correlated.
- "Pending session set, awaiting K-bit cutover" lines remain at
  debug per the existing convention of info-for-cutover-completion-
  only.

Verification: under the un-fixed handler, both sides log
"rekey-msg3 drop: pending_new_session already set" within 242
microseconds of each other, with rekey_in_progress=false and
different pending session indices. Failure pattern matches: six of
twenty pairs FAILED post-rekey, all involving the single-peer node.
Six consecutive post-fix CI attempts on the same test green across
rekey, rekey-accept-off, and rekey-outbound-only suites.
2026-05-13 23:58:10 +00:00
Johnathan Corgan 195b3bad01 Merge master into next (AUR publish fix) 2026-05-12 17:57:25 +00:00
Johnathan Corgan 212c45bd37 Merge master into next after v0.4.0-dev open
Marker merge to record the master dev-line (now v0.4.0-dev, with
maint's v0.3.1-dev open already incorporated) as known on next
without taking master's tree (next is on v0.5.0-dev). Future
forward-merges from master to next land cleanly on top of this base.
2026-05-12 13:47:29 +00:00
Johnathan Corgan b0e9627a77 chore: retarget next from v0.4.0-dev to v0.5.0-dev
Master is now the v0.4.0-dev line; next moves up to v0.5.0-dev for
the wire-format-breaking work staged here.

- Cargo: 0.4.0-dev → 0.5.0-dev
- CHANGELOG Breaking-section header retargeted to v0.5.0; baseline
  bumped from v0.2.x to v0.3.x peers (master is now the v0.3.1-dev
  patch line and v0.4.0-dev minor line, so v0.5.0 breaks against
  v0.3.x, not v0.2.x)
- README: badge v0.4.0--dev → v0.5.0--dev, three Status & Roadmap
  prose references retargeted
- docs/reference/cli-fips.md: example version 0.4.0-dev → 0.5.0-dev
2026-05-12 13:41:21 +00:00
Johnathan Corgan 7865557f8a Merge master into next after v0.3.0 release
Brings v0.3.0 release content forward into the next-branch
development line, preserves next's own version state, and updates
operator-facing version references to v0.4.0-dev.

Kept from master:
- docs/releases/release-notes-v0.2.1.md and -v0.3.0.md (cumulative
  archive of shipped releases)
- RELEASE-NOTES.md root mirror at v0.3.0 (tracks the most recent
  shipped release; will be replaced when the v0.4.0 release cycle
  begins on this line)
- CHANGELOG entries: [0.3.0] and [0.2.1] sections inserted under
  the existing Breaking and [Unreleased] sections
- All code, config, test, and documentation updates from master
  (openwrt yaml resync, doc-config IP placeholders, etc.)

Kept from next (resolved against master's release-prep changes):
- Cargo.toml / Cargo.lock at 0.4.0-dev (next's package version)
- CHANGELOG ## Breaking section (next-specific v0.4.0 wire-format
  breaking work) and the empty ## [Unreleased] block for future
  v0.4.0 non-breaking work

Updated to v0.4.0-dev for consistency with Cargo.toml:
- README badge (v0.3.0--dev -> v0.4.0--dev) and status-section
  prose, rewritten to describe v0.4.0 wire-format-breaking work
  on this branch (Noise XX unification, FMP node profiles,
  slimmer MMP, extensible bloom-filter encoding) instead of the
  v0.3.0 testing-and-polishing narrative that applied while
  v0.3.0 was unreleased
- docs/reference/cli-fips.md example version string
2026-05-11 19:00:48 +00:00
Johnathan Corgan 7807516b8d Merge branch 'master' into next 2026-05-10 22:04:29 +00:00
Johnathan Corgan e5071d74fb Merge branch 'master' into next 2026-05-10 17:02:33 +00:00
Johnathan Corgan 831ce5f741 Merge branch 'master' into next 2026-05-10 02:16:49 +00:00
Johnathan Corgan dd65951244 Merge branch 'master' into next
# Conflicts:
#	src/node/tests/bootstrap.rs
2026-05-09 23:22:32 +00:00
Johnathan Corgan c22b50c147 Merge branch 'master' into next 2026-05-09 18:45:05 +00:00
Johnathan Corgan 19b921ec5d Merge branch 'master' into next 2026-05-08 21:42:16 +00:00
Johnathan Corgan 45a8a4cd9b Merge branch 'master' into next
Forward-merge of 12 master commits past the previous merge
(823b830, master @ 18019bb): dep-audit bumps (rand,
clap, tun, rtnetlink, windows-service, plus the bump-safe
lockfile batch), bloom-storm chaos scenario, control-socket
resolver consolidation, gateway dns.listen default change,
OpenWrt ipk README refresh, gateway tutorial review, Ethernet
MTU rustdoc fix, dead session-variant drop, CHANGELOG prep.

Conflict resolution:

- CHANGELOG.md: both bullets kept under [Unreleased] / Fixed.
  Master's spanning-tree internal-path-propagation fix precedes
  next's tree-ancestry-test determinism entry and the
  responder-Disconnect XX-handshake entry.
- src/protocol/session.rs: kept next's SessionSetup/SessionAck
  variants and rustdoc. Master's drop of those variants suits
  v0.3.0's FSP phase-byte dispatch but is undone by next's
  v0.4.0 wire format, which retains the variants and uses the
  inner msg_type byte for handshake identification.
- src/transport/ethernet/mod.rs: kept next's "interface MTU - 4"
  comment. Master corrected the v0.3.0 3-byte rustdoc; next
  redesigned the framing to a 4-byte header (type/flags/length)
  for shared-media beacons, so master's correction does not
  apply to next's format.

Auto-merged cleanly: Cargo.toml (next's 0.4.0-dev + the new dep
pins from master), Cargo.lock, all gateway docs,
docs/reference/configuration.md, packaging files,
.github/workflows/ci.yml, testing/chaos/sim/* and
testing/ci-local.sh (bloom-storm additions), src/config/*.

Local verification: cargo build --release, cargo test (1252
passed, 4 ignored), cargo clippy -D warnings, cargo fmt --check
all green.
2026-05-08 18:47:13 +00:00
Johnathan Corgan 823b830289 Merge branch 'master' into next
Forward-merge of the docs-overhaul squash (5abf9a9) and top-level
README rewrite (18019bb). Conflict resolution:

- README.md: master's rewritten feature lists adopted, with the encryption
  bullets reflecting next's two-layer Noise XX (replacing the IK/XK pair
  master describes for v0.3.0).
- 6 design/reference markdown files (fips-bloom-filters.md, fips-mesh-layer.md,
  fips-mesh-operation.md, fips-session-layer.md, fips-transport-layer.md,
  reference/wire-formats.md): master's reorg taken, next's protocol details
  preserved (XX handshake naming, bloom v2 RLE/delta wire format,
  v2 LookupRequest sizing).
- fips-intro.md modify/delete: accepted master's split into
  fips-architecture.md / fips-concepts.md / fips-prior-work.md, then
  re-applied next's IK/XK -> XX transition and spin-bit removal across
  the relevant split files. Same pass swept docs/reference/security.md,
  docs/design/fips-mmp.md, docs/design/fips-security.md,
  docs/design/fips-nostr-discovery.md,
  docs/design/port-advertisement-and-nat-traversal.md,
  docs/how-to/enable-nostr-discovery.md, and the affected tutorials so
  no IK/XK or spin-bit prose remains in current-state docs.
- Diagram path conflicts: noise-ik-msg{1,2}.svg removed (IK is gone);
  noise-xx-msg{1,2,3}.svg moved from docs/design/diagrams/ to
  docs/reference/diagrams/ to match master's diagram reorg. The
  wire-formats.md image references resolve correctly to the new path.

Local verification: cargo build --release, cargo test (1265 passed,
4 ignored), cargo clippy -D warnings, cargo fmt --check all green.
2026-05-08 13:45:04 +00:00
Johnathan Corgan 933e9ee6ee Merge branch 'master' into next
# Conflicts:
#	src/node/bloom.rs
2026-05-07 14:31:11 +00:00
Johnathan Corgan fb834dc2ca nostr: separate next-branch advert namespace by default
Bump the default Nostr-discovery advert namespace from
`fips-overlay-v1` to `fips-overlay-v1-next` on next. Master
continues to publish under `fips-overlay-v1`.

Background: next runs FMP-v1 (Noise XX, msg1 33 bytes) which is
wire-incompatible with master's FMP-v0. Until now both branches
defaulted to the same Nostr advert namespace, so a stock
next-branch daemon's open-discovery sweep would happily pick up
master peers' adverts (and vice versa), succeed at the UDP punch,
adopt the socket, and fail every FMP handshake at the version-gate.
The per-peer-mismatch cooldown introduced on master is the safety
net for any case that slips past this default; the namespace
separation is the structural answer.

Three sites updated:

- `src/discovery/nostr/types.rs` `ADVERT_IDENTIFIER` const
  documents why the value is branch-specific.
- `src/config/node.rs` `default_app()` matches.
- `src/discovery/nostr/tests.rs` and the
  `testing/nat/scripts/nostr-relay-test.sh` malformed-advert
  fixture publish under the new namespace so test harnesses see
  the same adverts a real daemon would.

Operators who need cross-branch discovery during a coordinated
rolling upgrade can override `node.discovery.nostr.app` in
fips.yaml back to `fips-overlay-v1`.
2026-05-06 15:09:16 +00:00
Johnathan Corgan acb750c0d2 Merge branch 'master' into next 2026-05-06 15:03:27 +00:00
Johnathan Corgan 5a80582b0b Merge branch 'master' into next 2026-05-05 14:49:51 +00:00
Johnathan Corgan 60a4353661 Merge branch 'master' into next 2026-05-04 14:02:09 +00:00
Johnathan Corgan 3cbb667655 Regenerate show_bloom snapshot for next-branch bloom v2 stats
The control-query show_bloom snapshot, baselined on master, mismatches
the next-branch output because the bloom filter v2 work on next added
six new stats fields (deltas_sent, full_sends, nacks_received,
nacks_sent, size_changes, total_compressed_bytes, total_raw_bytes)
and dropped non_v1. The snapshot test correctly flagged this as
schema drift.

The drift is intentional (next's bloom v2 is a deliberate schema
expansion, not a regression), so the right action per the test's own
guidance is to delete the fixture and let the test regenerate it
against next's actual output.

No production-code change.
2026-05-03 23:51:32 +00:00
Johnathan Corgan 4457587e5f Merge branch 'master' into next
# Conflicts:
#	src/node/tests/discovery.rs
#	testing/ci-local.sh
2026-05-03 23:47:16 +00:00
Johnathan Corgan 1c8b8d55e8 Merge branch 'master' into next 2026-05-03 12:32:49 +00:00
Johnathan Corgan b02eaf2c5b Merge branch 'master' into next
Forward-merges the squashed per-destination TCP MSS clamping work
(master `ae60743`) into next.

Auto-merge of source files clean. Two pre-existing test assertions
on the next-side discovery tests were updated as part of merge
resolution to reflect the target-edge MTU fold introduced by the
merged-in commit:

- test_transit_forwards_when_mtu_sufficient: 1400 → 1280 = min(target-edge 1280, transit 1400)
- test_response_path_mtu_four_node_chain: 1350 → 1280 = min(target-edge 1280, transits 1350+1500)

Resulting next tree is bit-for-bit identical to the pre-squash next
state previously verified by full local CI sweep (29 + 1 next-only
suites pass on `6a8b519` in 23m 41s).
2026-05-02 17:39:26 +00:00
Johnathan Corgan d457b6d5f6 Merge branch 'master' into next 2026-05-02 02:45:10 +00:00
Johnathan Corgan e7fc4f7d10 Merge branch 'master' into next 2026-05-02 02:14:36 +00:00
Johnathan Corgan 5317735a91 Merge branch 'master' into next
# Conflicts:
#	src/node/tests/handshake.rs
#	testing/ci-local.sh
2026-04-30 14:05:59 +00:00
Johnathan Corgan 9c35c7e55d Merge branch 'master' into next 2026-04-29 12:59:10 +00:00
Johnathan Corgan 15753cdc20 Resolve simultaneous-init cross-connection in XX handle_msg3
After the master→next merge brought PR #53's bootstrap-handoff socket
adoption onto next's XX three-message handshake, three integration jobs
(rekey, nat-cone, nat-lan) regressed: the FMP handshake completed but
post-handshake encrypted frames silently dropped — packets_recv stayed
at 0 and link-dead timeout fired every 30s.

Root cause: in symmetric bootstrap-handoff both sides initiate XX in
parallel, running two concurrent handshakes (each side's outbound paired
with the peer's inbound). Each side's handle_msg2 (immediate response
to its own outbound msg1) ran before the peer's outbound msg3 arrived;
peers.contains_key was false, so the "Normal path" promoted the outbound
connection. When the peer's msg3 then arrived, peers contained the peer
at the same epoch and the request fell through to the
"duplicate handshake from same epoch" branch, which tore down the inbound
link without applying the cross-connection tie-breaker. Both sides ended
up keeping their own outbound session whose Noise key material pairs with
the peer's discarded inbound, so each side's their_index pointed at the
peer's inbound session index that was never registered in peers_by_index.

handle_msg2 already has the symmetric handler for the inverse ordering
(msg3-then-msg2). Add the missing simultaneous-init handler in
handle_msg3: when the existing peer is on a different link from this
msg3's pending_inbound link and the session is fresh (<30s, so this
isn't a rekey), apply cross_connection_winner with this_is_outbound=false.
The larger-node side swaps to the inbound session via replace_session and
updates peers_by_index from outbound_idx to inbound_idx; the smaller-node
side keeps its outbound session and frees the inbound's allocated index.
Both sides converge on the same Noise session pair.

Verified: cargo test --lib (1144 passed), cargo fmt + clippy clean,
nat-cone + nat-lan + symmetric NAT scenarios pass, rekey integration
test 70/70 pairs across all phases.
2026-04-27 19:39:00 +00:00
Johnathan Corgan 8a6477b7ff Adapt bootstrap-handoff tests to XX three-way handshake
The two bootstrap-handoff tests were written against the IK two-message
handshake where peer promotion happens after msg2. Under the noise-XX
three-message handshake on this branch, identity is only revealed in
msg3, so peer promotion happens one message later: the responder learns
the initiator's identity in msg3 and only then promotes the peer.

`test_adopted_udp_traversal_completes_handshake`: Previous version ran
two `run_rx_loop` iterations (one per node) and asserted both peers
had been promoted. That works for IK but not XX: after node_a sends
msg1 from `adopt_established_traversal`, node_b's first rx_loop pass
generates msg2 (revealing node_b's identity to node_a), node_a's next
rx_loop pass generates msg3 (revealing node_a's identity to node_b),
and only then does node_b promote node_a. A third rx_loop pass on
node_b is needed.

Restructured the test to use direct `handle_msg{1,2,3}` dispatch via
`packet_rx.recv()` rather than `run_rx_loop` + select cancellation,
matching the pattern already used by
`test_third_peer_can_handshake_via_adopted_transport_socket`. This
sidesteps the issue that `run_rx_loop` does `packet_rx.take()` and a
cancelled future never returns it, so a second rx_loop call fails
with `NotStarted`.

`test_third_peer_can_handshake_via_adopted_transport_socket`: Added
explicit msg3 dispatch on both Alice/Bob and Bob/Colin sub-handshakes
so the responder side actually receives the initiator's identity
before the test asserts peer promotion.
2026-04-27 17:28:54 +00:00
Johnathan Corgan 5a5dbe36da Merge branch 'master' into next
# Conflicts:
#	src/node/lifecycle.rs
#	testing/static/scripts/rekey-test.sh
2026-04-27 16:10:11 +00:00
Johnathan Corgan 64c38b2e26 Merge branch 'master' into next 2026-04-22 03:43:20 +00:00
Johnathan Corgan 3edca4a84f Notify initiator when inbound ACL rejects a Noise XX handshake
Under Noise XX the responder only sees the initiator's static key
in msg3, so the inbound-handshake ACL check cannot fire until msg3
has already been processed. By then the initiator has received
msg2, passed its own outbound ACL check, and promoted its side of
the peering. Silently tearing down the responder's state left the
initiator as a "connected" peer with no traffic until the
link-dead timeout fired tens of seconds later; the acl-allowlist
integration test on the next branch timed out at the 5s
convergence check on blocked nodes.

The responder now sends an encrypted Disconnect on the
freshly-completed Noise session before tearing down. The initiator
decodes it via the existing handle_disconnect path and cleans up
the zombie peer within one RTT. DisconnectReason::Other is used
instead of SecurityViolation so the wire payload does not name the
ACL mechanism; the behavioural signature (explicit reject right
after handshake) is the only observable leak.

Supporting changes:

- Add send_encrypted_link_message_raw in node/mod.rs that operates
  on a raw NoiseSession + indices without going through self.peers,
  since the peer has not yet been promoted at the reject site.
- Declare the acl test module in node/tests/mod.rs. It had been
  orphaned since PR #50 so none of its tests ran; two of them no
  longer compiled against the current next branch. Fixed the two
  and replaced test_inbound_msg1_denied_by_acl (asserts IK-era
  behaviour that cannot happen under XX) with
  test_inbound_msg3_denied_triggers_disconnect, a full end-to-end
  UDP test covering both the responder cleanup and the initiator's
  Disconnect-driven cleanup.
2026-04-22 02:51:17 +00:00
Johnathan Corgan bcb87165fa Merge branch 'master' into next 2026-04-22 01:22:54 +00:00
Johnathan Corgan e5e6054229 Merge branch 'master' into next
# Conflicts:
#	src/bloom/filter.rs
#	src/node/bloom.rs
2026-04-21 19:42:35 +00:00
Johnathan Corgan 14d2a4f2df Merge branch 'master' into next
Integrates PR #50 (peer ACL enforcement) into the XX handshake
architecture. ACL enforcement points adapted for XX's deferred
identity learning: InboundHandshake check moves from handle_msg1
to handle_msg3 (responder learns initiator identity), OutboundHandshake
check remains in handle_msg2 (initiator learns responder identity).
Borrow scopes restructured to release connection borrows before
authorize_peer calls.
2026-04-16 06:11:03 +00:00
Johnathan Corgan fd9a8d132d Merge branch 'master' into next 2026-04-15 17:10:08 +00:00
Johnathan Corgan dab0ed4854 Merge branch 'master' into next
# Conflicts:
#	testing/ci-local.sh
2026-04-15 06:35:32 +00:00
Johnathan Corgan 2382c1438f Merge branch 'master' into next 2026-04-14 15:27:12 +00:00
Johnathan Corgan 942cc83878 Merge branch 'master' into next 2026-04-14 14:07:43 +00:00
Johnathan Corgan c2133a0f29 Merge branch 'master' into next 2026-04-14 11:45:59 +00:00
Johnathan Corgan 76a7147b17 Merge branch 'master' into next 2026-04-14 09:34:05 +00:00
Johnathan Corgan 9e93b0b224 Merge branch 'master' into next 2026-04-13 06:37:23 +00:00
Johnathan Corgan 9f539ce3c5 Apply cargo fmt to node module and handshake tests
Exposed by the rustfmt toolchain-pin fix now that rustfmt runs under
the pinned 1.94.1 toolchain in CI. Mechanical formatting changes:

- src/node/mod.rs: collapse EthernetTransport::new let-binding onto
  one line (fits within width limit)
- src/node/tests/handshake.rs: reorder imports (super::* after
  specific import) and expand two assert_eq! calls to multi-line form
2026-04-12 09:14:27 +00:00
Johnathan Corgan 5d898490ac Merge branch 'master' into next 2026-04-12 08:39:19 +00:00
Johnathan Corgan fe6f9aaa78 Merge branch 'master' into next
# Conflicts:
#	src/node/mod.rs
2026-04-11 17:51:24 +00:00
Johnathan Corgan 44667d9cc2 Update design docs and diagrams for Noise XX and forklift changes
Update CHANGELOG Breaking section with all v0.4.0 wire format
changes. Comprehensive update to fips-wire-formats.md (handshake,
MMP, FilterAnnounce, discovery, size tables).

Update fips-mesh-layer.md, fips-session-layer.md, fips-intro.md
for IK/XK→XX transition and spin bit removal. Document
disable_routing and leaf_only profile config in
fips-configuration.md. Update fips-transport-layer.md for 4-byte
beacon and unified header. Update fips-bloom-filters.md for delta
compression and variable sizing. Update fips-mesh-operation.md for
node profiles and discovery min_mtu.

Create/update 8 SVG diagrams (XX msg1/2/3, handshake flow, session
setup/ack/msg3, filter-announce, sender/receiver report). Update
README.md Noise pattern references.
2026-04-11 13:21:47 +00:00
Johnathan Corgan 10122d7d87 Production hardening: unwrap safety, 14 new tests, diagnostics
Harden unwrap() calls in handler hot paths (handshake, encrypted,
rekey, session handlers) with proper error propagation.

Add 14 tests: profile rejection (6), MMP forward-compat (4),
discovery min_mtu pruning (3), XX duplicate msg1 dedup (1).

Add NodeProfile Display impl with structured tracing for profile
mismatch logging. Expose bloom compression diagnostics
(total_compressed_bytes, total_raw_bytes) in control socket.

Add module documentation for XX identity timing, profile decision
tree, and bloom codec strategy.
2026-04-11 13:14:17 +00:00
Johnathan Corgan 8b5f1e349f Update .git-blame-ignore-revs with noise-xx format commit 2026-04-11 08:16:01 +00:00
Johnathan Corgan 34f840a77b Apply rustfmt to noise-xx-only code 2026-04-11 08:16:01 +00:00
Johnathan Corgan 59b7ea765b Fix stale discovery success regex in log analyzer
The log analysis matched "proof verified, caching route" but the
actual log message is "Discovery succeeded, proof verified, route
cached", causing discovery succeeded to show 0 in all sim runs.
2026-04-11 08:16:01 +00:00
Johnathan Corgan eb8479eb20 Heterogeneous bloom filters: delta compression, variable sizing, adaptive heuristic
Replace fixed 1KB bloom filters with variable-size filters (512B-32KB)
that adapt to each node's position in the spanning tree.

Core changes:
- Internal storage: Vec<u8> → Vec<u64> for word-level operations
- Delta compression: XOR diff with word-level RLE, sequence-based
  NACK protocol for full retransmit recovery
- Size conversion: fold (large→small) and duplicate (small→large)
  with auto-converting merge for mixed-size filter combination
- Native-size storage: peer filters stored at advertised size for
  full-resolution routing queries, converted only for outgoing filter
- Adaptive sizing: outgoing fill ratio drives step-up/step-down
  between size classes with hysteresis (20%/5% thresholds)
- Filter size decoupled from FMP negotiation: announced dynamically
  in filter updates, bit 7 and TLV field 1 removed from handshake

Wire format: FilterAnnounce gains flags byte (delta bit), base_seq
field, RLE-compressed payload. New FilterNack message (0x21) for
out-of-sequence delta recovery.
2026-04-11 08:16:01 +00:00
Johnathan Corgan 8162d3c237 Minimal shared-media beacons: 4-byte header, strip pubkey, remove BLE exchange
Redesign shared-media transport framing now that XX replaces IK:

- Ethernet: unified 4-byte header [type][flags][length:2 LE] for all
  frame types, effective MTU now if_mtu - 4
- Beacons: strip 32-byte pubkey (34 → 5 bytes), identity learned from
  XX handshake msg2/msg3 instead of beacon
- BLE: remove pre-handshake pubkey_exchange() and cross-probe
  tie-breaker, both unnecessary with XX
- Discovery: support anonymous connections (pubkey_hint: None) with
  address-based dedup for shared-media transports
- Post-handshake identity hooks in handle_msg2/msg3 for self-detection
  and future allow/deny list filtering (IDEA-0047)
2026-04-11 08:16:01 +00:00
Johnathan Corgan e06015dd43 Discovery wire format cleanup
Drop origin_coords from LookupRequest — unused since reverse-path
routing became primary. Saves 2+16*depth bytes per request.

Wire up min_mtu: populated from TUN MTU config (default 1280) at
origination. Transit nodes skip peers whose link MTU is below the
request's min_mtu requirement. path_mtu on LookupResponse was
already wired (transit min() applied).

Add TLV extension to LookupRequest (after min_mtu) and
LookupResponse (after proof). Uses same TlvEntry format as
negotiation. Transit nodes forward TLV bytes verbatim.
2026-04-11 08:16:01 +00:00
Johnathan Corgan 4aded9a238 Remove spin bit, slim down MMP reports with extensibility header
Delete SpinBitState, FLAG_SP (FMP bit 2), and FspInnerFlags.spin_bit.
Spin bit superseded by MMP receiver report timestamp echo for RTT.
Reclaims FMP flags bit 2 and FSP inner flags bit 0.

SenderReport: 48 -> 20 bytes. Three fields: interval_packets_sent,
interval_bytes_sent, cumulative_packets_sent.

ReceiverReport: 68 -> 54 bytes. Ten decision-driving and diagnostic
fields retained; removed max_burst_loss, mean_burst_loss,
interval_packets_recv, interval_bytes_recv.

Both report types use extensibility header: repurposed 3 reserved
bytes as [format_version:1][total_length:2 LE]. Decoders skip
unknown trailing bytes for forward compatibility. Session-layer
reports updated to match.
2026-04-11 08:16:01 +00:00
Johnathan Corgan f4278f1dd7 Add FMP node profile negotiation with mixed-profile integration tests
NodeProfile enum (Full/NonRouting/Leaf) with FMP feature bitfield:
bits 0-2 profile, bits 3-6 MMP wants/provides, bit 7 bloom filter
size negotiable. Bloom size TLV always sent with min=max=1KB.

Config mapping: leaf_only -> Leaf, disable_routing -> NonRouting.
Profile and agreed bloom size stored on PeerConnection and
ActivePeer. Handshake msg2/msg3 carry FMP negotiation payload
with profile validation and bloom size agreement.

MMP report sending gated by profile wants/provides. Parent
selection and routing constraints skip non-full peers. One-way
bloom filters for non-routing peers (F inserts N as dependent).
Leaf mode: single-peer enforcement, suppress tree announces and
discovery forwarding.

Mixed-profile integration test: A(Full) + B(Full) + C(NonRouting)
+ D(Leaf) with 9 connectivity assertions.
2026-04-11 08:16:01 +00:00
Johnathan Corgan 8357200b0e Remove IK/XK dead code, rename XX methods as sole pattern
- Delete PROTOCOL_NAME_IK, PROTOCOL_NAME_XK and all IK/XK methods
- Remove Ik/Xk variants from NoisePattern enum
- Rename XX methods to drop xx_ prefix (sole pattern)
- Rename HandshakeMessageType variants from NoiseIKMsg1/NoiseIKMsg2
  to Msg1/Msg2, add Msg3 variant (0x03) for XX 3-message flow
- Fix doc comments with correct XX message sizes and descriptions
- Fix stale SessionSetup/SessionAck handshake payload size comments
2026-04-11 08:16:01 +00:00
Johnathan Corgan ae0f791dbc Switch FSP handshake from Noise XK to XX
Replace the 3-message XK handshake with XX for FSP session establishment.
XX requires no prior knowledge of the peer's static key — the responder's
identity is revealed in msg2, the initiator's in msg3.

Key changes:
- session.rs: XX initiator/responder, post-handshake identity verification
  using x-only key comparison (parity-independent for npub compatibility),
  negotiation payload in msg2/msg3 (FSP version [0,0], features=0)
- Rekey: switched from XK to XX for FSP rekey handshake
- timeout.rs: suppress msg1 resends when target peer is already promoted,
  preventing cross-connection session mismatch from duplicate handshakes
- Test template: discovery backoff 3s and handshake timeout 10s for
  faster convergence in integration tests
- Integration test timeouts restored to 45s (ping) and 60s (rekey)

Squashed commits:
- Switch FSP handshake from Noise XK to XX
- Fix integration test convergence by reducing discovery backoff
- Fix cross-connection session mismatch from msg1 resend
- Fix FSP identity verification parity mismatch
2026-04-11 08:16:01 +00:00
Johnathan Corgan 179689d6f2 Switch FMP handshake from Noise IK to XX with version negotiation
Replace the 2-message IK handshake with a 3-message XX handshake for
FMP link establishment. XX requires no prior knowledge of the peer's
static key — both identities are revealed during the handshake
(responder in msg2, initiator in msg3). This is the foundation for
the forklift upgrade that enables rolling protocol upgrades.

Changes:
- Noise XX state machine alongside IK/XK (8 unit tests)
- Protocol negotiation payload codec: format byte, packed version
  min/max, 64-bit feature bitfield, TLV extensions (11 unit tests)
- FMP wire format version 0→1, msg3 header/builder, TCP stream framing
- FMP handshake switched to XX: PeerConnection 3-message flow,
  handle_msg1 simplified (no identity), handle_msg2 sends msg3 and
  promotes initiator, new handle_msg3 promotes responder with
  restart/rekey/cross-connection detection
- Rekey handshake switched to XX with negotiation payload hash chain
  fix (decrypt-and-discard in complete_rekey_msg2/msg3)
- Negotiation payload in msg2/msg3 (FMP version [1,1], features=0)
- Debug logging for handshake promotion paths
- Integration test convergence timeouts adjusted for extra round-trip

Squashed commits:
- Add Noise XX state machine alongside IK/XK
- Add protocol negotiation payload codec
- FMP wire format prep: version 1, msg3 header support
- Switch FMP handshake from Noise IK to XX
- Increase convergence timeouts for XX 3-message handshake
- Fix negotiation hash chain desync in rekey handshake
2026-04-11 08:16:01 +00:00
Johnathan Corgan 9ccaae5044 Create next branch for breaking changes (0.4.0-dev)
- Bump version to 0.4.0-dev
- Add Breaking section to CHANGELOG above Unreleased
2026-04-11 08:16:01 +00:00
135 changed files with 12512 additions and 7052 deletions
+3
View File
@@ -3,3 +3,6 @@
# rustfmt master-only code
e9da598f8ab13de5dea3a1496531d675af6a0b94
# rustfmt noise-xx-only code
fef3d011ab290743a13ae3f9b287ad4ebaa1713b
+163
View File
@@ -5,10 +5,105 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Breaking
Wire-format breaking changes for v0.6.0. All nodes in a mesh must
run the same major version — these changes are not backward compatible
with v0.4.x or earlier peers.
### Changed
#### Noise XX Handshake (FMP and FSP)
- FMP link handshake switched from Noise IK (2 messages) to Noise XX
(3 messages). Neither side requires prior knowledge of the peer's
static key. Responder identity revealed in msg2, initiator in msg3.
FMP wire version incremented to 1.
- FSP session handshake switched from Noise XK to Noise XX. Same
3-message flow with post-handshake identity verification using
x-only key comparison (parity-independent for npub compatibility).
- Protocol negotiation payload added to XX msg2/msg3 for both layers:
format byte, packed version min/max, 64-bit feature bitfield, and
forward-compatible TLV extensions. Enables rolling protocol upgrades
in future releases.
- FMP msg1 reduced from 106 to 33 bytes (ephemeral key only, no
encrypted static key or DH products).
#### FMP Node Profiles
- Node profile enum (Full, NonRouting, Leaf) advertised in FMP feature
bitfield bits 0-2. At least one side of a link must be Full.
- MMP report flow gated by wants/provides bits (bits 3-6): reports
only sent when the sender can provide and the receiver wants them.
- Non-routing nodes receive bloom filters (one-way) but do not send
them; the full peer inserts their identity as a leaf dependent.
- Leaf nodes enforce single-peer constraint with no tree, bloom, or
transit participation.
#### MMP Report Format
- Spin bit removed. Reclaims FMP flags bit 2 and FSP inner flags
bit 0. Superseded by MMP receiver report timestamp echo for RTT.
- SenderReport reduced from 48 to 20 bytes (3 fields: interval
packets/bytes sent, cumulative packets sent).
- ReceiverReport reduced from 68 to 54 bytes (10 fields retained;
removed max/mean burst loss and interval recv counters).
- Both report types use extensibility header: `[format_version:1]
[total_length:2 LE]` replacing reserved bytes. Decoders skip
unknown trailing bytes for forward compatibility.
#### Discovery Wire Format
- Dropped `origin_coords` from LookupRequest (saves 2 + 16*depth
bytes per request). Reverse-path routing via `recent_requests` is
the primary response mechanism.
- `min_mtu` field wired up in LookupRequest: transit nodes skip peers
whose link MTU is below the request's minimum.
- TLV extension section added to LookupRequest and LookupResponse
after fixed fields. Transit nodes forward TLV bytes verbatim.
#### Nostr-Discovery Advert Namespace
- Default Nostr-discovery advert namespace bumped from
`fips-overlay-v1` to `fips-overlay-v1-next` on the `next` branch.
Master continues to publish under `fips-overlay-v1`. Effect: a
stock `next`-branch daemon's open-discovery sweep no longer
discovers `master` peers, and vice versa — eliminating the
cross-version retraversal storms that arise when both sides
punched a UDP socket via Nostr but cannot complete an FMP
handshake. Operators who genuinely want cross-branch reach (e.g.
during a coordinated rolling upgrade) can override per-daemon
via `node.discovery.nostr.app` in `fips.yaml`. The
`protocol_mismatch_cooldown_secs` defense-in-depth on master is
the safety net against any peer that bypasses this default
(config override, future fork, static-peer config).
#### Shared-Media Beacons
- Ethernet frame header unified to 4 bytes `[type][flags][length:2
LE]` for all frame types. Beacons reduced from 34 to 5 bytes
(pubkey stripped — identity learned from XX handshake).
- BLE pre-handshake pubkey exchange removed. Cross-probe tie-breaker
eliminated (unnecessary with XX).
#### Bloom Filter Wire Format
- FilterAnnounce gains flags byte (delta bit), `base_seq` field, and
RLE-compressed payload for XOR-diff delta compression.
- New FilterNack message type (0x21) for out-of-sequence delta
recovery (triggers full retransmit).
- Filter size decoupled from FMP negotiation: announced dynamically in
filter updates. Bit 7 and TLV field 1 removed from handshake.
- Variable filter sizes (512 bytes to 32 KB) with adaptive sizing
based on outgoing fill ratio (step-up at 20%, step-down at 5%).
## [Unreleased]
### Added
- The receive-path `RejectReason` classification (shipped in 0.4.0) is
additionally wired into the Noise XX handshake cluster
(msg1/msg2/msg3) and the rekey-initiator outbound sites on `next`.
- An optional tick-body profiler behind the new `profiling` Cargo feature,
**off by default**. When enabled, `fipsctl profile tick on [--dir PATH]` /
`off` / `status` starts and stops a capture at runtime with no restart. Each
@@ -35,6 +130,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Rekey timer jitter is enabled on next's XX FMP rekey path
(`REKEY_JITTER_SECS = 15` at `src/node/mod.rs`), matching the
IK-line behavior on maint/master. It had been temporarily set to
`0` on next because variable-interval rekeys exposed three XX
rekey-path defects that left the two endpoints on divergent Noise
sessions; those defects are fixed (see `### Fixed`), so the
per-session signed jitter over `[-15, +15]` seconds is restored.
`node.rekey.after_secs` is the nominal interval rather than a floor;
mean is preserved.
- On the XX FMP handshake, an over-cap inbound connection is rejected
solely by the late `promote_connection` check, and the resulting
`MaxPeersExceeded` rejection is logged at debug rather than warn so a
saturated node under sustained inbound pressure does not emit WARN
spam for these expected policy rejections. There is no early cap gate:
on XX the peer's identity is not known until the third handshake
message, by which point Msg1, Msg2, and Msg3 have all crossed the
wire, so an early gate would save no wire bytes and would govern
exactly the same net-new-peer set as the late check. The known-peer /
cross-connection bypass — which also covers peers the node is itself
dialing, e.g. configured `auto_connect` peers — is handled by that
late check, since those peers return earlier via the cross-connection
paths and are not subject to the cap.
- The Ethernet transport's per-interface `discovery` flag was renamed to
`listen` (`transports.ethernet.*`) to match the symmetric `announce`
(transmit) / `listen` (receive) neighbor-beacon vocabulary. The old
@@ -87,6 +204,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
suspends or hibernates was affected. Reported in
[#128](https://github.com/jmcorgan/fips/issues/128).
- XX FMP rekey no longer diverges under timer jitter, which unblocked
re-enabling the rekey jitter on next (`REKEY_JITTER_SECS = 15`; see
`### Changed`). With jitter the two directions of a link rekey close
together in time, and three defects specific to the XX three-message
rekey state machine could each leave the endpoints committed to
different Noise sessions — silent session divergence that starved the
receiver into ~50% post-rekey ping loss and a 30-second heartbeat
link-dead teardown (tree parent loss, routing failure) while every
crypto, transport, and link-state gate stayed green. All three are
fixed:
- The K-bit-flip handler promoted whatever pending session existed the
instant the header bit flipped, which under interleaved rekeys could
be a stale pending from an earlier epoch. It now trial-decrypts the
inbound frame against the pending session and promotes only on an
authenticated decrypt, delivering that plaintext through the
canonical path and leaving the pending untouched otherwise — the
same cutover discipline used on FSP.
- The FMP rekey msg3 was sent once, so a lost datagram left the
responder without the new session. The msg3 payload is now retained
and retransmitted over the existing link until a peer frame
authenticates against the pending or post-cutover current session,
abandoning after the configured handshake-resend budget. Per-link
rekeys are also serialized: a new rekey does not start while one
awaits cutover or is still retransmitting msg3.
- The `handle_msg3` cross-connection and rekey-responder paths were
partitioned by a fixed 30-second session-age threshold, but a rekey
resets the session-age clock, so under jitter a rekey-aged msg3 was
frequently under 30 seconds and got swallowed by the
initial-handshake cross-connection branch, which discarded the
peer's rekey session with no pending slot while the peer cut over to
it anyway. The cross-connection branch is now bounded by the same
jitter-aware session-age floor the rekey responder uses, so the two
paths partition with no overlap. At zero jitter the floor equals the
previous 30-second constant, so default-cadence behavior is
unchanged.
- XX rekey dual-initiation race that broke six pair-directions
post-rekey when both endpoints initiated rekey simultaneously.
The `handle_msg3` tie-breaker only fired when `rekey_in_progress`
was still true, but XX's three-message handshake lets both sides
clear that flag (via `set_pending_session`) before either's msg3
lands. The drop-on-pending-session guard then silently discarded
the peer's msg3, each side cut over to its own initiator session,
and the link broke asymmetrically. The tie-breaker now also fires
when `pending_new_session().is_some()`, applying the same
smaller-NodeAddr resolution rule. Mirrored to the FSP rekey msg1
path for symmetry.
- `SessionDatagram` hop-limit handling now follows IP semantics. Delivery to
the addressed node is no longer TTL-gated, and a forwarder decrements before
deciding rather than after, so a datagram that would leave with a TTL of zero
Generated
+1 -1
View File
@@ -1074,7 +1074,7 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
[[package]]
name = "fips"
version = "0.5.0-dev"
version = "0.6.0-dev"
dependencies = [
"arc-swap",
"bech32",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "fips"
version = "0.5.0-dev"
version = "0.6.0-dev"
edition = "2024"
description = "A distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports"
license = "MIT"
+17 -15
View File
@@ -3,7 +3,7 @@
![banner](docs/logos/fips_banner.png)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org/)
[![Status](https://img.shields.io/badge/status-v0.5.0--dev-green.svg)](#status--roadmap)
[![Status](https://img.shields.io/badge/status-v0.6.0--dev-green.svg)](#status--roadmap)
A self-organizing encrypted mesh network built on Nostr identities,
capable of operating over arbitrary transports without central
@@ -43,9 +43,9 @@ same way it would on a local network.
- **Multi-transport.** UDP, TCP, Ethernet, Tor, Nym, and Bluetooth
(BLE L2CAP) ship today; transports compose on a single mesh and a
node may run several at once.
- **Two-layer encryption.** Noise IK between peers (hop-by-hop) and
Noise XK between mesh endpoints (independent end-to-end), with
periodic rekey for forward secrecy.
- **Two-layer encryption.** Noise XX both hop-by-hop (peer links)
and end-to-end (mesh sessions), with periodic rekey for forward
secrecy and protocol negotiation in the handshake.
- **Nostr-native identity.** secp256k1 / schnorr keypairs as node
addresses; self-generated, no registration, no central authority.
- **IPv6 adapter.** A TUN interface maps each remote npub to an
@@ -212,24 +212,26 @@ testing/ Docker-based integration test harnesses + chaos simulation
## Status & roadmap
FIPS is at **v0.5.0-dev** on the `master` branch.
[v0.4.1](https://github.com/jmcorgan/fips/releases/tag/v0.4.1) has
shipped; this development line continues the testing-and-polishing
track toward v0.5.0. The core protocol works end-to-end over
FIPS is at **v0.6.0-dev** on the `next` branch.
[v0.4.1](https://github.com/jmcorgan/fips/releases/tag/v0.4.1)
has shipped from `master`; this development line carries
wire-format-breaking work for v0.6.0 — unified Noise XX handshake
at both layers, FMP node profiles, slimmer MMP reports, and an
extensible bloom-filter encoding — that will not interoperate with
v0.2.x, v0.3.x, or v0.4.x peers. The core protocol works end-to-end over
UDP, TCP, Ethernet, Tor, Nym, and Bluetooth on a global, public test
mesh of thousands of nodes. v0.4.0 added the Nym mixnet transport and
mDNS LAN discovery alongside the existing Nostr-mediated peer discovery,
UDP NAT traversal, peer ACL, and packaging hardening. New wire-format work
continues to be staged on the `next` branch for the subsequent
release line.
mesh of thousands of nodes. See the CHANGELOG `## Breaking` section for the
full list of v0.6.0 wire-format changes in flight.
### What works today
- Spanning-tree construction with greedy coordinate routing.
- Bloom-filter-guided destination discovery (no flooding,
single-path with retry).
- Two-layer Noise encryption (IK at the link, XK at the session)
with periodic hitless rekey for forward secrecy at both layers.
- Two-layer Noise XX encryption (hop-by-hop at the link layer and
end-to-end at the session layer) with periodic hitless rekey for
forward secrecy at both layers and protocol negotiation in the
handshake.
- Persistent or ephemeral node identity with key-file management.
- IPv6 TUN adapter with built-in `.fips` DNS resolver and
multi-backend auto-configuration (systemd dns-delegate,
+15 -17
View File
@@ -44,7 +44,7 @@ See [fips-transport-layer.md](fips-transport-layer.md) for the
transport layer specification.
**FIPS Mesh Protocol (FMP)**: Manages peer connections, authenticates
peers via Noise IK handshakes, and encrypts all traffic on each link.
peers via Noise XX handshakes, and encrypts all traffic on each link.
FMP is where the mesh organizes itself — nodes exchange spanning tree
announcements and bloom filters with their direct peers, and FMP
makes forwarding decisions for transit traffic. FMP provides
@@ -116,8 +116,8 @@ three are deterministically derived from the same keypair.
![Identity Derivation](diagrams/fips-identity-derivation.svg)
The pubkey is the node's cryptographic identity, used in Noise
handshakes for both link encryption (IK) and session encryption (XK).
The pubkey is the node's cryptographic identity, used in Noise XX
handshakes for both link encryption and session encryption.
It is never exposed beyond the endpoints of an encrypted channel. The node_addr, a one-way
SHA-256 hash truncated to 16 bytes, serves as the routing identifier
in packet headers and bloom filters. Intermediate routers see only
@@ -156,31 +156,29 @@ FIPS uses independent encryption at two protocol layers:
| Layer | Scope | Pattern | Purpose |
| ----- | ----- | ------- | ------- |
| **FMP (Mesh)** | Hop-by-hop | Noise IK | Encrypt all traffic on each peer link |
| **FSP (Session)** | End-to-end | Noise XK | Encrypt application payload between endpoints |
| **FMP (Mesh)** | Hop-by-hop | Noise XX | Encrypt all traffic on each peer link |
| **FSP (Session)** | End-to-end | Noise XX | Encrypt application payload between endpoints |
### Link Layer (Hop-by-Hop)
When two nodes establish a direct connection, they perform a [Noise
IK](https://noiseprotocol.org/) handshake. This authenticates both
XX](https://noiseprotocol.org/) handshake. This authenticates both
parties and establishes symmetric keys for encrypting all traffic on
that link. Every packet between direct peers is encrypted — gossip
messages, routing queries, and forwarded session datagrams alike.
The IK pattern is used because outbound connections know the peer's
npub from configuration, while inbound connections learn the
initiator's identity from the first handshake message.
Neither side requires prior knowledge of the other's static key —
both identities are revealed during the three-message handshake,
along with a protocol negotiation payload that enables rolling
upgrades.
### Session Layer (End-to-End)
FIPS establishes end-to-end encrypted sessions between any two
communicating nodes using Noise XK, regardless of how many hops
separate them. The initiator knows the destination's npub (required
for XK's pre-message); the responder learns the initiator's identity
from the third handshake message. Unlike the link-layer IK pattern
where the initiator's identity is revealed in msg1, XK delays
identity disclosure until msg3, providing stronger initiator identity
protection for traffic traversing untrusted intermediate nodes.
communicating nodes using Noise XX, regardless of how many hops
separate them. The same three-message XX pattern is used at both
layers — neither side reveals its identity until msg2 (responder) or
msg3 (initiator), providing mutual identity protection for traffic
traversing untrusted intermediate nodes.
A packet from A to D through intermediate nodes B and C:
+43 -35
View File
@@ -1,5 +1,12 @@
# FIPS Bloom Filters
> **Status (2026-07-12):** This document still describes the **abandoned v1.5**
> bloom design. The code on `next` is presently plain **v1** — the v1.5
> implementation was removed during the sans-IO refactor and replaced with the
> v1 filter. The target design is **v2** (per the settled v2 bloom protocol
> specification), which is **pending implementation**. Until v2 lands, treat any
> "Implemented" wording below as the retired v1.5 work, not the shipped code.
This document describes the bloom filter data structures, parameters, and
mathematical properties used by FIPS for reachability-based candidate
selection. It is a supporting reference — for how bloom filters fit into
@@ -255,29 +262,30 @@ for i in 0..hash_count:
return true // Maybe present (possible false positive)
```
Where `filter_bits = 8 × (512 << size_class)` — 8,192 for v1.
Where `filter_bits = 8 × (512 << size_class)` — 8,192 for size_class 1 (default).
## Wire Format
The FilterAnnounce byte layout (`msg_type 0x20`, sequence, hash_count,
size_class, filter_bits) lives in
The FilterAnnounce byte layout (`msg_type 0x20`, flags, sequence,
base_seq, size_class, compressed payload) lives in
[../reference/wire-formats.md](../reference/wire-formats.md). The
v1 plaintext payload is 1,035 bytes (11-byte header + 1,024-byte
filter); link encryption adds 36 bytes of FMP framing (16-byte outer
header + 4-byte inner timestamp + 16-byte AEAD tag), bringing the
on-the-wire size to roughly 1,071 bytes before the underlying
transport's per-packet overhead.
v2 payload uses RLE-compressed full filters and XOR deltas
(`[count:2 LE][word:8 LE]` runs) so steady-state announcements are
typically a few dozen bytes; a `FilterNack` (msg_type 0x21) requests
full retransmission when a sequence gap is detected. Link encryption
adds 36 bytes of FMP framing (16-byte outer header + 4-byte inner
timestamp + 16-byte AEAD tag) on top of the compressed payload.
## Scale and Size Classes
### v1 Scale Limits
### Scale Limits
Coordinate-based tree distance checking ensures correct routing decisions
at all network sizes — bloom filters are an optimization that narrows the
set of peers considered, not a correctness requirement. As filters
saturate, routing still works; it just evaluates more candidates per hop.
With the v1 mandatory 1 KB filter (size_class 1):
With the default 1 KB filter (size_class 1):
- **Small networks (< 1,000 nodes)**: Both upward and downward filters
are highly accurate (worst-case FPR < 1%). Filters effectively narrow
@@ -298,33 +306,29 @@ With the v1 mandatory 1 KB filter (size_class 1):
### Size Class Table
| size_class | Bytes | Bits | Status |
| ---------- | ----- | ---- | ------ |
| 0 | 512 | 4,096 | Reserved |
| 1 | 1,024 | 8,192 | **v1 (MUST use)** |
| 2 | 2,048 | 16,384 | Reserved |
| 3 | 4,096 | 32,768 | Reserved |
| size_class | Bytes | Bits | Notes |
| ---------- | ----- | ---- | ----- |
| 0 | 512 | 4,096 | Minimum |
| 1 | 1,024 | 8,192 | Default |
| 2 | 2,048 | 16,384 | |
| 3 | 4,096 | 32,768 | |
| 4 | 8,192 | 65,536 | |
| 5 | 16,384 | 131,072 | |
| 6 | 32,768 | 262,144 | Maximum |
FMP v1 mandates size_class = 1. Nodes MUST use size_class = 1 and MUST
reject FilterAnnounce messages with any other size_class. The size_class
field is reserved in the wire format to support future protocol versions
with larger default filter sizes.
### Adaptive Sizing
### Scaling Strategy
Filter size is a node property, not a link property. Each node selects
its own size class based on outgoing filter fill ratio: step up above
~20%, step down below ~5%, with hysteresis to prevent oscillation.
Nodes near the root of the spanning tree — which carry larger combined
filters — naturally upsize, while leaf and edge nodes stay small.
The 1 KB filter becomes a practical limitation beyond ~2,000 nodes. The
size class mechanism provides the path forward: future FMP versions may
use larger default filters (size_class 2 or 3) to support larger networks
while remaining compatible with constrained nodes through folding.
Size_class 2 (2 KB, 16,384 bits) would roughly double the practical
network size limit.
The envisioned approach is that hub nodes near the root — which carry the
largest downward filters — would use larger size classes, while leaf nodes
and resource-constrained nodes continue with smaller filters. A node
receiving a filter larger than its own size class folds it down locally.
The mechanism by which heterogeneous filter sizes propagate through
the tree is a future design direction not specified in v1.
When a node receives a filter at a different size class than its own,
it converts on receipt: larger filters are folded down, smaller filters
are expanded via bit duplication. Routing queries use the peer's filter
at its native (advertised) size for full resolution; conversion happens
only when building the node's own outgoing filter.
### Folding
@@ -378,7 +382,7 @@ as described above.
| Feature | Status |
| ------- | ------ |
| 1 KB bloom filter (size_class 1) | **Implemented** |
| Variable-size bloom filters (512 B 32 KB) | **Implemented** |
| 5 hash functions | **Implemented** |
| Split-horizon filter computation | **Implemented** |
| Tree-only merge propagation | **Implemented** |
@@ -386,6 +390,10 @@ as described above.
| Per-peer filter maintenance | **Implemented** |
| Event-driven updates | **Implemented** |
| 500ms rate limiting | **Implemented** |
| Delta compression (XOR diff + RLE) | **Implemented** |
| FilterNack sequence recovery | **Implemented** |
| Adaptive sizing (fill-ratio heuristic) | **Implemented** |
| Fold/duplicate size conversion | **Implemented** |
| FilterAnnounce gossip (all peers) | **Implemented** |
| Filter cardinality logging | **Implemented** |
| Mesh size estimation (OR-union of peer filters) | **Implemented** |
+52 -39
View File
@@ -9,7 +9,7 @@ the mesh self-organizes, and where forwarding decisions are made.
FMP manages direct peer connections over transports. When a transport delivers
a datagram from an unknown address, FMP authenticates the sender through a
Noise IK handshake, establishing a cryptographic link. Once authenticated, the
Noise XX handshake, establishing a cryptographic link. Once authenticated, the
link carries all inter-peer communication: spanning tree gossip, bloom filter
updates, coordinate discovery, and forwarded session datagrams — all encrypted
per-hop.
@@ -82,7 +82,7 @@ for the encrypted frame wrapper: 16-byte outer header + 5-byte inner header +
### Connection Lifecycle
For connection-oriented transports, the transport must establish the underlying
connection before FMP can begin the Noise IK handshake. For connectionless
connection before FMP can begin the Noise XX handshake. For connectionless
transports, datagrams can flow immediately.
### Endpoint Discovery (Optional)
@@ -94,46 +94,52 @@ through configuration.
## Peer Authentication
### Noise IK Handshake
### Noise XX Handshake
Every peer connection begins with a Noise IK handshake that mutually
Every peer connection begins with a Noise XX handshake that mutually
authenticates both parties and establishes symmetric keys for link encryption.
The IK pattern is chosen because:
The XX pattern is chosen because:
- The **initiator** knows the responder's static public key from configuration
or discovery, and sends their own static key encrypted in the first message
- The **responder** learns the initiator's identity from the first message,
then responds with their own ephemeral key
- **Neither side** requires prior knowledge of the other's static public key
- The **responder** reveals its identity in msg2; the **initiator** reveals
its identity in msg3
- A protocol negotiation payload (version range, feature bitfield, TLV
extensions) is appended to msg2 and msg3, enabling rolling protocol
upgrades without additional round-trips
After the two-message handshake completes, both parties share symmetric
session keys derived from four DH operations (es, ss, ee, se). The handshake
provides mutual authentication, forward secrecy, and identity hiding for the
initiator.
After the three-message handshake completes, both parties share symmetric
session keys derived from three DH operations (ee, es, se). The handshake
provides mutual authentication, forward secrecy, and identity hiding for
both parties until they choose to reveal.
### Epoch Exchange and Peer Restart Detection
Both IK handshake messages carry an encrypted epoch payload — an 8-byte
random value generated once at node startup:
XX handshake messages msg2 and msg3 carry an encrypted epoch payload — an
8-byte random value generated once at node startup:
- **msg1**: Ephemeral key (33 bytes) + encrypted static key (49 bytes) +
encrypted epoch (24 bytes) = 106 bytes total
- **msg2**: Ephemeral key (33 bytes) + encrypted epoch (24 bytes) = 57 bytes
total
- **msg1**: Ephemeral key only (33 bytes) — no identity or epoch
- **msg2**: Ephemeral key (33 bytes) + encrypted static key (49 bytes) +
encrypted epoch (24 bytes) = 106 bytes base, plus negotiation payload
- **msg3**: Encrypted static key (49 bytes) + encrypted epoch (24 bytes)
= 73 bytes base, plus negotiation payload
The encrypted epoch (EPOCH_ENCRYPTED_SIZE = 24 bytes) consists of the
8-byte epoch value plus a 16-byte AEAD tag.
On reconnection, each peer compares the received epoch with the previously
stored epoch for that peer. An epoch mismatch indicates the peer has
restarted (generated a new epoch), triggering full link re-establishment
rather than treating the handshake as a simple reconnection. This prevents
stale session state from persisting across restarts.
Because msg1 carries no identity, restart detection is deferred: the
initiator checks the responder's epoch after msg2, and the responder
checks the initiator's epoch after msg3. An epoch mismatch indicates the
peer has restarted, triggering full link re-establishment rather than
treating the handshake as a simple reconnection. This prevents stale
session state from persisting across restarts.
### Identity Binding
The Noise handshake binds the link to the peer's cryptographic identity. After
handshake completion:
The Noise handshake binds the link to the peer's cryptographic identity.
With XX, identity confirmation happens at different points: the initiator
learns the responder's identity from msg2, and the responder learns the
initiator's identity from msg3. After handshake completion:
- The peer's public key (FIPS identity) is confirmed
- The node_addr is computed from the public key (SHA-256, truncated to 128 bits)
@@ -143,8 +149,12 @@ handshake completion:
### Reconnection
When a Noise IK msg1 arrives from a peer that already has an authenticated
link, FMP accepts the new handshake alongside the existing session. If the new
When a Noise XX msg1 arrives from an address that already has an
authenticated link, FMP accepts the new handshake alongside the existing
session. With XX, the peer's identity is not known at msg1 time — FMP can
only detect the duplicate by transport address. Identity-based checks
(restart detection, rekey recognition, cross-connection resolution) are
deferred to msg3 when the initiator's identity is revealed. If the new
handshake completes successfully, it replaces the old session. This handles
legitimate reconnection (network change, process restart, NAT rebinding)
without disrupting ongoing traffic until the new session is confirmed.
@@ -163,7 +173,7 @@ The auto-reconnect path:
3. If eligible, the peer is fed into the retry system with unlimited retries
and exponential backoff (same base interval and max backoff as startup
retries, configured via `node.retry.*`)
4. On each retry tick, a fresh Noise IK handshake is initiated toward the
4. On each retry tick, a fresh Noise XX handshake is initiated toward the
peer's configured transport addresses
Auto-reconnect only applies to peers in the static peer list with
@@ -173,8 +183,8 @@ config) is responsible for re-establishing the link.
### Handshake Message Retry
Both link-layer (Noise IK msg1/msg2) and session-layer (SessionSetup/
SessionAck) handshakes use message-level retry with exponential backoff
Both link-layer (Noise XX msg1/msg2/msg3) and session-layer (SessionSetup/
SessionAck/SessionMsg3) handshakes use message-level retry with exponential backoff
within the handshake timeout window. This handles packet loss on the
underlying transport without waiting for the full handshake timeout to
expire.
@@ -380,13 +390,14 @@ configuration tree is documented in
### Mechanism
A rekey reuses the Noise IK pattern of the initial handshake, but the two
messages travel over the existing link as ordinary encrypted FMP frames
rather than as plaintext bootstrap packets. The initiator builds a fresh
`HandshakeState`, generates msg1, and sends it through the current session;
the responder consumes msg1, builds msg2, and replies. After both sides
have exchanged messages and finalised the new keys, traffic transitions
from the old session to the new one.
A rekey reuses the Noise XX pattern of the initial handshake, but the
three messages travel over the existing link as ordinary encrypted FMP
frames rather than as plaintext bootstrap packets. The initiator builds
a fresh `HandshakeState`, generates msg1, and sends it through the
current session; the responder consumes msg1, builds msg2, and replies;
the initiator then sends msg3. After both sides have exchanged messages
and finalised the new keys, traffic transitions from the old session to
the new one.
Cutover is signalled in-band by the **K-bit** in the FMP flags byte. Each
side starts emitting frames under the new session with K set; on receipt
@@ -547,7 +558,9 @@ an attacker sends invalid packets to elicit responses.
| Feature | Status |
| ------- | ------ |
| Noise IK handshake (with epoch) | **Implemented** |
| Noise XX handshake (with epoch and negotiation) | **Implemented** |
| Protocol negotiation (version + features + TLV) | **Implemented** |
| Node profiles (Full, NonRouting, Leaf) | **Implemented** |
| Peer restart detection (epoch mismatch) | **Implemented** |
| Link encryption (ChaCha20-Poly1305) | **Implemented** |
| Index-based session dispatch | **Implemented** |
+20 -13
View File
@@ -182,8 +182,8 @@ The source creates a LookupRequest containing:
- **request_id**: Unique identifier for deduplication
- **target**: The node_addr being sought
- **origin**: The requester's node_addr
- **origin_coords**: The requester's current tree coordinates (so the
response can route back)
- **min_mtu**: Minimum transport MTU the origin requires (transit nodes
skip peers whose link MTU is below this)
- **TTL**: Bounds the forwarding radius
### Bloom-Guided Tree Routing
@@ -269,8 +269,8 @@ the primary mechanism: each transit node looks up the `request_id` in its
`recent_requests` table to find the peer that forwarded the original request,
and sends the response back through that peer. This ensures the response
follows the same path as the request. Greedy tree routing toward the
`origin_coords` is used only as a fallback if the reverse-path entry has
expired.
greedy tree routing toward the origin's coordinates is used only as a
fallback if the reverse-path entry has expired.
**Response-forwarded flag**: Each `recent_requests` entry tracks whether a
response has already been forwarded for that `request_id`. If a second
@@ -472,16 +472,23 @@ When traffic resumes:
3. Coordinates: discovery may be needed if cache has expired
4. SessionSetup re-warms transit caches on the new path
## Leaf-Only Operation *(under development)*
## Node Profiles
Leaf-only operation is an optimization for resource-constrained nodes
(sensors, battery-powered devices). The core infrastructure exists (config
flag, node constructor, bloom filter support) but is not yet enabled in
normal operation.
Nodes advertise a profile during FMP negotiation (bits 0-2 of the feature
bitfield): **Full** (default), **NonRouting**, or **Leaf**. At least one
side of a link must be Full. Config mapping: `disable_routing: true`
NonRouting, `leaf_only: true` → Leaf.
### Concept
### Non-Routing Nodes
A leaf-only node connects to a single upstream peer that handles all routing
A non-routing node participates in the spanning tree but does not forward
transit traffic or send bloom filters. Its full peer inserts the
non-routing node's identity as a leaf dependent. MMP report flow is gated
by wants/provides bits negotiated during the handshake.
### Leaf Nodes
A leaf node connects to a single upstream peer that handles all routing
on its behalf:
- **No bloom filter storage or processing**: The upstream peer includes the
@@ -504,7 +511,7 @@ The upstream peer:
Even as a leaf-only node, it still:
- Maintains its own Noise IK link session with the upstream peer (FMP layer)
- Maintains its own Noise XX link session with the upstream peer (FMP layer)
- Can establish end-to-end FSP sessions with arbitrary destinations
- Has its own identity (npub, node_addr)
@@ -564,7 +571,7 @@ recovery).
| Discovery originator backoff | **Implemented** |
| Discovery transit-side rate limiting | **Implemented** |
| Discovery response-forwarded dedup | **Implemented** |
| Leaf-only operation | Under development |
| Node profiles (Full, NonRouting, Leaf) | **Implemented** |
| Link cost in parent selection (ETX) | **Implemented** |
| Link cost in candidate ranking | **Implemented** |
+6 -16
View File
@@ -68,7 +68,7 @@ MMP supports three modes:
| ---- | ----------------- | ----------------- |
| **Full** (default) | SenderReport + ReceiverReport | All metrics including RTT, loss, jitter, goodput, OWD trend |
| **Lightweight** | ReceiverReport only | Loss (from counter gaps), jitter, OWD trend. No RTT. |
| **Minimal** | None | Spin bit and CE echo flags only. No computed metrics. |
| **Minimal** | None | CE echo flags only. No computed metrics. |
The mode is configured per layer (`node.mmp.mode` and
`node.session_mmp.mode`).
@@ -88,28 +88,18 @@ The session-layer bounds are higher because session reports are
encrypted and forwarded through every transit link, so bandwidth cost
is proportional to path length.
## Spin Bit and RTT
## RTT Measurement
The SP (spin bit) flag in the FMP inner header follows the QUIC spin
bit pattern: reflected on receive, toggled on send when the reflected
value matches the last sent value. The spin bit state machine runs
for TX reflection, but **RTT samples from the spin bit are
discarded**. In a mesh protocol where frames are sent irregularly
(tree announces, bloom filters, MMP reports on different timers),
inter-frame processing delays inflate spin bit RTT measurements
unpredictably. Timestamp-echo from ReceiverReports (with dwell-time
compensation) is the sole SRTT source.
SRTT is derived exclusively from timestamp-echo in ReceiverReports
with dwell-time compensation, applied via the Jacobson/Karels
algorithm (RFC 6298, α = 1/8). This is the sole SRTT source at both
layers.
Duplicate or regressed ReceiverReports are ignored before any RTT, loss,
goodput, or ETX update. If receiver-side dwell time exceeds the wire
field, the report keeps its counters but sends a zero timestamp echo so
the sender cannot form an invalid RTT sample.
The spin bit lives in the link-layer FMP inner header, so this
mechanism applies to link-layer MMP only. Session-layer MMP carries
its spin bit in the FSP encrypted inner header but uses it the same
way: reflected for diagnostic visibility, not used for SRTT.
## ECN Congestion Signaling
The CE (Congestion Experienced) flag (bit 1 in the FMP flags byte)
+3 -3
View File
@@ -273,7 +273,7 @@ carrying the session id, the punch socket, and the learned remote
address. `adopt_established_traversal()` in the node lifecycle takes
the socket, registers it with the UDP transport layer as a new
transport instance, and calls `initiate_connection()` with the peer's
FIPS identity as the expected remote. FMP's Noise IK handshake runs on
FIPS identity as the expected remote. FMP's Noise XX handshake runs on
the same socket — there is no "promote link" step between punch and
handshake; the punch socket *is* the FMP socket.
@@ -382,7 +382,7 @@ semaphore and replay-cache layers downstream.
non-default `app` value to scope visibility.
- **Nothing about discovery bypasses FMP.** A successful punch yields
a UDP socket with a claimed remote identity. That identity is not
trusted until FMP's Noise IK handshake completes. A peer whose
trusted until FMP's Noise XX handshake completes. A peer whose
advert says "I am npub X at 1.2.3.4:5678" but whose FMP handshake
presents a different static key is rejected at the mesh layer.
@@ -584,7 +584,7 @@ beyond the shared scope fallback.
- [fips-transport-layer.md](fips-transport-layer.md) — UDP, TCP, and
Tor transport mechanics; the punch socket is adopted as a normal
UDP transport after handoff.
- [fips-mesh-layer.md](fips-mesh-layer.md) — FMP Noise IK handshake
- [fips-mesh-layer.md](fips-mesh-layer.md) — FMP Noise XX handshake
that runs on the adopted socket.
- [port-advertisement-and-nat-traversal.md](port-advertisement-and-nat-traversal.md)
— generic protocol reference (event tags, NIP usage, on-the-wire
+17 -29
View File
@@ -80,27 +80,24 @@ patterns.
## Noise Protocol Framework
FIPS uses the [Noise Protocol Framework](https://noiseprotocol.org/)
at both protocol layers, with different handshake patterns chosen for
each layer's threat model. FMP link encryption uses **Noise IK**,
providing mutual authentication with a single round trip where the
initiator knows the responder's static key in advance.
[WireGuard](https://www.wireguard.com/) uses the same IK base pattern
(extended with a pre-shared key as IKpsk2) for VPN tunnels. FSP
session encryption uses **Noise XK**, the same pattern used by the
[Lightning Network](https://github.com/lightning/bolts/blob/master/08-transport.md),
where the initiator's static key is transmitted in a third message
rather than the first. XK provides stronger initiator identity hiding
at the cost of an additional round trip — a worthwhile tradeoff for
session-layer traffic that traverses untrusted intermediate nodes. At
the link layer, where both peers are configured and directly
connected, IK's single round trip is preferred.
at both protocol layers with the **Noise XX** handshake pattern. XX
requires no prior knowledge of the peer's static key — both
identities are revealed during a three-message handshake (responder
in msg2, initiator in msg3). This enables anonymous peer discovery on
shared-media transports and allows a protocol negotiation payload to
be exchanged alongside the handshake, supporting rolling protocol
upgrades without extra round trips.
[WireGuard](https://www.wireguard.com/) uses the related IK pattern
for VPN tunnels;
[Lightning Network](https://github.com/lightning/bolts/blob/master/08-transport.md)
uses XK for transport encryption.
Specific Noise references and adapted constructions:
- Perrin, T. ["The Noise Protocol Framework"](https://noiseprotocol.org/noise.html).
Revision 34, 2018. *Framework for building crypto protocols using
Diffie-Hellman key agreement and AEAD ciphers. FSP uses the XK
handshake pattern.*
Diffie-Hellman key agreement and AEAD ciphers. FIPS uses the XX
handshake pattern at both layers.*
- Donenfeld, J.A. ["WireGuard: Next Generation Kernel Network Tunnel"](https://www.wireguard.com/papers/wireguard.pdf).
NDSS 2017. *Transport-independent cryptographic sessions bound to
@@ -156,14 +153,6 @@ computation used in TCP for retransmission timeout calculation since
1988. MMP derives RTT from timestamp-echo in ReceiverReports with
dwell-time compensation, rather than from packet round-trips.
The spin bit in the FMP frame header follows the
[QUIC](https://www.rfc-editor.org/rfc/rfc9000) spin bit
([RFC 9312](https://www.rfc-editor.org/rfc/rfc9312)) — a single bit
that alternates each round trip, enabling passive latency measurement.
FIPS implements the spin bit state machine but relies on
timestamp-echo for SRTT, as irregular mesh traffic makes spin bit RTT
unreliable.
The Expected Transmission Count (ETX) metric, computed from
bidirectional delivery ratios, was introduced by
[De Couto et al. (2003)](https://pdos.csail.mit.edu/papers/grid:mobicom03/paper.pdf)
@@ -325,11 +314,10 @@ The protocol builds on these foundations and adds several new elements:
| [HIP](https://en.wikipedia.org/wiki/Host_Identity_Protocol) | identity-as-address |
| [Babel](https://www.irif.fr/~jch/software/babel/) | split-horizon, ETX |
| [RIP](https://en.wikipedia.org/wiki/Routing_Information_Protocol) | split-horizon |
| [Noise Framework](https://noiseprotocol.org/) | FMP IK, FSP XK |
| [WireGuard](https://www.wireguard.com/) | IK pattern, receiver-index dispatch, identity-bound sessions |
| [Lightning BOLT #8](https://github.com/lightning/bolts/blob/master/08-transport.md) | XK pattern |
| [QUIC (RFC 9000)](https://www.rfc-editor.org/rfc/rfc9000) | spin bit, transport design |
| [QUIC Spin Bit (RFC 9312)](https://www.rfc-editor.org/rfc/rfc9312) | passive RTT measurement |
| [Noise Framework](https://noiseprotocol.org/) | FMP and FSP XX handshakes |
| [WireGuard](https://www.wireguard.com/) | receiver-index dispatch, identity-bound sessions |
| [Lightning BOLT #8](https://github.com/lightning/bolts/blob/master/08-transport.md) | comparison reference |
| [QUIC (RFC 9000)](https://www.rfc-editor.org/rfc/rfc9000) | transport design |
| [RTCP (RFC 3550)](https://www.rfc-editor.org/rfc/rfc3550) | sender/receiver report structure, jitter algorithm |
| [TCP SRTT/RTO (RFC 6298)](https://www.rfc-editor.org/rfc/rfc6298) | Jacobson/Karels SRTT |
| [ECN (RFC 3168)](https://www.rfc-editor.org/rfc/rfc3168) | CE echo |
+3 -3
View File
@@ -17,8 +17,8 @@ you can deliver packets to your `fips0` address — your direct peers
forward traffic from non-peer mesh nodes onto your `fips0` the same
way any router forwards transit traffic. Identity on the mesh is the
originating node's npub — the FMP link layer authenticates direct
peers with Noise IK and the FSP session layer authenticates session
endpoints with Noise XK — but identity is **not** authorization.
peers with Noise XX and the FSP session layer authenticates session
endpoints with Noise XX — but identity is **not** authorization.
Knowing who sent a packet does not, by itself, decide whether the
local host should accept it.
@@ -149,7 +149,7 @@ explicitly not:
originating mesh node's npub is allowed to use that service. That
is the application's responsibility (e.g., an `authorized_keys`
file for SSH, an ACL in the application's configuration).
- **ACL on the mesh handshake.** The FMP Noise IK handshake
- **ACL on the mesh handshake.** The FMP Noise XX handshake
authenticates the peer's npub and, on both inbound and outbound
paths, consults the peer ACL (`peers.allow` / `peers.deny`) before
promoting the connection. The ACL evaluates in TCP-Wrappers order:
+71 -48
View File
@@ -120,25 +120,29 @@ node, FMP delivers it to FSP for session-layer processing.
Sessions are established on demand when the first datagram needs to be sent to
a destination with no existing session.
FSP uses Noise XK for session key agreement (Noise Protocol Framework;
Perrin 2018). The initiator knows the destination's npub (required for
XK's pre-message `s` token); the responder learns the initiator's
identity from msg3 (not msg1, unlike IK at the link layer). This
provides stronger initiator identity hiding — the initiator's static
key is encrypted under the established shared secret rather than under
only the responder's static key.
FSP uses Noise XX for session key agreement (Noise Protocol Framework;
Perrin 2018). Neither side requires prior knowledge of the other's
static key — both identities are revealed during the handshake
(responder in msg2, initiator in msg3). An optional protocol negotiation
payload may be appended to msg2/msg3 (omitted for rekey handshakes).
The handshake is a three-message flow carried in SessionSetup, SessionAck,
and SessionMsg3:
1. **Initiator** sends SessionSetup containing Noise XK msg1 (ephemeral key
1. **Initiator** sends SessionSetup containing Noise XX msg1 (ephemeral key
only) and both parties' tree coordinates
2. **Responder** processes msg1, sends SessionAck containing Noise XK msg2
(ephemeral key + encrypted epoch) and both parties' tree coordinates.
The responder transitions to AwaitingMsg3 state.
3. **Initiator** processes msg2, sends SessionMsg3 containing the encrypted
static key and encrypted epoch. Both parties derive identical symmetric
session keys and the session is established.
2. **Responder** processes msg1, sends SessionAck containing Noise XX msg2
(ephemeral key + encrypted static key + encrypted epoch) and both
parties' tree coordinates. The responder transitions to AwaitingMsg3
state.
3. **Initiator** processes msg2 (learning the responder's identity), sends
SessionMsg3 containing its encrypted static key and encrypted epoch.
The responder learns the initiator's identity from msg3. Both parties
derive identical symmetric session keys and the session is established.
Post-handshake identity verification uses x-only key comparison
(parity-independent) to confirm the revealed identity matches the
expected npub.
Each side's epoch (an 8-byte random value generated at startup) is
exchanged encrypted in msg2 and msg3. On subsequent handshakes, an epoch
@@ -219,29 +223,30 @@ than network addresses. A session survives:
## End-to-End Encryption
### Noise XK Pattern
### Noise XX Pattern
FSP uses Noise XK for session encryption, distinct from the Noise IK
pattern used at the link layer. The full Noise descriptor is
`Noise_XK_secp256k1_ChaChaPoly_SHA256`.
FSP uses the same Noise XX pattern as the link layer (FMP). The full
Noise descriptor is `Noise_XX_secp256k1_ChaChaPoly_SHA256`.
The XK pattern (pre-message: `← s`):
The XX pattern (no pre-message):
- **msg1** (`→ e, es`): Initiator sends ephemeral key only. The initiator's
static identity is not revealed in this message.
- **msg2** (`← e, ee`): Responder sends ephemeral key and encrypted epoch.
- **msg1** (`→ e`): Initiator sends ephemeral key only. No identity
disclosed, no DH with static keys.
- **msg2** (`← e, ee, s, es`): Responder sends ephemeral key, encrypted
static key, and encrypted epoch. The initiator learns the responder's
identity.
- **msg3** (`→ s, se`): Initiator sends encrypted static key and encrypted
epoch. Both parties now share identical session keys.
epoch. The responder learns the initiator's identity. Both parties now
share identical session keys.
After the handshake, Noise produces two directional symmetric keys
(`send_key`, `recv_key`) used with ChaCha20-Poly1305 for all subsequent data.
(`send_key`, `recv_key`) used with ChaCha20-Poly1305 for all subsequent
data.
The XK pattern requires the initiator to know the responder's static key
in advance (the `← s` pre-message), which is satisfied by the discovery
or DNS lookup that precedes session establishment. In exchange, XK
provides stronger initiator identity protection than IK — the initiator's
static key is encrypted under the full shared secret (after three DH
operations) rather than under only the responder's static key.
XX requires no prior knowledge of the peer's static key. The initiator
still needs the destination's npub to address the SessionSetup, but the
Noise handshake itself does not depend on it — identity is verified
post-handshake by comparing the revealed key against the expected npub.
### Cryptographic Primitives
@@ -251,26 +256,27 @@ primitive table shared with the link layer.
### secp256k1 Parity Normalization
Nostr npubs encode x-only public keys (32 bytes, no y-coordinate parity). The
Noise XK pre-message mixes the responder's static key as a 33-byte compressed
key, and the default secp256k1 ECDH hash includes a parity-dependent version
byte.
Nostr npubs encode x-only public keys (32 bytes, no y-coordinate parity).
When the Noise XX handshake reveals a peer's static key via
`public_key().serialize()`, the key has its actual parity (0x02 or 0x03
prefix). The default secp256k1 ECDH hash also includes a parity-dependent
version byte.
Both operations are normalized to be parity-independent: the pre-message hash
uses even parity (`0x02` prefix), and ECDH hashes only the x-coordinate of the
result point. This ensures handshakes succeed regardless of the responder's
actual key parity.
Both operations are normalized to be parity-independent: ECDH hashes only
the x-coordinate of the result point, and post-handshake identity
verification uses `x_only_public_key()` to strip parity before comparing
against the expected npub. This ensures handshakes and identity checks
succeed regardless of key parity.
### Privacy Note
Noise XK provides stronger initiator identity protection than IK. In XK, the
initiator's static key is encrypted in msg3 under the full shared secret
(derived from three DH operations), so an attacker who compromises only the
responder's nsec cannot decrypt the initiator's identity from captured
handshake messages (they would also need the responder's ephemeral key).
This is the primary reason FSP uses XK rather than IK — session-layer
traffic traverses untrusted intermediate nodes, making initiator identity
protection more valuable than at the link layer.
Noise XX provides mutual identity protection — both the initiator's and
responder's static keys are encrypted under the evolving shared secret
(derived from DH operations completed in earlier messages). An attacker
who compromises only one side's nsec cannot decrypt the other side's
identity from captured handshake messages without also obtaining the
corresponding ephemeral key. Since session-layer traffic traverses
untrusted intermediate nodes, this mutual identity hiding is valuable.
### Data Packet Authentication
@@ -461,7 +467,7 @@ reactive PMTUD mechanism.
| Feature | Status |
| ------- | ------ |
| Session establishment (Noise XK) | **Implemented** |
| Session establishment (Noise XX) | **Implemented** |
| Peer restart detection (epoch exchange) | **Implemented** |
| MtuExceeded handling | **Implemented** |
| End-to-end encryption (ChaCha20-Poly1305) | **Implemented** |
@@ -497,7 +503,7 @@ reactive PMTUD mechanism.
- [fips-mmp.md](fips-mmp.md) — Metrics Measurement Protocol (link + session)
- [fips-mtu.md](fips-mtu.md) — Path MTU model (PathMtuNotification,
MtuExceeded, hysteresis)
- [fips-prior-work.md](fips-prior-work.md) — Noise XK, WireGuard,
- [fips-prior-work.md](fips-prior-work.md) — Noise XX, WireGuard,
DTLS replay window, IKEv2 simultaneous initiation, hybrid coordinate
warmup citations
- [../reference/wire-formats.md](../reference/wire-formats.md) — Wire
@@ -507,6 +513,23 @@ reactive PMTUD mechanism.
### External References
- Perrin, T. ["The Noise Protocol Framework"](https://noiseprotocol.org/noise.html).
Revision 34, 2018. *Framework for building crypto protocols using Diffie-Hellman
key agreement and AEAD ciphers. FSP uses the XX handshake pattern.*
- Donenfeld, J.A. ["WireGuard: Next Generation Kernel Network Tunnel"](https://www.wireguard.com/papers/wireguard.pdf).
NDSS 2017. *Transport-independent cryptographic sessions bound to identity keys
rather than network addresses; AEAD-only authentication model.*
- Rescorla, E., Modadugu, N. [RFC 6347](https://datatracker.ietf.org/doc/html/rfc6347):
"Datagram Transport Layer Security Version 1.2". 2012. *Explicit sequence numbers
with sliding bitmap window for replay protection over unreliable transports.*
- Kaufman, C., Hoffman, P., Nir, Y., Eronen, P., Kivinen, T.
[RFC 7296](https://datatracker.ietf.org/doc/html/rfc7296):
"Internet Key Exchange Protocol Version 2 (IKEv2)". 2014. *Simultaneous
initiation resolution (§2.8) and INITIAL_CONTACT peer restart detection (§2.4).*
- Mogul, J., Deering, S. [RFC 1191](https://datatracker.ietf.org/doc/html/rfc1191):
"Path MTU Discovery". 1990. *End-to-end path MTU discovery; FSP adapts this for
overlay networks using transit-node min() propagation.*
+1 -1
View File
@@ -171,7 +171,7 @@ When a node receives a TreeAnnounce from peer P:
1. **Validate version**: Reject if version ≠ 0x01
2. **Verify signature**: Check P's declaration signature using P's known
public key (established during Noise IK handshake)
public key (established during Noise XX handshake)
3. **Verify identity**: Confirm the declaration's node_addr matches the
sender's known identity
4. **Check freshness**: If `sequence ≤ stored sequence for P`, discard
+17 -19
View File
@@ -18,7 +18,7 @@ to the FIPS Mesh Protocol (FMP) above.
The transport layer deals exclusively in **transport addresses** — IP:port
or hostname:port addresses, MAC addresses, .onion identifiers, radio device addresses. These are
opaque to every layer above FMP. The mapping from transport address to FIPS
identity happens at the link layer after the Noise IK link handshake completes.
identity happens at the link layer after the Noise XX link handshake completes.
The word "peer" belongs to the link layer and above; the transport layer
knows only about remote endpoints identified by transport addresses.
@@ -71,7 +71,7 @@ forwarding and LookupResponse transit annotation.
For connection-oriented transports, manage the underlying connection: TCP
handshake, Tor circuit establishment, BLE pairing. FMP cannot begin
the Noise IK link handshake until the transport-layer connection is
the Noise XX link handshake until the transport-layer connection is
established.
Connection-oriented transports expose a non-blocking connect interface.
@@ -154,7 +154,7 @@ and duplication at the routing layer.
**Connection model**: Connectionless transports (UDP, raw Ethernet) allow
immediate datagram exchange. Connection-oriented transports (TCP, Tor, BLE)
require connection setup before FMP can begin the Noise IK link handshake,
require connection setup before FMP can begin the Noise XX link handshake,
adding startup latency.
**Stream vs. datagram**: Datagram transports have natural packet boundaries.
@@ -276,30 +276,28 @@ EtherType 0x2121. SOCK_DGRAM mode
lets the kernel handle Ethernet header construction and parsing — the
transport deals only with payloads and MAC addresses.
Data frames use a 3-byte header: a 1-byte frame type (`0x00`) followed by
a 2-byte little-endian payload length. The length field allows the receiver
to trim Ethernet minimum-frame padding that would otherwise corrupt AEAD
verification. Beacon frames (`0x01`) use only the 1-byte type prefix
(fixed 34-byte payload). Beacons and data share the same EtherType and
socket.
All frames use a unified 4-byte header: `[type:1][flags:1][length:2 LE]`.
The length field allows the receiver to trim Ethernet minimum-frame padding
that would otherwise corrupt AEAD verification. Frame types: `0x00` (data),
`0x01` (beacon). Beacons and data share the same EtherType and socket.
| Property | Value |
| -------- | ----- |
| EtherType | 0x2121 |
| Socket type | AF_PACKET SOCK_DGRAM |
| Data frame header | `[type:1][length:2 LE][payload]` |
| Beacon frame header | `[type:1][payload]` (fixed 34 bytes) |
| Effective MTU | Interface MTU - 3 (typically 1497) |
| Frame header | `[type:1][flags:1][length:2 LE][payload]` |
| Effective MTU | Interface MTU - 4 (typically 1496) |
| Addressing | 6-byte MAC address |
| Platform | Linux only (`CAP_NET_RAW` required) |
### Neighbor Beacons
Ethernet nodes discover peers via broadcast beacons sent to
ff:ff:ff:ff:ff:ff. Each beacon is a 34-byte frame containing the sender's
x-only public key. Receiving nodes extract the MAC source address from the
frame and the public key from the payload, then report the discovered peer
to FMP.
ff:ff:ff:ff:ff:ff. Beacons are minimal 5-byte frames (4-byte header +
1-byte beacon type) — no public key is included. The peer's identity is
learned from the Noise XX handshake after the connection is established.
Receiving nodes extract the MAC source address from the frame and report
the discovered address to FMP.
Four configuration flags control neighbor behavior — `listen`
(listen for beacons), `announce` (broadcast beacons), `auto_connect`
@@ -384,7 +382,7 @@ every tick, `poll_pending_connects()` calls `connection_state(addr)` to
check progress. When the transport reports `Connected`, the completed
connection is promoted to the established pool (stream split into
read/write halves, per-connection receive task spawned), and the node
initiates the Noise IK link handshake. If the transport reports `Failed`,
initiates the Noise XX link handshake. If the transport reports `Failed`,
the node schedules a retry with exponential backoff.
As a fallback, `send(addr, data)` still performs synchronous
@@ -422,7 +420,7 @@ socket is created).
The Tor transport routes FIPS traffic through the Tor network, hiding
a node's IP address from its peers. A node behind Tor connects outbound
through a local Tor SOCKS5 proxy; the remote peer sees the Tor exit
node's IP, not the initiator's. After the Noise IK handshake, the remote
node's IP, not the initiator's. After the Noise XX handshake, the remote
peer knows the initiator's FIPS identity (npub) but not its network
location.
@@ -503,7 +501,7 @@ The inbound accept loop mirrors the TCP transport's pattern: accept
connection, configure socket (TCP_NODELAY, keepalive), spawn a
per-connection receive loop using the shared FMP stream reader. Inbound
connections arrive from `127.0.0.1` (Tor daemon's local forwarding); peer
identity is resolved during the Noise IK handshake, not from the transport
identity is resolved during the Noise XX handshake, not from the transport
address.
Configuration requires coordinating `torrc` and `fips.yaml`. The
@@ -457,7 +457,7 @@ authentication or encryption**. The application layer is responsible
for establishing its own security on the punched channel — for
example, a Noise Protocol handshake keyed from the Nostr identity,
or an application-specific authenticated-encryption layer. FIPS
runs its FMP Noise IK handshake immediately after adoption; the
runs its FMP Noise XX handshake immediately after adoption; the
identity proven by the Noise handshake is the same Nostr pubkey
that signed the inner offer/answer rumour, so a man-in-the-middle on
the relay cannot impersonate the responder.
+1 -1
View File
@@ -197,7 +197,7 @@ What this achieves: the node publishes a `udp:nat` endpoint plus its
signaling relays in the advert. When either side initiates, an
encrypted offer is sealed to the peer's npub, a matching answer
comes back, and both sides punch at the negotiated time. On success,
the punch socket is adopted as an FMP UDP transport and Noise IK
the punch socket is adopted as an FMP UDP transport and Noise XX
proceeds normally.
> **Validation:** `advertise_on_nostr: true` with `public: false` on
+2 -2
View File
@@ -4,7 +4,7 @@ Link FIPS routers over radio — no cables, no APs, no shared
infrastructure — by running the Ethernet transport on an open 802.11s
mesh interface. The radio layer provides nothing but L2 frames to
direct neighbors; FIPS provides everything else: encryption and
authentication (Noise IK), peer discovery (Ethernet beacons), and
authentication (the Noise handshake), peer discovery (Ethernet beacons), and
routing (the spanning tree).
For the transport design, see
@@ -17,7 +17,7 @@ For all `transports.ethernet.*` configuration keys, see
Two deliberate choices distinguish this from a stock 802.11s setup:
- **`encryption none`** — the mesh is open on purpose. Every FIPS peer
link is already authenticated and encrypted by the Noise IK
link is already authenticated and encrypted by the Noise
handshake, so SAE at L2 would duplicate that work, add a shared
credential to provision across routers, and (on ath10k) force the
firmware into its slower raw Tx/Rx mode. A stranger can form an
+1 -1
View File
@@ -28,7 +28,7 @@ controlled through the standard service control manager.
| Flag | Argument | Description |
| ---- | -------- | ----------- |
| `-c`, `--config` | `FILE` | Use `FILE` as the configuration. Skips the default search paths. |
| `-V` | — | Print the short version (e.g. `0.4.0 (rev abcdef1)`). |
| `-V` | — | Print the short version (e.g. `0.5.0-dev (rev abcdef1)`). |
| `--version` | — | Print the long version: short version plus build target triple. |
| `-h`, `--help` | — | Print usage and exit. |
| `--install-service` | — | (Windows only) Install `fips` as a Windows service. Requires Administrator. |
+6 -4
View File
@@ -104,7 +104,8 @@ to the highest-priority config file for operator visibility, even in ephemeral m
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `node.leaf_only` | bool | `false` | Leaf-only mode: node does not forward traffic or participate in routing |
| `node.disable_routing` | bool | `false` | Non-routing mode: participates in spanning tree but does not forward transit traffic or send bloom filters |
| `node.leaf_only` | bool | `false` | Leaf mode: single upstream peer, no tree/bloom/transit participation. Implies `disable_routing: true` |
| `node.tick_interval_secs` | u64 | `1` | Periodic maintenance tick interval (retry checks, timeout cleanup, tree refresh) |
| `node.base_rtt_ms` | u64 | `100` | Initial RTT estimate for new links before measurements converge |
| `node.heartbeat_interval_secs` | u64 | `10` | Heartbeat send interval per peer for liveness detection |
@@ -124,7 +125,7 @@ Controls capacity for connections, peers, and links.
### Rate Limiting (`node.rate_limit.*`)
Handshake rate limiting protects against DoS on the Noise IK handshake path.
Handshake rate limiting protects against DoS on the Noise XX handshake path.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
@@ -304,7 +305,7 @@ stays set for all subsequent hops to the destination.
### Rekey (`node.rekey.*`)
Controls periodic Noise rekey for forward secrecy. When enabled, both FMP
(link-layer IK) and FSP (session-layer XK) sessions perform fresh Diffie-Hellman
(link-layer XX) and FSP (session-layer XX) sessions perform fresh Diffie-Hellman
key exchanges after a time or message count threshold, whichever comes first.
A 10-second drain window keeps the old session active for decryption during
cutover.
@@ -338,7 +339,7 @@ Metrics Measurement Protocol for per-peer link measurement. See
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `node.mmp.mode` | string | `"full"` | Operating mode: `full` (sender + receiver reports), `lightweight` (receiver reports only), or `minimal` (spin bit + CE echo only, no reports) |
| `node.mmp.mode` | string | `"full"` | Operating mode: `full` (sender + receiver reports), `lightweight` (receiver reports only), or `minimal` (CE echo only, no reports) |
| `node.mmp.log_interval_secs` | u64 | `30` | Periodic operator log interval for link metrics |
| `node.mmp.owd_window_size` | usize | `32` | One-way delay trend ring buffer size |
@@ -899,6 +900,7 @@ node:
identity:
nsec: null # secret key in nsec or hex (null = depends on persistent)
persistent: false # true = load/save fips.key; false = ephemeral each start
disable_routing: false
leaf_only: false
tick_interval_secs: 1
base_rtt_ms: 100
+34 -25
View File
@@ -1,43 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 270" font-family="monospace" font-size="13">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 374" font-family="monospace" font-size="13">
<!-- Background -->
<rect width="580" height="270" fill="#1a1a2e" rx="4"/>
<rect width="580" height="374" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">FilterAnnounce (0x20) — 11 + filter bytes</text>
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">FilterAnnounce (0x20) &#8212; 19-byte header + RLE payload</text>
<!-- Row 0 (03): msg_type(1) + sequence starts -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">03</text>
<!-- Row 0 (0-1): msg_type + flags -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0&#8211;1</text>
<rect x="55" y="36" width="130" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
<text x="120" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">msg_type</text>
<text x="120" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">0x20</text>
<text x="120" y="56" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">msg_type</text>
<text x="120" y="74" fill="#8ab4f8" text-anchor="middle" font-size="10">0x20</text>
<rect x="185" y="36" width="390" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="380" y="66" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sequence</text>
<rect x="185" y="36" width="390" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="380" y="56" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">flags</text>
<text x="380" y="74" fill="#8af8c8" text-anchor="middle" font-size="10">1 byte &#8212; bit 0: delta (XOR diff)</text>
<!-- Row 1 (48): sequence continued -->
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">48</text>
<!-- Row 1 (2-9): sequence -->
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">2&#8211;9</text>
<rect x="55" y="88" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="315" y="118" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE — monotonic counter</text>
<text x="315" y="110" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sequence</text>
<text x="315" y="128" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE &#8212; per-peer monotonic counter</text>
<!-- Row 2 (910): hash_count + size_class -->
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">910</text>
<!-- Row 2 (10-17): base_seq -->
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">10&#8211;17</text>
<rect x="55" y="140" width="260" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="185" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">hash_count</text>
<text x="185" y="180" fill="#8af8c8" text-anchor="middle" font-size="10">1 byte</text>
<rect x="55" y="140" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">base_seq</text>
<text x="315" y="180" fill="#d8a0f8" text-anchor="middle" font-size="10">8 bytes LE &#8212; reference sequence for delta (0 if full)</text>
<rect x="315" y="140" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="445" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">size_class</text>
<text x="445" y="180" fill="#d8a0f8" text-anchor="middle" font-size="10">1 byte</text>
<!-- Row 3 (18): size_class -->
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">18</text>
<!-- Row 3 (11): filter_bits -->
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">11</text>
<rect x="55" y="192" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="315" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">size_class</text>
<text x="315" y="232" fill="#8af8f8" text-anchor="middle" font-size="10">1 byte &#8212; filter size = 512 &#171; size_class bytes</text>
<rect x="55" y="198" width="520" height="40" fill="#1f1f3a" stroke="#4ad9d9" stroke-width="1" stroke-dasharray="4,3" rx="3"/>
<text x="315" y="222" fill="#8af8f8" text-anchor="middle" font-size="11">filter_bits (variable, 512 &lt;&lt; size_class bytes)</text>
<!-- Row 4 (19-): compressed_payload -->
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">19&#8211;</text>
<rect x="55" y="250" width="520" height="40" fill="#1f1f3a" stroke="#4a9090" stroke-width="1" stroke-dasharray="4,3" rx="3"/>
<text x="315" y="274" fill="#8ad8d8" text-anchor="middle" font-size="11">compressed_payload (RLE: [count:2 LE][word:8 LE] per run)</text>
<!-- Explanation -->
<rect x="55" y="304" width="520" height="32" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
<text x="315" y="324" fill="#888" text-anchor="middle" font-size="10">delta: XOR diff of current vs last-sent filter &#8212; full: raw filter words</text>
<!-- Total -->
<text x="310" y="258" fill="#777" font-size="10" text-anchor="middle">v1 payload: 1,035 bytes (11 header + 1,024 filter)</text>
<text x="310" y="362" fill="#777" font-size="10" text-anchor="middle">19-byte header + variable compressed payload</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

+43 -34
View File
@@ -1,26 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 620 500" font-family="monospace" font-size="13">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 620 620" font-family="monospace" font-size="13">
<defs>
<marker id="arrowR" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto" markerUnits="userSpaceOnUse">
<polygon points="0,0 10,4 0,8" fill="#e0e0e0"/>
</marker>
<marker id="arrowL" markerWidth="10" markerHeight="8" refX="1" refY="4" orient="auto" markerUnits="userSpaceOnUse">
<polygon points="10,0 0,4 10,8" fill="#e0e0e0"/>
</marker>
</defs>
<!-- Background -->
<rect width="620" height="500" fill="#1a1a2e" rx="4"/>
<rect width="620" height="620" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">FMP Handshake Flow (Noise IK)</text>
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">FMP Handshake Flow (Noise XX)</text>
<!-- Column headers -->
<text x="100" y="54" fill="#8ab4f8" text-anchor="middle" font-size="12" font-weight="bold">Initiator</text>
<text x="520" y="54" fill="#8ab4f8" text-anchor="middle" font-size="12" font-weight="bold">Responder</text>
<!-- Lifelines -->
<line x1="100" y1="62" x2="100" y2="480" stroke="#333" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="520" y1="62" x2="520" y2="480" stroke="#333" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="100" y1="62" x2="100" y2="600" stroke="#333" stroke-width="1" stroke-dasharray="4,4"/>
<line x1="520" y1="62" x2="520" y2="600" stroke="#333" stroke-width="1" stroke-dasharray="4,4"/>
<!-- Step 1: Initiator prepares -->
<text x="20" y="88" fill="#8af8c8" font-size="10">generates sender_idx</text>
@@ -28,39 +25,51 @@
<!-- Arrow 1: msg1 (initiator -> responder) -->
<line x1="100" y1="120" x2="520" y2="120" stroke="#4a90d9" stroke-width="1.5" marker-end="url(#arrowR)"/>
<rect x="130" y="128" width="360" height="24" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1" rx="3"/>
<text x="310" y="144" fill="#e0e0e0" text-anchor="middle" font-size="10">[0x01|flags=0|len] | sender_idx | noise_msg1</text>
<text x="310" y="166" fill="#666" text-anchor="middle" font-size="9">phase 0x1 — 114 bytes</text>
<rect x="145" y="128" width="330" height="24" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1" rx="3"/>
<text x="310" y="144" fill="#e0e0e0" text-anchor="middle" font-size="10">[0x11|flags=0|len] | sender_idx | noise_msg1</text>
<text x="310" y="166" fill="#666" text-anchor="middle" font-size="9">phase 0x1 &#8212; 41 bytes &#8212; pattern: &#8594; e</text>
<!-- Step 2: Responder processes (right-aligned to stay in bounds) -->
<text x="600" y="192" fill="#8af8c8" font-size="10" text-anchor="end">validates msg1</text>
<text x="600" y="206" fill="#8af8c8" font-size="10" text-anchor="end">learns initiator's static key</text>
<text x="600" y="220" fill="#8af8c8" font-size="10" text-anchor="end">generates sender_idx</text>
<text x="600" y="234" fill="#8af8c8" font-size="10" text-anchor="end">generates ephemeral keypair</text>
<!-- Step 2: Responder processes msg1 -->
<text x="600" y="192" fill="#8af8c8" font-size="10" text-anchor="end">validates msg1 (ephemeral only)</text>
<text x="600" y="206" fill="#8af8c8" font-size="10" text-anchor="end">generates sender_idx</text>
<text x="600" y="220" fill="#8af8c8" font-size="10" text-anchor="end">generates ephemeral keypair</text>
<!-- Arrow 2: msg2 (responder -> initiator) -->
<!-- Draw line right-to-left so orient="auto" points the arrowhead left -->
<line x1="520" y1="256" x2="100" y2="256" stroke="#4ad99a" stroke-width="1.5" marker-end="url(#arrowR)"/>
<rect x="130" y="264" width="360" height="24" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1" rx="3"/>
<text x="310" y="280" fill="#e0e0e0" text-anchor="middle" font-size="9">[0x02|flags=0|len] | sender_idx | receiver_idx | noise_msg2</text>
<text x="310" y="300" fill="#666" text-anchor="middle" font-size="9">phase 0x2 — 69 bytes</text>
<line x1="520" y1="244" x2="100" y2="244" stroke="#4ad99a" stroke-width="1.5" marker-end="url(#arrowR)"/>
<rect x="115" y="252" width="390" height="24" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1" rx="3"/>
<text x="310" y="268" fill="#e0e0e0" text-anchor="middle" font-size="9">[0x12|flags=0|len] | sender_idx | receiver_idx | noise_msg2 + negotiation</text>
<text x="310" y="288" fill="#666" text-anchor="middle" font-size="9">phase 0x2 &#8212; 144 bytes &#8212; pattern: &#8592; e, ee, s, es</text>
<text x="600" y="300" fill="#d8a0f8" font-size="9" text-anchor="end">responder identity revealed</text>
<!-- Step 3: Initiator completes -->
<text x="20" y="324" fill="#8af8c8" font-size="10">validates msg2</text>
<text x="20" y="338" fill="#8af8c8" font-size="10">derives session keys</text>
<!-- Step 3: Initiator processes msg2, learns responder identity -->
<text x="20" y="322" fill="#8af8c8" font-size="10">validates msg2</text>
<text x="20" y="336" fill="#8af8c8" font-size="10">learns responder identity</text>
<text x="20" y="350" fill="#8af8c8" font-size="10">checks epoch (restart detection)</text>
<!-- Arrow 3: msg3 (initiator -> responder) -->
<line x1="100" y1="368" x2="520" y2="368" stroke="#d94a6a" stroke-width="1.5" marker-end="url(#arrowR)"/>
<rect x="115" y="376" width="390" height="24" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1" rx="3"/>
<text x="310" y="392" fill="#e0e0e0" text-anchor="middle" font-size="9">[0x13|flags=0|len] | sender_idx | receiver_idx | noise_msg3 + neg</text>
<text x="310" y="412" fill="#666" text-anchor="middle" font-size="9">phase 0x3 &#8212; 111 bytes &#8212; pattern: &#8594; s, se</text>
<text x="20" y="424" fill="#d8a0f8" font-size="9">initiator identity revealed</text>
<!-- Step 4: Responder processes msg3, learns initiator identity -->
<text x="600" y="440" fill="#8af8c8" font-size="10" text-anchor="end">validates msg3</text>
<text x="600" y="454" fill="#8af8c8" font-size="10" text-anchor="end">learns initiator identity</text>
<text x="600" y="468" fill="#8af8c8" font-size="10" text-anchor="end">checks epoch, derives session keys</text>
<!-- Handshake complete separator -->
<line x1="30" y1="360" x2="230" y2="360" stroke="#d9904a" stroke-width="1.5"/>
<text x="310" y="364" fill="#d9904a" text-anchor="middle" font-size="11" font-weight="bold">HANDSHAKE COMPLETE</text>
<line x1="390" y1="360" x2="590" y2="360" stroke="#d9904a" stroke-width="1.5"/>
<line x1="30" y1="488" x2="230" y2="488" stroke="#d9904a" stroke-width="1.5"/>
<text x="310" y="492" fill="#d9904a" text-anchor="middle" font-size="11" font-weight="bold">HANDSHAKE COMPLETE</text>
<line x1="390" y1="488" x2="590" y2="488" stroke="#d9904a" stroke-width="1.5"/>
<!-- Arrow 3: first encrypted frame -->
<text x="20" y="392" fill="#777" font-size="10">first encrypted frame:</text>
<line x1="100" y1="406" x2="520" y2="406" stroke="#d94a6a" stroke-width="1.5" marker-end="url(#arrowR)"/>
<rect x="115" y="414" width="390" height="24" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1" rx="3"/>
<text x="310" y="430" fill="#e0e0e0" text-anchor="middle" font-size="9">[0x00|flags|len] | receiver_idx | counter=0 | ciphertext+tag</text>
<text x="310" y="450" fill="#666" text-anchor="middle" font-size="9">phase 0x0 established frame</text>
<!-- Arrow 4: first encrypted frame -->
<text x="20" y="516" fill="#777" font-size="10">first encrypted frame:</text>
<line x1="100" y1="530" x2="520" y2="530" stroke="#d9b04a" stroke-width="1.5" marker-end="url(#arrowR)"/>
<rect x="115" y="538" width="390" height="24" fill="#4a3d2d" stroke="#d9b04a" stroke-width="1" rx="3"/>
<text x="310" y="554" fill="#e0e0e0" text-anchor="middle" font-size="9">[0x00|flags|len] | receiver_idx | counter=0 | ciphertext+tag</text>
<text x="310" y="574" fill="#666" text-anchor="middle" font-size="9">phase 0x0 &#8212; established frame</text>
<!-- Legend -->
<text x="310" y="478" fill="#555" text-anchor="middle" font-size="9">Both parties hold identical symmetric keys. Epoch exchange enables restart detection.</text>
<text x="310" y="600" fill="#555" text-anchor="middle" font-size="9">Both parties hold identical symmetric keys. Epoch + negotiation exchanged in msg2/msg3.</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

-67
View File
@@ -1,67 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 426" font-family="monospace" font-size="13">
<!-- Background -->
<rect width="580" height="426" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">Noise IK Message 1 — phase 0x1 (114 bytes)</text>
<!-- Row 0: Common prefix (bytes 0-3) -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">03</text>
<rect x="55" y="36" width="95" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
<text x="102" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ver</text>
<text x="102" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">4 bits</text>
<rect x="150" y="36" width="95" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="197" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">phase</text>
<text x="197" y="78" fill="#8af8c8" text-anchor="middle" font-size="10">4 bits</text>
<rect x="245" y="36" width="130" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="310" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">flags</text>
<text x="310" y="78" fill="#f8c88a" text-anchor="middle" font-size="10">1 byte</text>
<rect x="375" y="36" width="200" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="475" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">payload_len</text>
<text x="475" y="78" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE</text>
<!-- Row 1: sender_idx (bytes 4-7) -->
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">47</text>
<rect x="55" y="88" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="315" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sender_idx</text>
<text x="315" y="130" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Noise IK msg1 label -->
<text x="10" y="250" fill="#777" font-size="10" text-anchor="middle" transform="rotate(-90, 10, 250)">Noise IK msg1 (106 bytes)</text>
<line x1="18" y1="140" x2="18" y2="348" stroke="#555" stroke-width="1"/>
<line x1="18" y1="140" x2="23" y2="140" stroke="#555" stroke-width="1"/>
<line x1="18" y1="348" x2="23" y2="348" stroke="#555" stroke-width="1"/>
<!-- Rows 2-3: ephemeral_pubkey (bytes 8-40, 33 bytes) -->
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">840</text>
<rect x="55" y="140" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="315" y="170" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ephemeral_pubkey</text>
<rect x="55" y="192" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="315" y="222" fill="#f8c88a" text-anchor="middle" font-size="10">33 bytes (compressed secp256k1)</text>
<!-- Rows 4-5: encrypted_static (bytes 41-89, 49 bytes) -->
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">4189</text>
<rect x="55" y="244" width="520" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
<text x="315" y="274" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_static</text>
<rect x="55" y="296" width="520" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
<text x="315" y="326" fill="#f88aaa" text-anchor="middle" font-size="10">49 bytes (static key 33 + AEAD tag 16)</text>
<!-- Row 6: encrypted_epoch (bytes 90-113, 24 bytes) -->
<text x="50" y="374" fill="#666" font-size="10" text-anchor="end">90113</text>
<rect x="55" y="348" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="315" y="372" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_epoch</text>
<text x="315" y="390" fill="#d8a0f8" text-anchor="middle" font-size="10">24 bytes (epoch 8 + AEAD tag 16)</text>
<!-- Total -->
<text x="310" y="418" fill="#777" font-size="10" text-anchor="middle">total: 114 bytes · payload_len = 110 · pattern: e, es, s, ss</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

@@ -1,12 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 374" font-family="monospace" font-size="13">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 218" font-family="monospace" font-size="13">
<!-- Background -->
<rect width="580" height="374" fill="#1a1a2e" rx="4"/>
<rect width="580" height="218" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">Noise IK Message 2 — phase 0x2 (69 bytes)</text>
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">Noise XX Message 1 &#8212; phase 0x1 (41 bytes)</text>
<!-- Row 0: Common prefix (bytes 0-3) -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">03</text>
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0&#8211;3</text>
<rect x="55" y="36" width="95" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
<text x="102" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ver</text>
@@ -25,41 +25,19 @@
<text x="475" y="78" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE</text>
<!-- Row 1: sender_idx (bytes 4-7) -->
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">47</text>
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4&#8211;7</text>
<rect x="55" y="88" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="315" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sender_idx</text>
<text x="315" y="130" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Row 2: receiver_idx (bytes 8-11) -->
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">811</text>
<!-- Row 2: ephemeral_pubkey (bytes 8-40, 33 bytes) -->
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">8&#8211;40</text>
<rect x="55" y="140" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="315" y="164" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">receiver_idx</text>
<text x="315" y="182" fill="#8af8c8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Noise IK msg2 bracket -->
<text x="10" y="270" fill="#777" font-size="10" text-anchor="middle" transform="rotate(-90, 10, 270)">Noise IK msg2 (57 bytes)</text>
<line x1="18" y1="192" x2="18" y2="348" stroke="#555" stroke-width="1"/>
<line x1="18" y1="192" x2="23" y2="192" stroke="#555" stroke-width="1"/>
<line x1="18" y1="348" x2="23" y2="348" stroke="#555" stroke-width="1"/>
<!-- Rows 3-4: ephemeral_pubkey (bytes 12-44, 33 bytes) -->
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">1244</text>
<rect x="55" y="192" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="315" y="222" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ephemeral_pubkey</text>
<rect x="55" y="244" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="315" y="274" fill="#f8c88a" text-anchor="middle" font-size="10">33 bytes (compressed secp256k1)</text>
<!-- Row 5: encrypted_epoch (bytes 45-68, 24 bytes) -->
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">4568</text>
<rect x="55" y="296" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="315" y="320" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_epoch</text>
<text x="315" y="338" fill="#d8a0f8" text-anchor="middle" font-size="10">24 bytes (epoch 8 + AEAD tag 16)</text>
<rect x="55" y="140" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="315" y="164" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ephemeral_pubkey</text>
<text x="315" y="182" fill="#f8c88a" text-anchor="middle" font-size="10">33 bytes (compressed secp256k1)</text>
<!-- Total -->
<text x="310" y="366" fill="#777" font-size="10" text-anchor="middle">total: 69 bytes · payload_len = 65 · pattern: e, ee, se</text>
<text x="310" y="210" fill="#777" font-size="10" text-anchor="middle">total: 41 bytes &#183; payload_len = 37 &#183; pattern: &#8594; e</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

+82
View File
@@ -0,0 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 478" font-family="monospace" font-size="13">
<!-- Background -->
<rect width="580" height="478" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">Noise XX Message 2 &#8212; phase 0x2 (118&#8211;144 bytes)</text>
<!-- Row 0: Common prefix (bytes 0-3) -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0&#8211;3</text>
<rect x="55" y="36" width="95" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
<text x="102" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ver</text>
<text x="102" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">4 bits</text>
<rect x="150" y="36" width="95" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="197" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">phase</text>
<text x="197" y="78" fill="#8af8c8" text-anchor="middle" font-size="10">4 bits</text>
<rect x="245" y="36" width="130" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="310" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">flags</text>
<text x="310" y="78" fill="#f8c88a" text-anchor="middle" font-size="10">1 byte</text>
<rect x="375" y="36" width="200" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="475" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">payload_len</text>
<text x="475" y="78" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE</text>
<!-- Row 1: sender_idx (bytes 4-7) -->
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4&#8211;7</text>
<rect x="55" y="88" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="185" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sender_idx</text>
<text x="185" y="130" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- receiver_idx (bytes 8-11) -->
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end"/>
<rect x="315" y="88" width="260" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="445" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">receiver_idx</text>
<text x="445" y="130" fill="#8af8c8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Noise XX msg2 bracket -->
<text x="10" y="290" fill="#777" font-size="10" text-anchor="middle" transform="rotate(-90, 10, 290)">Noise XX msg2 (106 bytes base)</text>
<line x1="18" y1="140" x2="18" y2="400" stroke="#555" stroke-width="1"/>
<line x1="18" y1="140" x2="23" y2="140" stroke="#555" stroke-width="1"/>
<line x1="18" y1="400" x2="23" y2="400" stroke="#555" stroke-width="1"/>
<!-- Row 2: ephemeral_pubkey (bytes 12-44, 33 bytes) -->
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">12&#8211;44</text>
<rect x="55" y="140" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="315" y="164" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ephemeral_pubkey</text>
<text x="315" y="182" fill="#f8c88a" text-anchor="middle" font-size="10">33 bytes (compressed secp256k1)</text>
<!-- Row 3: encrypted_static (bytes 45-93, 49 bytes) -->
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">45&#8211;93</text>
<rect x="55" y="192" width="520" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
<text x="315" y="216" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_static</text>
<text x="315" y="234" fill="#f88aaa" text-anchor="middle" font-size="10">49 bytes (static key 33 + AEAD tag 16)</text>
<!-- Row 4: encrypted_epoch (bytes 94-117, 24 bytes) -->
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">94&#8211;117</text>
<rect x="55" y="244" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="315" y="268" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_epoch</text>
<text x="315" y="286" fill="#d8a0f8" text-anchor="middle" font-size="10">24 bytes (epoch 8 + AEAD tag 16)</text>
<!-- Row 5: negotiation payload (variable, optional) -->
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">118+</text>
<rect x="55" y="296" width="520" height="52" fill="#2d4a4a" stroke="#4a9090" stroke-width="1.5" rx="3" stroke-dasharray="4,3"/>
<text x="315" y="320" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted negotiation payload</text>
<text x="315" y="338" fill="#8ad8d8" text-anchor="middle" font-size="10">26 bytes typical (10 payload + 16 AEAD tag)</text>
<!-- Row 6: AEAD explanation -->
<rect x="55" y="362" width="520" height="38" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
<text x="315" y="386" fill="#888" text-anchor="middle" font-size="10">negotiation appended via encrypt_payload() &#8212; extends Noise hash chain</text>
<!-- Total -->
<text x="310" y="425" fill="#777" font-size="10" text-anchor="middle">base: 118 bytes &#183; with FMP negotiation: 144 bytes</text>
<text x="310" y="442" fill="#777" font-size="10" text-anchor="middle">pattern: &#8592; e, ee, s, es</text>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

+72
View File
@@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 426" font-family="monospace" font-size="13">
<!-- Background -->
<rect width="580" height="426" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">Noise XX Message 3 &#8212; phase 0x3 (85&#8211;111 bytes)</text>
<!-- Row 0: Common prefix (bytes 0-3) -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0&#8211;3</text>
<rect x="55" y="36" width="95" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
<text x="102" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ver</text>
<text x="102" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">4 bits</text>
<rect x="150" y="36" width="95" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="197" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">phase</text>
<text x="197" y="78" fill="#8af8c8" text-anchor="middle" font-size="10">4 bits</text>
<rect x="245" y="36" width="130" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="310" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">flags</text>
<text x="310" y="78" fill="#f8c88a" text-anchor="middle" font-size="10">1 byte</text>
<rect x="375" y="36" width="200" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="475" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">payload_len</text>
<text x="475" y="78" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE</text>
<!-- Row 1: sender_idx + receiver_idx (bytes 4-11) -->
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4&#8211;11</text>
<rect x="55" y="88" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="185" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sender_idx</text>
<text x="185" y="130" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<rect x="315" y="88" width="260" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="445" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">receiver_idx</text>
<text x="445" y="130" fill="#8af8c8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Noise XX msg3 bracket -->
<text x="10" y="240" fill="#777" font-size="10" text-anchor="middle" transform="rotate(-90, 10, 240)">Noise XX msg3 (73 bytes base)</text>
<line x1="18" y1="140" x2="18" y2="348" stroke="#555" stroke-width="1"/>
<line x1="18" y1="140" x2="23" y2="140" stroke="#555" stroke-width="1"/>
<line x1="18" y1="348" x2="23" y2="348" stroke="#555" stroke-width="1"/>
<!-- Row 2: encrypted_static (bytes 12-60, 49 bytes) -->
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">12&#8211;60</text>
<rect x="55" y="140" width="520" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
<text x="315" y="164" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_static</text>
<text x="315" y="182" fill="#f88aaa" text-anchor="middle" font-size="10">49 bytes (static key 33 + AEAD tag 16)</text>
<!-- Row 3: encrypted_epoch (bytes 61-84, 24 bytes) -->
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">61&#8211;84</text>
<rect x="55" y="192" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="315" y="216" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_epoch</text>
<text x="315" y="234" fill="#d8a0f8" text-anchor="middle" font-size="10">24 bytes (epoch 8 + AEAD tag 16)</text>
<!-- Row 4: negotiation payload (variable, optional) -->
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">85+</text>
<rect x="55" y="244" width="520" height="52" fill="#2d4a4a" stroke="#4a9090" stroke-width="1.5" rx="3" stroke-dasharray="4,3"/>
<text x="315" y="268" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted negotiation payload</text>
<text x="315" y="286" fill="#8ad8d8" text-anchor="middle" font-size="10">26 bytes typical (10 payload + 16 AEAD tag)</text>
<!-- Row 5: explanation -->
<rect x="55" y="310" width="520" height="38" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
<text x="315" y="334" fill="#888" text-anchor="middle" font-size="10">negotiation appended via encrypt_payload() &#8212; extends Noise hash chain</text>
<!-- Total -->
<text x="310" y="378" fill="#777" font-size="10" text-anchor="middle">base: 85 bytes &#183; with FMP negotiation: 111 bytes</text>
<text x="310" y="395" fill="#777" font-size="10" text-anchor="middle">pattern: &#8594; s, se</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+58 -67
View File
@@ -3,104 +3,95 @@
<rect width="580" height="634" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">ReceiverReport (0x02) — 68 bytes</text>
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">ReceiverReport (0x02) &#8212; 54 bytes</text>
<!-- Row 0 (03): msg_type + reserved -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">03</text>
<!-- Row 0 (0-3): msg_type + format_version + total_length -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0&#8211;3</text>
<rect x="55" y="36" width="130" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
<text x="120" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">msg_type</text>
<text x="120" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">0x02</text>
<text x="120" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">msg_type</text>
<text x="120" y="74" fill="#8ab4f8" text-anchor="middle" font-size="10">0x02</text>
<rect x="185" y="36" width="390" height="52" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
<text x="380" y="66" fill="#666" text-anchor="middle" font-size="12">reserved (3 bytes zero)</text>
<rect x="185" y="36" width="130" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="250" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">fmt_ver</text>
<text x="250" y="74" fill="#8af8c8" text-anchor="middle" font-size="10">1 byte</text>
<!-- Row 1 (411): highest_counter -->
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">411</text>
<rect x="315" y="36" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="445" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">total_length</text>
<text x="445" y="74" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE (= 50)</text>
<!-- Row 1 (4-7): timestamp_echo -->
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4&#8211;7</text>
<rect x="55" y="88" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="315" y="110" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">highest_counter</text>
<text x="315" y="128" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE</text>
<text x="315" y="110" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">timestamp_echo</text>
<text x="315" y="128" fill="#f8c88a" text-anchor="middle" font-size="10">4 bytes LE (echoed sender timestamp for RTT)</text>
<!-- Row 2 (1219): cumulative_packets_recv -->
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">1219</text>
<!-- Row 2 (8-9): dwell_time -->
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">8&#8211;9</text>
<rect x="55" y="140" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_packets_recv</text>
<text x="315" y="180" fill="#8af8c8" text-anchor="middle" font-size="10">8 bytes LE</text>
<rect x="55" y="140" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">dwell_time</text>
<text x="315" y="180" fill="#8af8f8" text-anchor="middle" font-size="10">2 bytes LE (ms between receive and echo)</text>
<!-- Row 3 (2027): cumulative_bytes_recv -->
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">2027</text>
<!-- Row 3 (10-17): highest_counter -->
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">10&#8211;17</text>
<rect x="55" y="192" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="315" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_bytes_recv</text>
<text x="315" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">highest_counter</text>
<text x="315" y="232" fill="#8af8c8" text-anchor="middle" font-size="10">8 bytes LE</text>
<!-- Row 4 (2833): timestamp_echo + dwell_time -->
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">2833</text>
<!-- Row 4 (18-25): cumulative_packets_recv -->
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">18&#8211;25</text>
<rect x="55" y="244" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="185" y="266" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">timestamp_echo</text>
<text x="185" y="284" fill="#d8a0f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<rect x="55" y="244" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="315" y="266" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_packets_recv</text>
<text x="315" y="284" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE</text>
<rect x="315" y="244" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="445" y="266" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">dwell_time</text>
<text x="445" y="284" fill="#8af8f8" text-anchor="middle" font-size="10">2 bytes LE ms</text>
<!-- Row 5 (26-33): cumulative_bytes_recv -->
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">26&#8211;33</text>
<!-- Row 5 (3439): max_burst_loss + mean_burst_loss + reserved -->
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">3439</text>
<rect x="55" y="296" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="315" y="318" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_bytes_recv</text>
<text x="315" y="336" fill="#8af8f8" text-anchor="middle" font-size="10">8 bytes LE</text>
<rect x="55" y="296" width="173" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
<text x="141" y="318" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">max_burst</text>
<text x="141" y="336" fill="#f88aaa" text-anchor="middle" font-size="10">2 bytes LE</text>
<!-- Row 6 (34-41): jitter + ecn_ce_count -->
<text x="50" y="374" fill="#666" font-size="10" text-anchor="end">34&#8211;41</text>
<rect x="228" y="296" width="174" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
<text x="315" y="318" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">mean_burst</text>
<text x="315" y="336" fill="#f88aaa" text-anchor="middle" font-size="10">2 bytes u8.8</text>
<rect x="402" y="296" width="173" height="52" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
<text x="488" y="322" fill="#666" text-anchor="middle" font-size="10">reserved</text>
<!-- Row 6 (4047): jitter + ecn_ce_count -->
<text x="50" y="374" fill="#666" font-size="10" text-anchor="end">4047</text>
<rect x="55" y="348" width="260" height="52" fill="#4a3d2d" stroke="#d9b04a" stroke-width="1.5" rx="3"/>
<rect x="55" y="348" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="185" y="370" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">jitter</text>
<text x="185" y="388" fill="#f8d88a" text-anchor="middle" font-size="10">4 bytes LE µs</text>
<text x="185" y="388" fill="#d8a0f8" text-anchor="middle" font-size="10">4 bytes LE (&#181;s)</text>
<rect x="315" y="348" width="260" height="52" fill="#4a3d2d" stroke="#d9b04a" stroke-width="1.5" rx="3"/>
<rect x="315" y="348" width="260" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="445" y="370" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ecn_ce_count</text>
<text x="445" y="388" fill="#f8d88a" text-anchor="middle" font-size="10">4 bytes LE</text>
<text x="445" y="388" fill="#8af8c8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Row 7 (4855): owd_trend + burst_loss_count -->
<text x="50" y="426" fill="#666" font-size="10" text-anchor="end">4855</text>
<!-- Row 7 (42-49): owd_trend + burst_loss_count -->
<text x="50" y="426" fill="#666" font-size="10" text-anchor="end">42&#8211;49</text>
<rect x="55" y="400" width="260" height="52" fill="#3d4a2d" stroke="#8ad94a" stroke-width="1.5" rx="3"/>
<rect x="55" y="400" width="260" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="185" y="422" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">owd_trend</text>
<text x="185" y="440" fill="#c8f88a" text-anchor="middle" font-size="10">4 bytes i32 LE µs/s</text>
<text x="185" y="440" fill="#f8c88a" text-anchor="middle" font-size="10">i32 LE (&#181;s/s)</text>
<rect x="315" y="400" width="260" height="52" fill="#3d4a2d" stroke="#8ad94a" stroke-width="1.5" rx="3"/>
<rect x="315" y="400" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="445" y="422" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">burst_loss_count</text>
<text x="445" y="440" fill="#c8f88a" text-anchor="middle" font-size="10">4 bytes LE</text>
<text x="445" y="440" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Row 8 (5663): cumulative_reorder_count + interval_packets_recv -->
<text x="50" y="478" fill="#666" font-size="10" text-anchor="end">5663</text>
<!-- Row 8 (50-53): cumulative_reorder_count -->
<text x="50" y="478" fill="#666" font-size="10" text-anchor="end">50&#8211;53</text>
<rect x="55" y="452" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="185" y="474" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">reorder_count</text>
<text x="185" y="492" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE cumulative</text>
<rect x="55" y="452" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="315" y="474" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_reorder_count</text>
<text x="315" y="492" fill="#d8a0f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<rect x="315" y="452" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="445" y="474" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">interval_pkts_recv</text>
<text x="445" y="492" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Forward compat note -->
<rect x="55" y="518" width="520" height="32" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
<text x="315" y="538" fill="#888" text-anchor="middle" font-size="10">decoders skip trailing bytes beyond total_length (forward compatibility)</text>
<!-- Row 9 (6467): interval_bytes_recv -->
<text x="50" y="530" fill="#666" font-size="10" text-anchor="end">6467</text>
<rect x="55" y="504" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="185" y="526" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">interval_bytes_recv</text>
<text x="185" y="544" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Removed fields note -->
<rect x="55" y="558" width="520" height="32" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
<text x="315" y="578" fill="#666" text-anchor="middle" font-size="10">removed from v0.2: max_burst_loss, mean_burst_loss, interval_packets/bytes_recv</text>
<!-- Total -->
<text x="310" y="586" fill="#777" font-size="10" text-anchor="middle">total: 68 bytes</text>
<text x="310" y="618" fill="#777" font-size="10" text-anchor="middle">total: 54 bytes &#183; format_version = 0 &#183; total_length = 50</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

+32 -48
View File
@@ -1,66 +1,50 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 478" font-family="monospace" font-size="13">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 322" font-family="monospace" font-size="13">
<!-- Background -->
<rect width="580" height="478" fill="#1a1a2e" rx="4"/>
<rect width="580" height="322" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SenderReport (0x01) — 48 bytes</text>
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SenderReport (0x01) &#8212; 20 bytes</text>
<!-- Row 0 (03): msg_type + reserved -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">03</text>
<!-- Row 0 (0-3): msg_type + format_version + total_length -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0&#8211;3</text>
<rect x="55" y="36" width="130" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
<text x="120" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">msg_type</text>
<text x="120" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">0x01</text>
<text x="120" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">msg_type</text>
<text x="120" y="74" fill="#8ab4f8" text-anchor="middle" font-size="10">0x01</text>
<rect x="185" y="36" width="390" height="52" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
<text x="380" y="66" fill="#666" text-anchor="middle" font-size="12">reserved (3 bytes zero)</text>
<rect x="185" y="36" width="130" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="250" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">fmt_ver</text>
<text x="250" y="74" fill="#8af8c8" text-anchor="middle" font-size="10">1 byte</text>
<!-- Row 1 (411): interval_start_counter -->
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">411</text>
<rect x="315" y="36" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="445" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">total_length</text>
<text x="445" y="74" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE (= 16)</text>
<!-- Row 1 (4-7): interval_packets_sent -->
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4&#8211;7</text>
<rect x="55" y="88" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="315" y="110" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_start_counter</text>
<text x="315" y="128" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE</text>
<text x="315" y="110" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_packets_sent</text>
<text x="315" y="128" fill="#f8c88a" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Row 2 (1219): interval_end_counter -->
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">1219</text>
<!-- Row 2 (8-11): interval_bytes_sent -->
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">8&#8211;11</text>
<rect x="55" y="140" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_end_counter</text>
<text x="315" y="180" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE</text>
<rect x="55" y="140" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_bytes_sent</text>
<text x="315" y="180" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Row 3 (2027): interval_start_timestamp + interval_end_timestamp -->
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">2027</text>
<!-- Row 3 (12-19): cumulative_packets_sent -->
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">12&#8211;19</text>
<rect x="55" y="192" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="185" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_start_ts</text>
<text x="185" y="232" fill="#d8a0f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<rect x="55" y="192" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="315" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_packets_sent</text>
<text x="315" y="232" fill="#8af8c8" text-anchor="middle" font-size="10">8 bytes LE</text>
<rect x="315" y="192" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
<text x="445" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_end_ts</text>
<text x="445" y="232" fill="#d8a0f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Row 4 (2831): interval_bytes_sent -->
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">2831</text>
<rect x="55" y="244" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
<text x="315" y="266" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_bytes_sent</text>
<text x="315" y="284" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
<!-- Row 5 (3239): cumulative_packets_sent -->
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">3239</text>
<rect x="55" y="296" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="315" y="318" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_packets_sent</text>
<text x="315" y="336" fill="#8af8c8" text-anchor="middle" font-size="10">8 bytes LE</text>
<!-- Row 6 (4047): cumulative_bytes_sent -->
<text x="50" y="374" fill="#666" font-size="10" text-anchor="end">4047</text>
<rect x="55" y="348" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="315" y="370" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_bytes_sent</text>
<text x="315" y="388" fill="#8af8c8" text-anchor="middle" font-size="10">8 bytes LE</text>
<!-- Forward compat note -->
<rect x="55" y="258" width="520" height="32" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
<text x="315" y="278" fill="#888" text-anchor="middle" font-size="10">decoders skip trailing bytes beyond total_length (forward compatibility)</text>
<!-- Total -->
<text x="310" y="422" fill="#777" font-size="10" text-anchor="middle">total: 48 bytes</text>
<text x="310" y="314" fill="#777" font-size="10" text-anchor="middle">total: 20 bytes &#183; format_version = 0 &#183; total_length = 16</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

+3 -3
View File
@@ -3,7 +3,7 @@
<rect width="580" height="322" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionAck (phase 0x2) Noise XK msg2</text>
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionAck (phase 0x2) &#8212; Noise XX msg2</text>
<!-- Row 0 (03): FSP prefix -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">03</text>
@@ -40,8 +40,8 @@
<rect x="185" y="232" width="390" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="380" y="254" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">handshake_payload</text>
<text x="380" y="272" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XK msg2 (57 bytes ephemeral + epoch)</text>
<text x="380" y="272" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XX msg2 (106+ bytes &#8212; ephemeral + static + epoch)</text>
<!-- Total -->
<text x="310" y="308" fill="#777" font-size="10" text-anchor="middle">typical ~190 bytes (depth-dependent, carries both endpoints' coords)</text>
<text x="310" y="308" fill="#777" font-size="10" text-anchor="middle">typical ~240 bytes (depth-dependent, carries both endpoints' coords)</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

+2 -2
View File
@@ -3,7 +3,7 @@
<rect width="580" height="244" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionMsg3 (phase 0x3) Noise XK msg3</text>
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionMsg3 (phase 0x3) &#8212; Noise XX msg3</text>
<!-- Row 0 (03): FSP prefix -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">03</text>
@@ -28,7 +28,7 @@
<rect x="55" y="140" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">handshake_payload</text>
<text x="315" y="180" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XK msg3 (73 bytes encrypted static + encrypted epoch)</text>
<text x="315" y="180" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XX msg3 (73+ bytes &#8212; encrypted static + epoch + optional negotiation)</text>
<!-- Total -->
<text x="310" y="218" fill="#777" font-size="10" text-anchor="middle">~80 bytes · no coordinates (both endpoints already have them)</text>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

+2 -2
View File
@@ -3,7 +3,7 @@
<rect width="580" height="322" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionSetup (phase 0x1) Noise XK msg1</text>
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionSetup (phase 0x1) &#8212; Noise XX msg1</text>
<!-- Row 0 (03): FSP prefix -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">03</text>
@@ -40,7 +40,7 @@
<rect x="185" y="232" width="390" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
<text x="380" y="254" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">handshake_payload</text>
<text x="380" y="272" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XK msg1 (33 bytes ephemeral key)</text>
<text x="380" y="272" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XX msg1 (33 bytes &#8212; ephemeral key)</text>
<!-- Total -->
<text x="310" y="308" fill="#777" font-size="10" text-anchor="middle">typical ~170 bytes (depth-dependent)</text>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

+6 -6
View File
@@ -55,14 +55,14 @@ idempotent).
| Component | Choice | Where Used |
| --------- | ------ | ---------- |
| Curve | secp256k1 | FMP IK, FSP XK, Schnorr signatures |
| Diffie-Hellman | ECDH on secp256k1 (x-only normalized) | Noise IK, Noise XK |
| Curve | secp256k1 | FMP XX, FSP XX, Schnorr signatures |
| Diffie-Hellman | ECDH on secp256k1 (x-only normalized) | Noise XX (both layers) |
| AEAD | ChaCha20-Poly1305 | FMP link encryption, FSP session encryption |
| Hash | SHA-256 | NodeAddr derivation, Noise transcript |
| Key derivation | HKDF-SHA256 | Noise key schedule |
| Signatures | secp256k1 Schnorr | TreeAnnounce, LookupResponse proof, Nostr adverts |
| Noise pattern (link) | `Noise_IK_secp256k1_ChaChaPoly_SHA256` | FMP link layer (IK with epoch payload) |
| Noise pattern (session) | `Noise_XK_secp256k1_ChaChaPoly_SHA256` | FSP session layer (XK with epoch payload) |
| Noise pattern (link) | `Noise_XX_secp256k1_ChaChaPoly_SHA256` | FMP link layer (XX with negotiation payload) |
| Noise pattern (session) | `Noise_XX_secp256k1_ChaChaPoly_SHA256` | FSP session layer (XX with negotiation payload) |
These choices align with the Nostr cryptographic stack
(secp256k1 + ChaCha20-Poly1305 + SHA-256) and the NIP-44 encrypted
@@ -101,7 +101,7 @@ and FSP layers.
Mesh-level ACL files at `/etc/fips/peers.allow` and
`/etc/fips/peers.deny` give the operator allowlist/blocklist control
over which npubs may complete the FMP Noise IK link handshake.
over which npubs may complete the FMP Noise XX link handshake.
File format:
@@ -228,7 +228,7 @@ way to restrict inbound traffic on `fips0`. See
- [../design/fips-mesh-layer.md](../design/fips-mesh-layer.md) — FMP
link encryption, replay protection, rate limiting
- [../design/fips-session-layer.md](../design/fips-session-layer.md)
— FSP end-to-end encryption, Noise XK, replay window
— FSP end-to-end encryption, Noise XX, replay window
- [../how-to/enable-mesh-firewall.md](../how-to/enable-mesh-firewall.md)
— operator activation and drop-in recipes
- [configuration.md](configuration.md) — full `node.rekey.*`,
+220 -122
View File
@@ -24,7 +24,7 @@ The FMP link layer defines the following message types, dispatched by the
Handshake messages travel before encryption is established and are identified
by the FMP common-prefix `phase` field rather than a `msg_type` byte
(phase 0x1 = Noise IK msg1, phase 0x2 = Noise IK msg2).
(phase 0x1 = Noise XX msg1, phase 0x2 = Noise XX msg2, phase 0x3 = Noise XX msg3).
## Packet Type Summary
@@ -33,8 +33,8 @@ A higher-level summary that includes typical sizes and forwarding category:
| Message | Typical Size | When | Forwarded? |
| ------- | ------------ | ---- | ---------- |
| TreeAnnounce | Variable (depth-dependent) | Topology changes | No (peer-to-peer) |
| FilterAnnounce | ~1 KB | Topology changes | No (peer-to-peer) |
| LookupRequest | ~300 bytes | First contact, recovery | Yes (bloom-guided tree) |
| FilterAnnounce | variable (RLE compressed) | Topology changes | No (peer-to-peer) |
| LookupRequest | 44 bytes + TLV | First contact, recovery | Yes (bloom-guided tree) |
| LookupResponse | ~400 bytes | Response to discovery | Yes (reverse-path) |
| SessionDatagram + SessionSetup | ~232402 bytes | Session establishment | Yes (routed) |
| SessionDatagram + SessionAck | ~170 bytes | Session confirmation | Yes (routed) |
@@ -63,14 +63,14 @@ stream; the common prefix `payload_len` field provides this framing
directly. TCP and Tor share a common stream reader (`tcp/stream.rs`)
that implements this framing.
**Ethernet data frame header.** The Ethernet transport prepends a 3-byte
header before the FMP payload on data frames: a 1-byte frame type
(`0x00`) followed by a 2-byte little-endian payload length. The length
field allows the receiver to trim Ethernet minimum-frame padding that
would otherwise corrupt AEAD verification. Beacon frames (`0x01`) have
no length field (fixed 34-byte payload). These bytes are consumed by the
transport layer and are not visible to FMP. The effective MTU for FMP is
the interface MTU minus three bytes (typically 1497).
**Ethernet frame header.** The Ethernet transport prepends a 4-byte
unified header before the payload: `[type:1][flags:1][length:2 LE]`.
The length field allows the receiver to trim Ethernet minimum-frame
padding that would otherwise corrupt AEAD verification. Frame types:
`0x00` (data), `0x01` (beacon). Beacons are 5 bytes total (4-byte
header + 1-byte beacon type). These bytes are consumed by the
transport layer and are not visible to FMP. The effective MTU for FMP
is the interface MTU minus four bytes (typically 1496).
## Link-Layer Formats
@@ -84,7 +84,7 @@ length.
| Field | Size | Description |
| ----- | ---- | ----------- |
| version | 4 bits (high) | Protocol version. Currently 0x0 |
| version | 4 bits (high) | Protocol version. Currently 0x1 |
| phase | 4 bits (low) | Session lifecycle phase (see table) |
| flags | 1 byte | Per-packet signal flags (zero during handshake) |
| payload_len | 2 bytes LE | Length of payload after phase-specific header, excluding AEAD tag |
@@ -94,8 +94,9 @@ length.
| Phase | Type | Description |
| ----- | ---- | ----------- |
| 0x0 | Established frame | Post-handshake encrypted traffic |
| 0x1 | Noise IK msg1 | Handshake initiation |
| 0x2 | Noise IK msg2 | Handshake response |
| 0x1 | Noise XX msg1 | Handshake initiation (ephemeral only) |
| 0x2 | Noise XX msg2 | Handshake response (responder identity + negotiation) |
| 0x3 | Noise XX msg3 | Handshake completion (initiator identity + negotiation) |
### Flags (Established Phase Only)
@@ -103,10 +104,9 @@ length.
| --- | ---- | ----------- |
| 0 | K (key epoch) | Selects active key during rekeying |
| 1 | CE | Congestion Experienced echo |
| 2 | SP (spin bit) | RTT measurement |
| 3-7 | — | Reserved (must be zero) |
| 2-7 | — | Reserved (must be zero) |
Flags must be zero in handshake packets (phase 0x1 and 0x2).
Flags must be zero in handshake packets (phase 0x1, 0x2, and 0x3).
### Established Frame (phase 0x0)
@@ -119,7 +119,7 @@ encrypted link-layer message.
| Field | Size | Description |
| ----- | ---- | ----------- |
| common prefix | 4 bytes | ver=0, phase=0, flags, payload_len |
| common prefix | 4 bytes | ver=1, phase=0, flags, payload_len |
| receiver_idx | 4 bytes LE | Session index for O(1) lookup |
| counter | 8 bytes LE | Monotonic nonce, used as AEAD nonce and for replay detection |
@@ -147,67 +147,141 @@ the 1-byte message type and message-specific fields.
| Type | Message | Description |
| ---- | ------- | ----------- |
| 0x00 | SessionDatagram | Encapsulated session-layer payload for forwarding |
| 0x01 | SenderReport | MMP sender-side metrics report (48 bytes) |
| 0x02 | ReceiverReport | MMP receiver-side metrics report (68 bytes) |
| 0x01 | SenderReport | MMP sender-side metrics report (20 bytes) |
| 0x02 | ReceiverReport | MMP receiver-side metrics report (54 bytes) |
| 0x10 | TreeAnnounce | Spanning tree state announcement |
| 0x20 | FilterAnnounce | Bloom filter reachability update |
| 0x21 | FilterNack | Bloom filter delta NACK (retransmit request) |
| 0x30 | LookupRequest | Coordinate discovery request |
| 0x31 | LookupResponse | Coordinate discovery response |
| 0x50 | Disconnect | Orderly link teardown |
| 0x51 | Heartbeat | Link liveness probe |
### Noise IK Message 1 (phase 0x1)
### Noise XX Message 1 (phase 0x1)
Handshake initiation from connecting party.
Handshake initiation from connecting party. The initiator sends only its
ephemeral key — neither side's static identity is revealed in msg1.
![Noise IK message 1](diagrams/noise-ik-msg1.svg)
![Noise XX message 1](diagrams/noise-xx-msg1.svg)
Common prefix: ver=0, phase=0x1, flags=0, payload_len=110 (4 + 106).
Common prefix: ver=1, phase=0x1, flags=0, payload_len=37 (4 + 33).
| Field | Size | Description |
| ----- | ---- | ----------- |
| common prefix | 4 bytes | ver=0, phase=1, flags=0, payload_len |
| common prefix | 4 bytes | ver=1, phase=1, flags=0, payload_len |
| sender_idx | 4 bytes LE | Initiator's session index (becomes receiver's `receiver_idx`) |
| noise_msg1 | 106 bytes | Noise IK first message |
| noise_msg1 | 33 bytes | Noise XX first message |
**Noise msg1 breakdown** (106 bytes):
**Noise msg1 breakdown** (33 bytes):
| Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- |
| 0 | ephemeral_pubkey | 33 bytes | Initiator's ephemeral key (compressed secp256k1) |
| 33 | encrypted_static | 49 bytes | Initiator's static key (33) + AEAD tag (16) |
| 82 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) |
Noise pattern: `-> e, es, s, ss` with epoch payload
Noise pattern: `-> e`
### Noise IK Message 2 (phase 0x2)
**Total wire size**: 41 bytes (4 prefix + 4 sender_idx + 33 noise).
Handshake response from responder.
### Noise XX Message 2 (phase 0x2)
![Noise IK message 2](diagrams/noise-ik-msg2.svg)
Handshake response from responder. The responder reveals its identity
(ephemeral key, encrypted static key, and encrypted epoch) plus an
optional protocol negotiation payload.
Common prefix: ver=0, phase=0x2, flags=0, payload_len=65 (4 + 4 + 57).
![Noise XX message 2](diagrams/noise-xx-msg2.svg)
Common prefix: ver=1, phase=0x2, flags=0, payload_len varies.
| Field | Size | Description |
| ----- | ---- | ----------- |
| common prefix | 4 bytes | ver=0, phase=2, flags=0, payload_len |
| common prefix | 4 bytes | ver=1, phase=2, flags=0, payload_len |
| sender_idx | 4 bytes LE | Responder's session index |
| receiver_idx | 4 bytes LE | Echo of initiator's sender_idx from msg1 |
| noise_msg2 | 57 bytes | Noise IK second message |
| noise_msg2 | 106+ bytes | Noise XX second message (variable with negotiation) |
**Noise msg2 breakdown** (57 bytes):
**Noise msg2 breakdown** (106 bytes base):
| Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- |
| 0 | ephemeral_pubkey | 33 bytes | Responder's ephemeral key (compressed secp256k1) |
| 33 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) |
| 33 | encrypted_static | 49 bytes | Responder's static key (33) + AEAD tag (16) |
| 82 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) |
Noise pattern: `<- e, ee, se` with epoch payload
Noise pattern: `<- e, ee, s, es` with epoch and negotiation payload
After msg2, both parties derive identical symmetric session keys. The
encrypted epoch in msg1 and msg2 enables peer restart detection — if a
peer's epoch changes, the other side knows it restarted and must
re-establish the link.
**Negotiation payload** (variable, appended via `encrypt_payload()`):
encrypted negotiation bytes + AEAD tag. Minimum 26 bytes (10 payload +
16 tag) when present. See [Protocol Negotiation Payload](#protocol-negotiation-payload).
**Total wire size**: 118 bytes minimum (without negotiation), 144 bytes
typical (with FMP negotiation: 118 + 26).
After msg2, the responder's identity is known to the initiator.
### Noise XX Message 3 (phase 0x3)
Handshake completion from initiator. The initiator reveals its encrypted
static identity and epoch, plus an optional negotiation payload.
![Noise XX message 3](diagrams/noise-xx-msg3.svg)
| Field | Size | Description |
| ----- | ---- | ----------- |
| common prefix | 4 bytes | ver=1, phase=3, flags=0, payload_len |
| sender_idx | 4 bytes LE | Echo of initiator's sender_idx |
| receiver_idx | 4 bytes LE | Echo of responder's sender_idx from msg2 |
| noise_msg3 | 73+ bytes | Noise XX third message (variable with negotiation) |
**Noise msg3 breakdown** (73 bytes base):
| Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- |
| 0 | encrypted_static | 49 bytes | Initiator's static key (33) + AEAD tag (16) |
| 49 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) |
Noise pattern: `-> s, se` with epoch and negotiation payload
**Negotiation payload** (variable, appended via `encrypt_payload()`):
same format as msg2. Minimum 26 bytes when present.
**Total wire size**: 85 bytes minimum (without negotiation), 111 bytes
typical (with FMP negotiation: 85 + 26).
After msg3, both parties derive identical symmetric session keys and
have exchanged negotiation payloads. The encrypted epoch in msg2 and
msg3 enables peer restart detection — if a peer's epoch changes, the
other side knows it restarted and must re-establish the link.
### Protocol Negotiation Payload
Appended to XX msg2 and msg3 via Noise `encrypt_payload()` — extends
the Noise hash chain so negotiation data is authenticated alongside the
handshake transcript.
**Wire format**: `[format:1][versions:1][features:8][TLV entries...]`
| Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- |
| 0 | format | 1 byte | Must be 0 |
| 1 | versions | 1 byte | High nibble = version_min, low nibble = version_max |
| 2 | features | 8 bytes LE | 64-bit feature bitfield |
| 10 | tlv_entries | variable | TLV extensions: `[field_num:2 LE][length:2 LE][value:N]` per entry |
**Minimum size**: 10 bytes (no TLV entries). With AEAD tag: 26 bytes on
the wire.
**FMP feature bits**:
| Bits | Name | Description |
| ---- | ---- | ----------- |
| 0-2 | Node profile | Full (0), NonRouting (1), Leaf (2) |
| 3-6 | MMP wants/provides | MMP capability negotiation |
| 7 | Bloom | Bloom filter capability |
| 8-63 | — | Reserved |
**Critical**: `decrypt_payload()` MUST be called even if the result is
discarded, to maintain hash chain consistency between the two Noise
transport channels.
### Index Semantics
@@ -269,28 +343,49 @@ includes self)
### FilterAnnounce (0x20)
Bloom filter reachability update, exchanged between direct peers only.
Supports both full sends and delta (XOR diff) updates with RLE
compression.
![FilterAnnounce](diagrams/filter-announce.svg)
| Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- |
| 0 | msg_type | 1 byte | 0x20 |
| 1 | sequence | 8 bytes LE | Monotonic counter for freshness |
| 9 | hash_count | 1 byte | Number of hash functions (5 in v1) |
| 10 | size_class | 1 byte | Filter size: `512 << size_class` bytes |
| 11 | filter_bits | variable | Bloom filter bit array |
| 1 | flags | 1 byte | Bit 0: delta (XOR diff), bits 1-7 reserved |
| 2 | sequence | 8 bytes LE | Monotonic counter, per-peer (only increments on actual send) |
| 10 | base_seq | 8 bytes LE | Sequence of reference filter for delta (0 if full send) |
| 18 | size_class | 1 byte | Filter size: `512 << size_class` bytes |
| 19 | compressed_payload | variable | RLE-encoded filter or XOR diff |
**RLE format**: each run = `[count:2 LE][word:8 LE]` (10 bytes per run).
Sparse XOR diffs compress to very few runs.
**Size class table**:
| size_class | Bytes | Bits | Status |
| ---------- | ----- | ---- | ------ |
| 0 | 512 | 4,096 | Reserved |
| 1 | 1,024 | 8,192 | **v1 (MUST use)** |
| 2 | 2,048 | 16,384 | Reserved |
| 3 | 4,096 | 32,768 | Reserved |
| size_class | Bytes | Bits | Notes |
| ---------- | ----- | ---- | ----- |
| 0 | 512 | 4,096 | Minimum |
| 1 | 1,024 | 8,192 | Default |
| 2 | 2,048 | 16,384 | |
| 3 | 4,096 | 32,768 | |
| 4 | 8,192 | 65,536 | |
| 5 | 16,384 | 131,072 | |
| 6 | 32,768 | 262,144 | Maximum |
**v1 payload**: 1,035 bytes (11 header + 1,024 filter).
With link overhead: 1,072 bytes.
**Size**: variable (19-byte header + RLE-compressed payload).
### FilterNack (0x21)
Request full filter retransmission. Sent when a node receives an
out-of-sequence delta update (sequence gap detected), triggering a
full retransmit from the sender.
| Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- |
| 0 | msg_type | 1 byte | 0x21 |
| 1 | expected_seq | 8 bytes LE | Sequence number the receiver expected |
**Total**: 9 bytes.
### LookupRequest (0x30)
@@ -310,16 +405,10 @@ restructuring.
| 25 | origin | 16 bytes | Requester's NodeAddr |
| 41 | ttl | 1 byte | Remaining hops (default 64) |
| 42 | min_mtu | 2 bytes LE | Minimum transport MTU the origin requires (0 = no requirement) |
| 44 | origin_coords_cnt | 2 bytes LE | Number of coordinate entries |
| 46 | origin_coords | 16 x n bytes | Requester's ancestry (NodeAddr only) |
| 44 | tlv_entries | variable | TLV extensions, forwarded verbatim by transit |
**Size**: `46 + (n x 16)` bytes, where n = origin depth + 1
| Origin Depth | Payload |
| ------------ | ------- |
| 3 | 110 bytes |
| 5 | 142 bytes |
| 10 | 222 bytes |
**Size**: 44 bytes fixed + TLV entries. TLV format:
`[field_num:2 LE][length:2 LE][value:N]` per entry.
### LookupResponse (0x31)
@@ -337,11 +426,12 @@ requester via the transit nodes that forwarded the request.
| 27 | target_coords_cnt | 2 bytes LE | Number of coordinate entries |
| 29 | target_coords | 16 x n bytes | Target's ancestry (NodeAddr only) |
| 29 + 16n | proof | 64 bytes | Schnorr signature over `(request_id \|\| target \|\| target_coords)` |
| 93 + 16n | tlv_entries | variable | TLV extensions, forwarded verbatim by transit |
**Size**: `93 + (n x 16)` bytes
**Size**: `93 + (n x 16)` bytes + TLV entries
| Target Depth | Payload |
| ------------ | ------- |
| Target Depth | Payload (no TLV) |
| ------------ | ---------------- |
| 3 | 141 bytes |
| 5 | 173 bytes |
| 10 | 253 bytes |
@@ -414,16 +504,16 @@ Sent by the frame sender to provide interval-based transmission statistics.
| Offset | Field | Size | Encoding |
| ------ | ----- | ---- | -------- |
| 0 | msg_type | 1 | `0x01` |
| 1 | reserved | 3 | Zero |
| 4 | interval_start_counter | 8 | u64 LE — first counter in this interval |
| 12 | interval_end_counter | 8 | u64 LE — last counter in this interval |
| 20 | interval_start_timestamp | 4 | u32 LE — timestamp at interval start |
| 24 | interval_end_timestamp | 4 | u32 LE — timestamp at interval end |
| 28 | interval_bytes_sent | 4 | u32 LE — payload bytes sent in interval |
| 32 | cumulative_packets_sent | 8 | u64 LE — total packets sent on this link |
| 40 | cumulative_bytes_sent | 8 | u64 LE — total bytes sent on this link |
| 1 | format_version | 1 | 0 (current) |
| 2 | total_length | 2 | u16 LE — 16 (v0 payload size) |
| 4 | interval_packets_sent | 4 | u32 LE — packets sent in interval |
| 8 | interval_bytes_sent | 4 | u32 LE — payload bytes sent in interval |
| 12 | cumulative_packets_sent | 8 | u64 LE — total packets sent on this link |
**Total: 48 bytes.**
**Total: 20 bytes.**
Decoders skip trailing bytes beyond `total_length` for forward
compatibility with future format versions.
### ReceiverReport (0x02)
@@ -434,24 +524,26 @@ Sent by the frame receiver to provide loss, jitter, and timing feedback.
| Offset | Field | Size | Encoding |
| ------ | ----- | ---- | -------- |
| 0 | msg_type | 1 | `0x02` |
| 1 | reserved | 3 | Zero |
| 4 | highest_counter | 8 | u64 LE — highest counter value received |
| 12 | cumulative_packets_recv | 8 | u64 LE — total packets received |
| 20 | cumulative_bytes_recv | 8 | u64 LE — total bytes received |
| 28 | timestamp_echo | 4 | u32 LE — echoed sender timestamp for RTT |
| 32 | dwell_time | 2 | u16 LE — time between receive and echo (ms) |
| 34 | max_burst_loss | 2 | u16 LE — largest loss burst in interval |
| 36 | mean_burst_loss | 2 | u16 LE — mean burst length (u8.8 fixed-point) |
| 38 | reserved | 2 | Zero |
| 40 | jitter | 4 | u32 LE — interarrival jitter (microseconds) |
| 44 | ecn_ce_count | 4 | u32 LE — cumulative ECN-CE marked packets |
| 48 | owd_trend | 4 | i32 LE — one-way delay trend (µs/s, signed) |
| 52 | burst_loss_count | 4 | u32 LE — number of loss bursts in interval |
| 56 | cumulative_reorder_count | 4 | u32 LE — total reordered packets |
| 60 | interval_packets_recv | 4 | u32 LE — packets received in interval |
| 64 | interval_bytes_recv | 4 | u32 LE — bytes received in interval |
| 1 | format_version | 1 | 0 (current) |
| 2 | total_length | 2 | u16 LE — 50 (v0 payload size) |
| 4 | timestamp_echo | 4 | u32 LE — echoed sender timestamp for RTT |
| 8 | dwell_time | 2 | u16 LE — time between receive and echo (ms) |
| 10 | highest_counter | 8 | u64 LE — highest counter value received |
| 18 | cumulative_packets_recv | 8 | u64 LE — total packets received |
| 26 | cumulative_bytes_recv | 8 | u64 LE — total bytes received |
| 34 | jitter | 4 | u32 LE — interarrival jitter (microseconds) |
| 38 | ecn_ce_count | 4 | u32 LE — cumulative ECN-CE marked packets |
| 42 | owd_trend | 4 | i32 LE — one-way delay trend (µs/s, signed) |
| 46 | burst_loss_count | 4 | u32 LE — number of loss bursts in interval |
| 50 | cumulative_reorder_count | 4 | u32 LE — total reordered packets |
**Total: 68 bytes.**
**Total: 54 bytes.**
Fields removed from v0.2: `max_burst_loss`, `mean_burst_loss`,
`interval_packets_recv`, `interval_bytes_recv`.
Decoders skip trailing bytes beyond `total_length` for forward
compatibility with future format versions.
## Session-Layer Message Formats
@@ -473,9 +565,9 @@ protocol version, session lifecycle phase, per-packet flags, and payload length.
| Phase | Type | Description |
| ----- | ---- | ----------- |
| 0x0 | Established | Post-handshake encrypted traffic or plaintext error signals |
| 0x1 | Handshake msg1 | SessionSetup (Noise XK msg1) |
| 0x2 | Handshake msg2 | SessionAck (Noise XK msg2) |
| 0x3 | Handshake msg3 | SessionMsg3 (Noise XK msg3) |
| 0x1 | Handshake msg1 | SessionSetup (Noise XX msg1) |
| 0x2 | Handshake msg2 | SessionAck (Noise XX msg2) |
| 0x3 | Handshake msg3 | SessionMsg3 (Noise XX msg3) |
### FSP Flags (Established Phase Only)
@@ -518,7 +610,7 @@ Transit nodes parse the CP flag and extract coordinates without decryption.
| ----- | ---- | ----------- |
| timestamp | 4 bytes LE | Session-relative milliseconds (u32) |
| msg_type | 1 byte | Session-layer message type |
| inner_flags | 1 byte | Bit 0: SP (spin bit for RTT measurement) |
| inner_flags | 1 byte | Reserved (must be zero) |
After the inner header, the remaining plaintext is the message-type-specific
body.
@@ -563,8 +655,8 @@ encrypted inner header.
### SessionSetup (phase 0x1)
Establishes a session and warms transit coordinate caches. Contains the
first message of the Noise XK handshake (ephemeral key only — the
initiator's static identity is not revealed until msg3).
first message of the Noise XX handshake (ephemeral key only — neither
side's static identity is revealed until msg2/msg3).
SessionSetup, SessionAck, and SessionMsg3 are identified by the **phase**
field in the FSP common prefix (0x1, 0x2, 0x3), not by a message type
@@ -585,12 +677,13 @@ Encoded with FSP prefix: ver=0, phase=0x1, flags=0, payload_len.
| ... | dest_coords_count | 2 bytes LE | Number of dest coordinate entries |
| ... | dest_coords | 16 x m bytes | Destination's ancestry |
| ... | handshake_len | 2 bytes LE | Noise payload length |
| ... | handshake_payload | variable | Noise XK msg1 (33 bytes — ephemeral key only) |
| ... | handshake_payload | variable | Noise XX msg1 (33 bytes — ephemeral key only) |
### SessionAck (phase 0x2)
Second message of the Noise XK handshake. The responder sends its
ephemeral key and encrypted epoch.
Second message of the Noise XX handshake. The responder reveals its
identity (ephemeral key, encrypted static key, and encrypted epoch).
Optional FSP negotiation payload may be appended (omitted for rekey).
Encoded with FSP prefix: ver=0, phase=0x2, flags=0, payload_len.
![SessionAck](diagrams/session-ack.svg)
@@ -605,12 +698,13 @@ Encoded with FSP prefix: ver=0, phase=0x2, flags=0, payload_len.
| ... | dest_coords_count | 2 bytes LE | Number of initiator coordinate entries |
| ... | dest_coords | 16 x m bytes | Initiator's ancestry (for return-path cache warming) |
| ... | handshake_len | 2 bytes LE | Noise payload length |
| ... | handshake_payload | variable | Noise XK msg2 (57 bytes — ephemeral key + encrypted epoch) |
| ... | handshake_payload | variable | Noise XX msg2 (106+ bytes — ephemeral + encrypted static + encrypted epoch + optional negotiation) |
### SessionMsg3 (phase 0x3)
Third and final message of the Noise XK handshake. The initiator reveals
its encrypted static identity and epoch. After msg3, both parties derive
Third and final message of the Noise XX handshake. The initiator reveals
its encrypted static identity and epoch. Optional FSP negotiation payload
may be appended (omitted for rekey). After msg3, both parties derive
identical symmetric session keys and the session is established.
Encoded with FSP prefix: ver=0, phase=0x3, flags=0, payload_len.
@@ -622,9 +716,9 @@ Encoded with FSP prefix: ver=0, phase=0x3, flags=0, payload_len.
| ------ | ----- | ---- | ----------- |
| 0 | flags | 1 byte | Reserved |
| 1 | handshake_len | 2 bytes LE | Noise payload length |
| 3 | handshake_payload | variable | Noise XK msg3 (73 bytes — encrypted static + encrypted epoch) |
| 3 | handshake_payload | variable | Noise XX msg3 (73+ bytes — encrypted static + encrypted epoch + optional negotiation) |
**Noise XK msg3 breakdown** (73 bytes):
**Noise XX msg3 breakdown** (73 bytes base):
| Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- |
@@ -873,29 +967,33 @@ endpoint session keys).
## Size Summary
### FMP Handshake Messages (Noise IK)
### FMP Handshake Messages (Noise XX)
| Message | Raw Noise | Wire Frame |
| ------- | --------- | ---------- |
| IK msg1 (ephemeral + encrypted static + encrypted epoch) | 106 bytes | 114 bytes |
| IK msg2 (ephemeral + encrypted epoch) | 57 bytes | 69 bytes |
| XX msg1 (ephemeral only) | 33 bytes | 41 bytes |
| XX msg2 (ephemeral + encrypted static + epoch) | 106 bytes | 144 bytes (with negotiation) |
| XX msg3 (encrypted static + epoch) | 73 bytes | 111 bytes (with negotiation) |
### FSP Handshake Messages (Noise XK)
### FSP Handshake Messages (Noise XX)
| Message | Raw Noise | Notes |
| ------- | --------- | ----- |
| XK msg1 (ephemeral only) | 33 bytes | Carried in SessionSetup |
| XK msg2 (ephemeral + encrypted epoch) | 57 bytes | Carried in SessionAck |
| XK msg3 (encrypted static + encrypted epoch) | 73 bytes | Carried in SessionMsg3 |
| XX msg1 (ephemeral only) | 33 bytes | Carried in SessionSetup |
| XX msg2 (ephemeral + encrypted static + epoch) | 106 bytes | Carried in SessionAck |
| XX msg3 (encrypted static + epoch) | 73 bytes | Carried in SessionMsg3 |
### Link-Layer Messages (inside encrypted frame)
| Message | Size | Notes |
| ------- | ---- | ----- |
| TreeAnnounce | 100 + 32n bytes | n = depth + 1 |
| FilterAnnounce | 1,035 bytes | v1 (1KB filter) |
| LookupRequest | 46 + 16n bytes | n = origin depth + 1 |
| LookupResponse | 93 + 16n bytes | n = target depth + 1 |
| FilterAnnounce | variable | 19-byte header + RLE-compressed payload |
| FilterNack | 9 bytes | |
| LookupRequest | 44 bytes + TLV | Fixed (no longer depth-dependent) |
| LookupResponse | 93 + 16n bytes + TLV | n = target depth + 1 |
| SenderReport | 20 bytes | Extensibility header |
| ReceiverReport | 54 bytes | Extensibility header |
| SessionDatagram | 36 + payload bytes | Fixed 36-byte header |
| Disconnect | 2 bytes | |
@@ -903,13 +1001,13 @@ endpoint session keys).
| Message | Typical Size | Notes |
| ------- | ------------ | ----- |
| SessionSetup | ~170 bytes | Depth-dependent (XK msg1 = 33 bytes) |
| SessionAck | ~190 bytes | Depth-dependent, carries both endpoints' coords (XK msg2 = 57 bytes) |
| SessionMsg3 | ~80 bytes | Fixed (XK msg3 = 73 bytes, no coords) |
| SessionSetup | ~170 bytes | Depth-dependent (XX msg1 = 33 bytes) |
| SessionAck | ~240 bytes | Depth-dependent, carries both endpoints' coords (XX msg2 = 106+ bytes) |
| SessionMsg3 | ~80 bytes | Fixed (XX msg3 = 73+ bytes, no coords) |
| Data (minimal) | 12 + 6 + 4 + payload + 16 bytes | Steady state (port header included) |
| Data (with coords) | 12 + ~130 + 6 + 4 + payload + 16 bytes | Warmup/recovery (port header included) |
| SenderReport | 12 + 6 + 46 + 16 bytes | MMP metrics |
| ReceiverReport | 12 + 6 + 66 + 16 bytes | MMP metrics |
| SenderReport | 12 + 6 + 20 + 16 bytes | MMP metrics |
| ReceiverReport | 12 + 6 + 54 + 16 bytes | MMP metrics |
| PathMtuNotification | 12 + 6 + 2 + 16 bytes | MTU signal |
| CoordsWarmup | 12 + coords + 6 + 16 bytes | Standalone warmup (empty body) |
| CoordsRequired | 38 bytes | Fixed (prefix + msg_type + body) |
+1 -1
View File
@@ -282,7 +282,7 @@ an entry for `test-us03` (the open-discovery test mesh node).
It will have `connectivity` active and its own
`transport_addr`. This peering appeared without you
configuring anything — the test-mesh open-discovery node saw
your advert, dialed the endpoint, and Noise IK established
your advert, dialed the endpoint, and Noise XX established
the link.
If no inbound peers appear, that's not necessarily a failure
+1 -1
View File
@@ -47,7 +47,7 @@ Two machines, each running `fips`, joined by a physical Ethernet
link. After the worked example:
- The two daemons have discovered each other via L2 beacons on
the link, peered over Noise IK, and brought up an FMP link.
the link, peered over Noise XX, and brought up an FMP link.
- Each `fips0` adapter has a routable mesh address; each can
ping the other by `<npub>.fips`.
- Nothing between the two machines speaks IP. The link carries
+2 -2
View File
@@ -206,8 +206,8 @@ metrics" to "I understand why each one moves the way it does":
in `show sessions` means: the proactive forward-path field, the
reactive `MtuExceeded` mechanism, the hysteresis on increase.
- [../design/fips-architecture.md](../design/fips-architecture.md)
— the two-layer encryption model: link-layer Noise IK over
each hop, end-to-end Noise XK over the session.
— the two-layer encryption model: link-layer Noise XX over
each hop, end-to-end Noise XX over the session.
## What you've learned
+6 -6
View File
@@ -27,7 +27,7 @@ UDP/2121, and is reachable from any network that permits arbitrary
outbound UDP.
> **Peer vs. node.** In FIPS terminology, a *peer* is a node
> you have a direct link to — same Noise IK handshake, same
> you have a direct link to — same Noise XX handshake, same
> transport socket. A *node* is any participant on the mesh,
> whether you peer with it directly or reach it through one or
> more hops via your peer's connections. Peering is a local
@@ -85,7 +85,7 @@ peers:
What each field does:
- `npub` — the canonical Nostr public key of `test-us01`. This is
who your daemon will mutually authenticate with over Noise IK.
who your daemon will mutually authenticate with over Noise XX.
- `alias` — a short name your daemon will use when referring to
this peer in logs and `fipsctl show peers` output. Optional.
- `addresses` — one or more transport endpoints. UDP on the
@@ -111,7 +111,7 @@ Within a few seconds you should see lines mentioning:
- An outbound connection attempt to `test-us01` or
`test-us01.fips.network:2121`
- A handshake completion (a "Noise IK link handshake complete"
- A handshake completion (a "Noise XX link handshake complete"
style line, or "peer authenticated" with the test-us01 npub)
- An MMP / link metrics entry naming `test-us01`
@@ -203,7 +203,7 @@ public test mesh, with reach to every node that mesh routes you
to. You have seen:
- **Identity.** Your daemon's ephemeral keypair authenticated to
`test-us01` over Noise IK without either side trusting anyone in
`test-us01` over Noise XX without either side trusting anyone in
advance.
- **Transports.** A UDP socket on your host carries
authenticated, encrypted mesh frames to your peer. No central
@@ -317,8 +317,8 @@ For "what just happened, in detail":
- [../design/fips-architecture.md](../design/fips-architecture.md) —
the protocol stack and the two-layer encryption model.
- [../design/fips-mesh-layer.md](../design/fips-mesh-layer.md) —
Noise IK link encryption, hop-by-hop forwarding.
Noise XX link encryption, hop-by-hop forwarding.
- [../design/fips-session-layer.md](../design/fips-session-layer.md)
— end-to-end Noise XK, session lifecycle.
— end-to-end Noise XX, session lifecycle.
- [../design/fips-ipv6-adapter.md](../design/fips-ipv6-adapter.md) —
the TUN, the local DNS responder, MTU enforcement.
+1 -1
View File
@@ -44,7 +44,7 @@ literal sense. Several things derive from it:
- Your `fd97:...` mesh address — derived from the public key.
- Your `<npub>.fips` DNS name — the npub itself with `.fips`
appended.
- Every authenticated connection — Noise IK at the mesh layer,
- Every authenticated connection — Noise XX at the mesh layer,
XK at the session layer, both prove you hold the matching
secret key.
+2 -2
View File
@@ -245,9 +245,9 @@ For "what's actually in those packets":
- [../design/fips-architecture.md](../design/fips-architecture.md)
— the protocol stack and the two-layer encryption model.
- [../design/fips-mesh-layer.md](../design/fips-mesh-layer.md) —
Noise IK link encryption, hop-by-hop forwarding.
Noise XX link encryption, hop-by-hop forwarding.
- [../design/fips-session-layer.md](../design/fips-session-layer.md)
— end-to-end Noise XK between source and destination.
— end-to-end Noise XX between source and destination.
For the trace-it-yourself version of the path you just
exercised, see
@@ -11,7 +11,7 @@
# failover, not multipath — FIPS keeps one active link per peer; the other
# band stands by and reconnects the peer if the active link dies.
#
# - encryption 'none' — the mesh is OPEN on purpose. FIPS's Noise IK
# - encryption 'none' — the mesh is OPEN on purpose. FIPS's Noise
# handshake authenticates and encrypts every peer link, so SAE would
# only duplicate that (and on ath10k it forces the slower raw Tx/Rx
# firmware mode). A stranger can form an 802.11s peering AND a FIPS
+20 -1
View File
@@ -121,7 +121,6 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused:
&helpers::nested_u64(data, "stats", "decode_error"),
),
helpers::kv_line("Invalid", &helpers::nested_u64(data, "stats", "invalid")),
helpers::kv_line("Non-V1", &helpers::nested_u64(data, "stats", "non_v1")),
helpers::kv_line(
"Unknown Peer",
&helpers::nested_u64(data, "stats", "unknown_peer"),
@@ -130,6 +129,26 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused:
Line::from(""),
helpers::section_header("Outbound"),
helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")),
helpers::kv_line(
"Full Sends",
&helpers::nested_u64(data, "stats", "full_sends"),
),
helpers::kv_line(
"Deltas Sent",
&helpers::nested_u64(data, "stats", "deltas_sent"),
),
helpers::kv_line(
"NACKs Sent",
&helpers::nested_u64(data, "stats", "nacks_sent"),
),
helpers::kv_line(
"NACKs Received",
&helpers::nested_u64(data, "stats", "nacks_received"),
),
helpers::kv_line(
"Size Changes",
&helpers::nested_u64(data, "stats", "size_changes"),
),
helpers::kv_line(
"Debounce Suppressed",
&helpers::nested_u64(data, "stats", "debounce_suppressed"),
+17
View File
@@ -638,6 +638,21 @@ impl Config {
self.node.leaf_only
}
/// Derive the node profile from config.
///
/// leaf_only → Leaf (implies non-routing),
/// disable_routing → NonRouting,
/// otherwise → Full.
pub fn node_profile(&self) -> crate::proto::fmp::NodeProfile {
if self.node.leaf_only {
crate::proto::fmp::NodeProfile::Leaf
} else if self.node.disable_routing {
crate::proto::fmp::NodeProfile::NonRouting
} else {
crate::proto::fmp::NodeProfile::Full
}
}
/// Get the configured peers.
pub fn peers(&self) -> &[PeerConfig] {
&self.peers
@@ -1534,6 +1549,7 @@ node:
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_validate_peer_via_nostr_requires_nostr_enabled() {
let mut config = Config {
peers: vec![PeerConfig {
@@ -1550,6 +1566,7 @@ node:
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_validate_peer_addresses_required_unless_via_nostr() {
// Empty addresses + via_nostr=false → error.
let mut config = Config {
+17 -1
View File
@@ -497,7 +497,11 @@ impl NostrRendezvousConfig {
}
fn default_app() -> String {
"fips-overlay-v1".to_string()
// Branch-specific default. `next` runs FMP-v1 which is wire-
// incompatible with `master`'s FMP-v0, so the two namespaces
// separate the discovery overlays by default — operators who
// want cross-branch discovery can override here.
"fips-overlay-v1-next".to_string()
}
fn default_signal_ttl_secs() -> u64 {
@@ -1009,7 +1013,18 @@ pub struct NodeConfig {
#[serde(default)]
pub identity: IdentityConfig,
/// Non-routing mode (`node.disable_routing`).
///
/// Tree participation and one-way bloom receipt, but no transit
/// forwarding or bloom combination/propagation. Overridden by
/// `leaf_only` (leaf implies non-routing).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub disable_routing: bool,
/// Leaf-only mode (`node.leaf_only`).
///
/// Single upstream peer, no tree/bloom/transit. Implies
/// `disable_routing`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub leaf_only: bool,
@@ -1119,6 +1134,7 @@ impl Default for NodeConfig {
fn default() -> Self {
Self {
identity: IdentityConfig::default(),
disable_routing: false,
leaf_only: false,
tick_interval_secs: 1,
base_rtt_ms: 100,
+58 -45
View File
@@ -988,56 +988,70 @@ pub(crate) fn show_bloom_from_handle(handle: &super::read_handle::ControlReadHan
/// `show_mmp` — MMP metrics summary.
pub fn show_mmp(node: &Node) -> Value {
// Link-layer MMP per peer
let peers: Vec<Value> = node.peers().filter_map(|peer| {
let mmp = peer.mmp()?;
let addr = *peer.node_addr();
let metrics = &mmp.metrics;
let peers: Vec<Value> = node
.peers()
.filter_map(|peer| {
let mmp = peer.mmp()?;
let addr = *peer.node_addr();
let metrics = &mmp.metrics;
let mut link_layer = json!({
"loss_rate": metrics.loss_rate(),
"etx": metrics.etx,
"goodput_bps": metrics.goodput_bps,
"spin_bit_role": if mmp.spin_bit.is_initiator() { "initiator" } else { "responder" },
});
let mut link_layer = json!({
"loss_rate": metrics.loss_rate(),
"etx": metrics.etx,
"goodput_bps": metrics.goodput_bps,
});
if let Some(smoothed_loss) = metrics.smoothed_loss() {
link_layer["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = metrics.smoothed_etx() {
link_layer["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = metrics.srtt_ms() {
link_layer["srtt_ms"] = json!(srtt);
if let Some(setx) = metrics.smoothed_etx() {
link_layer["lqi"] = json!(setx * (1.0 + srtt / 100.0));
if let Some(smoothed_loss) = metrics.smoothed_loss() {
link_layer["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = metrics.smoothed_etx() {
link_layer["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = metrics.srtt_ms() {
link_layer["srtt_ms"] = json!(srtt);
if let Some(setx) = metrics.smoothed_etx() {
link_layer["lqi"] = json!(setx * (1.0 + srtt / 100.0));
}
}
}
// Trend indicators
if metrics.rtt_trend.initialized() {
link_layer["rtt_trend"] = json!(trend_label(metrics.rtt_trend.short(), metrics.rtt_trend.long()));
}
if metrics.loss_trend.initialized() {
link_layer["loss_trend"] = json!(trend_label(metrics.loss_trend.short(), metrics.loss_trend.long()));
}
if metrics.goodput_trend.initialized() {
link_layer["goodput_trend"] = json!(trend_label(metrics.goodput_trend.short(), metrics.goodput_trend.long()));
}
if metrics.jitter_trend.initialized() {
link_layer["jitter_trend"] = json!(trend_label(metrics.jitter_trend.short(), metrics.jitter_trend.long()));
}
// Trend indicators
if metrics.rtt_trend.initialized() {
link_layer["rtt_trend"] = json!(trend_label(
metrics.rtt_trend.short(),
metrics.rtt_trend.long()
));
}
if metrics.loss_trend.initialized() {
link_layer["loss_trend"] = json!(trend_label(
metrics.loss_trend.short(),
metrics.loss_trend.long()
));
}
if metrics.goodput_trend.initialized() {
link_layer["goodput_trend"] = json!(trend_label(
metrics.goodput_trend.short(),
metrics.goodput_trend.long()
));
}
if metrics.jitter_trend.initialized() {
link_layer["jitter_trend"] = json!(trend_label(
metrics.jitter_trend.short(),
metrics.jitter_trend.long()
));
}
link_layer["delivery_ratio_forward"] = json!(metrics.delivery_ratio_forward);
link_layer["delivery_ratio_reverse"] = json!(metrics.delivery_ratio_reverse);
link_layer["ecn_ce_count"] = json!(metrics.last_ecn_ce_count());
link_layer["delivery_ratio_forward"] = json!(metrics.delivery_ratio_forward);
link_layer["delivery_ratio_reverse"] = json!(metrics.delivery_ratio_reverse);
link_layer["ecn_ce_count"] = json!(metrics.last_ecn_ce_count());
Some(json!({
"peer": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(&addr),
"mode": format!("{}", mmp.mode()),
"link_layer": link_layer,
}))
}).collect();
Some(json!({
"peer": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(&addr),
"mode": format!("{}", mmp.mode()),
"link_layer": link_layer,
}))
})
.collect();
// Session-layer MMP
let sessions: Vec<Value> = node
@@ -1116,7 +1130,6 @@ pub(crate) fn show_mmp_from_handle(handle: &super::read_handle::ControlReadHandl
"loss_rate": peer.loss_rate,
"etx": peer.etx,
"goodput_bps": peer.goodput_bps,
"spin_bit_role": if peer.spin_bit_initiator { "initiator" } else { "responder" },
});
if let Some(smoothed_loss) = peer.smoothed_loss {
-1
View File
@@ -692,7 +692,6 @@ pub(crate) struct MmpPeerRow {
pub loss_rate: f64,
pub etx: f64,
pub goodput_bps: f64,
pub spin_bit_initiator: bool,
pub smoothed_loss: Option<f64>,
pub smoothed_etx: Option<f64>,
pub srtt_ms: Option<f64>,
+7
View File
@@ -10,13 +10,20 @@
"accepted": 0,
"debounce_suppressed": 0,
"decode_error": 0,
"deltas_sent": 0,
"fill_exceeded": 0,
"full_sends": 0,
"invalid": 0,
"nacks_received": 0,
"nacks_sent": 0,
"non_v1": 0,
"received": 0,
"send_failed": 0,
"sent": 0,
"size_changes": 0,
"stale": 0,
"total_compressed_bytes": 0,
"total_raw_bytes": 0,
"unknown_peer": 0
},
"uptree_estimated_count": null,
+12 -3
View File
@@ -54,6 +54,9 @@ pub(crate) enum Step {
PollLanRendezvous,
DrivePeerTimers,
ResendPendingRekeys,
/// `next`-only: the FMP rekey msg3 resend driver has no master-line
/// counterpart, so this variant exists on this line alone.
ResendPendingFmpRekeyMsg3,
ResendPendingSessionHandshakes,
ResendPendingSessionMsg3,
PurgeIdleSessions,
@@ -91,6 +94,7 @@ pub(crate) const STEPS: [Step; N_STEPS] = [
Step::PollLanRendezvous,
Step::DrivePeerTimers,
Step::ResendPendingRekeys,
Step::ResendPendingFmpRekeyMsg3,
Step::ResendPendingSessionHandshakes,
Step::ResendPendingSessionMsg3,
Step::PurgeIdleSessions,
@@ -123,6 +127,7 @@ impl Step {
Step::PollLanRendezvous => "poll_lan_rendezvous",
Step::DrivePeerTimers => "drive_peer_timers",
Step::ResendPendingRekeys => "resend_pending_rekeys",
Step::ResendPendingFmpRekeyMsg3 => "resend_pending_fmp_rekey_msg3",
Step::ResendPendingSessionHandshakes => "resend_pending_session_handshakes",
Step::ResendPendingSessionMsg3 => "resend_pending_session_msg3",
Step::PurgeIdleSessions => "purge_idle_sessions",
@@ -375,9 +380,13 @@ mod tests {
#[test]
fn emitted_row_count_matches_build() {
let emitted = STEPS.iter().filter(|s| s.emitted()).count();
// 24 unconditional subsystem steps + the whole-tick span, plus the two
// conditionally-compiled steps where this build has them.
let mut expected = 25;
// 25 unconditional subsystem steps on this line (24 shared with the
// master line, plus `resend_pending_fmp_rekey_msg3`, which exists only
// here) + the whole-tick span, plus the two conditionally-compiled
// steps where this build has them. The count is pinned deliberately: it
// is what caught the extra step when the master-line instrumentation
// was merged up, rather than letting the tables silently disagree.
let mut expected = 26;
if cfg!(any(target_os = "linux", target_os = "macos")) {
expected += 1;
}
+3
View File
@@ -90,6 +90,9 @@ pub use proto::routing::{
// Re-export FMP link-framing wire type (relocated from protocol:: to proto::fmp)
pub use proto::fmp::HandshakeMessageType;
// Re-export FMP negotiation wire types (relocated from protocol:: to proto::fmp)
pub use proto::fmp::{NegotiationPayload, NodeProfile, TlvEntry};
// Re-export cache types
pub use cache::{CacheEntry, CacheError, CacheStats, CoordCache};
+6
View File
@@ -15,6 +15,7 @@
use std::sync::Arc;
use crate::proto::fmp::NodeProfile;
use crate::{Config, Identity};
/// Effectively-immutable `Node` state, shared via `Arc<NodeContext>`.
@@ -36,6 +37,9 @@ pub(crate) struct NodeContext {
/// Whether this is a leaf-only node.
pub is_leaf_only: bool,
/// This node's routing profile (Full, NonRouting, Leaf).
pub node_profile: NodeProfile,
/// Maximum connections (0 = unlimited).
pub max_connections: usize,
@@ -55,6 +59,7 @@ impl NodeContext {
startup_epoch: [u8; 8],
started_at: std::time::Instant,
is_leaf_only: bool,
node_profile: NodeProfile,
max_connections: usize,
max_peers: usize,
max_links: usize,
@@ -65,6 +70,7 @@ impl NodeContext {
startup_epoch,
started_at,
is_leaf_only,
node_profile,
max_connections,
max_peers,
max_links,
+6 -9
View File
@@ -187,14 +187,10 @@ impl Node {
// Remove link and address mapping
self.remove_link(&link_id);
// Bound `peer_machines`: drop this peer's machine
// entry, keyed by the `link_id` derived above BEFORE the `peers` removal.
// This cleans up the OLD peer's machine on an inbound restart and prevents
// unbounded growth on the establish success path. NEUTRAL: nothing on the
// live path reads `peer_machines` except the establish executor, which only
// ever touches the in-flight establish's (distinct) `link_id`; no reader
// depends on a stale entry, so removal changes no behavior — it only bounds
// the map.
// Drop this peer's inert machine, keyed by the link_id
// derived above (a peer's link_id is immutable, so the key never
// moved). Keeps peers <-> peer_machines in exact correspondence on
// teardown. NEUTRAL: nothing reads peer_machines yet.
self.remove_peer_machine(link_id);
if let Some(transport_id) = transport_id {
self.cleanup_bootstrap_transport_if_unused(transport_id);
@@ -210,7 +206,8 @@ impl Node {
}
}
// Bloom filter cleanup: clear state for removed peer, mark all remaining peers
// Bloom filter cleanup: remove dependent (non-routing/leaf peers), clear state
self.bloom_state.remove_leaf_dependent(node_addr);
self.bloom_state.remove_peer_state(node_addr);
let remaining_peers: Vec<NodeAddr> = self.peers.keys().copied().collect();
self.bloom_state.mark_all_updates_needed(remaining_peers);
+47 -38
View File
@@ -2,9 +2,7 @@
use crate::node::Node;
use crate::noise::NoiseError;
use crate::proto::fmp::wire::{
EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, strip_inner_header,
};
use crate::proto::fmp::wire::{EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, strip_inner_header};
use crate::transport::ReceivedPacket;
use tracing::{debug, trace, warn};
@@ -59,15 +57,11 @@ impl Node {
// actually belongs to rekey N+1. Promoting on the bare bit then
// installs the WRONG Noise session as current — the two endpoints
// diverge, every subsequent frame fails AEAD on the far side, the
// receiver starves, and the link is declared dead at the heartbeat
// timeout (routing failure, green crypto). This mirrors the FSP fix
// (node/session.rs / node/handlers/session.rs): the authenticated
// decrypt, not the header bit, is the cutover signal. Trial-decrypt
// the frame against `pending` first; only promote if it
// authenticates. On success the same frame is delivered via
// `process_authentic_fmp_plaintext` and we return — it must not
// fall through to a second decrypt, which would be rejected as a
// replay (the trial-decrypt already advanced `pending`'s window).
// receiver starves, and the link is declared dead at the 30s
// heartbeat timeout (Phase-5 routing failure, green crypto). This
// mirrors the FSP fix (node/session.rs): the authenticated decrypt,
// not the header bit, is the cutover signal. Trial-decrypt the
// frame against `pending` first; only promote if it authenticates.
{
let Some(peer) = self.peers.get(&node_addr) else {
return;
@@ -78,12 +72,11 @@ impl Node {
if k_bit_flipped {
let ciphertext = &packet.data[header.ciphertext_offset()..];
let display_name = self.peer_display_name(&node_addr);
let our_addr = *self.identity().node_addr();
let Some(peer) = self.peers.get_mut(&node_addr) else {
return;
};
// Authenticate the frame against the pending session.
// Trial-decrypt mutates `pending`'s replay window only on
// success, so a failed trial leaves it untouched.
let pending_plaintext = peer.pending_new_session_mut().and_then(|pending| {
pending
.decrypt_with_replay_check_and_aad(
@@ -95,19 +88,27 @@ impl Node {
});
if let Some(plaintext) = pending_plaintext {
let pending_our = peer.pending_our_index();
let pending_their = peer.pending_their_index();
debug!(
peer = %display_name,
our_addr = %our_addr,
their_addr = %node_addr,
pending_our_index = ?pending_our,
pending_their_index = ?pending_their,
"Peer new-epoch frame authenticated, K-bit flip promoting new session"
);
// The peer authenticated a frame on the new epoch, so it
// derived the new session (it received our rekey msg3).
// If we are the rekey initiator still retransmitting msg3,
// stop — the responder is confirmed. (No-op for the
// responder side, which never retained a msg3 payload.)
peer.clear_rekey_msg3_payload();
// The trial-decrypt already advanced the pending
// session's replay window; `handle_peer_kbit_flip`
// moves that same session object to `current`, so no
// re-decrypt.
// session's replay window; handle_peer_kbit_flip moves
// that same session object to current, so no re-decrypt.
let did_flip = peer.handle_peer_kbit_flip().is_some();
if did_flip {
// New index was pre-registered in peers_by_index
// during msg1 handling (handshake.rs). Verify,
// don't duplicate.
debug_assert!(
peer.transport_id().is_some()
&& peer.our_index().is_some()
@@ -118,13 +119,8 @@ impl Node {
"peers_by_index should contain pre-registered new index after K-bit flip"
);
}
// Re-register the (now-promoted) session with the
// decrypt worker: cache_key = (transport_id, our_index)
// changed at the flip, so the old worker entry is
// stranded and every packet on the new session would
// miss the worker's HashMap lookup. Without this,
// throughput drops back to the inline-decrypt path
// after each rekey.
// Re-register the promoted session with the decrypt
// worker (cache_key changed at the flip).
#[cfg(unix)]
if did_flip {
self.register_decrypt_worker_session(&node_addr);
@@ -134,7 +130,6 @@ impl Node {
// canonical post-decrypt path, then return — it must
// not fall through to a second decrypt attempt.
let ce_flag = header.flags & FLAG_CE != 0;
let sp_flag = header.flags & FLAG_SP != 0;
self.process_authentic_fmp_plaintext(
&node_addr,
packet.transport_id,
@@ -143,7 +138,6 @@ impl Node {
packet.data.len(),
header.counter,
ce_flag,
sp_flag,
&plaintext,
)
.await;
@@ -196,7 +190,9 @@ impl Node {
// Decrypt: try current session first, then previous (drain fallback)
let ciphertext = &packet.data[header.ciphertext_offset()..];
let plaintext = {
let peer = self.peers.get_mut(&node_addr).unwrap();
let Some(peer) = self.peers.get_mut(&node_addr) else {
return;
};
let session = match peer.noise_session_mut() {
Some(s) => s,
None => {
@@ -262,9 +258,15 @@ impl Node {
// MMP per-frame processing and statistics
let now_ms = crate::time::mono_ms();
let ce_flag = header.flags & FLAG_CE != 0;
let sp_flag = header.flags & FLAG_SP != 0;
if let Some(peer) = self.peers.get_mut(&node_addr) {
// Initiator-side msg3 confirm (see process_authentic_fmp_plaintext):
// a frame authenticated against post-cutover `current` (no pending)
// proves the responder reached the new epoch. Inline-decrypt path
// mirror of the worker-bounce confirm.
if peer.rekey_msg3_payload().is_some() && peer.pending_new_session().is_none() {
peer.clear_rekey_msg3_payload();
}
if let Some(mmp) = peer.mmp_mut() {
mmp.receiver.record_recv(
header.counter,
@@ -273,7 +275,6 @@ impl Node {
ce_flag,
now_ms,
);
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now_ms);
}
peer.set_current_addr(packet.transport_id, packet.remote_addr.clone());
peer.link_stats_mut()
@@ -329,8 +330,8 @@ impl Node {
/// Canonical post-FMP-decrypt side-effect site. Used by both the
/// inline rx_loop decrypt path and the decrypt-worker bounce path
/// so the per-peer bookkeeping (stats, MMP, spin-bit RTT, ECN
/// propagation, address-rotation handling, link-message dispatch)
/// so the per-peer bookkeeping (stats, MMP, ECN propagation,
/// address-rotation handling, link-message dispatch)
/// happens in exactly one place.
#[allow(clippy::too_many_arguments)]
pub(in crate::node) async fn process_authentic_fmp_plaintext(
@@ -342,7 +343,6 @@ impl Node {
packet_len: usize,
fmp_counter: u64,
ce_flag: bool,
sp_flag: bool,
fmp_plaintext: &[u8],
) {
const INNER_TIMESTAMP_LEN: usize = 4;
@@ -360,6 +360,18 @@ impl Node {
let mut address_changed = false;
if let Some(peer) = self.peers.get_mut(node_addr) {
peer.reset_decrypt_failures();
// If we are the rekey initiator that already cut over on its
// own timer (no `pending`, `current` is the new session) but
// still retain a msg3 retransmission payload, an authenticated
// peer frame here decrypts against the post-cutover `current`
// session — proof the responder reached the new epoch. Stop
// retransmitting. Mirrors the FSP Current-slot confirm in
// handle_encrypted_session_msg. Works in both the inline and
// worker-bounce paths since both funnel through here, and the
// only session registered for the new index is the new one.
if peer.rekey_msg3_payload().is_some() && peer.pending_new_session().is_none() {
peer.clear_rekey_msg3_payload();
}
address_changed = peer.set_current_addr(transport_id, remote_addr.clone());
peer.link_stats_mut()
.record_recv(packet_len, packet_timestamp_ms);
@@ -367,7 +379,6 @@ impl Node {
if let Some(mmp) = peer.mmp_mut() {
mmp.receiver
.record_recv(fmp_counter, inner_ts, packet_len, ce_flag, now_ms);
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, fmp_counter, now_ms);
}
}
// Address rotation invalidates the per-peer connect()-ed UDP
@@ -394,7 +405,6 @@ impl Node {
fallback: crate::node::decrypt_worker::DecryptFallback,
) {
let ce_flag = fallback.fmp_flags & FLAG_CE != 0;
let sp_flag = fallback.fmp_flags & FLAG_SP != 0;
let plaintext = &fallback.packet_data[fallback.fmp_plaintext_offset
..fallback.fmp_plaintext_offset + fallback.fmp_plaintext_len];
self.process_authentic_fmp_plaintext(
@@ -405,7 +415,6 @@ impl Node {
fallback.packet_len,
fallback.fmp_counter,
ce_flag,
sp_flag,
plaintext,
)
.await;
+475 -203
View File
@@ -8,8 +8,7 @@
//!
//! ## Progressive cutover
//!
//! The executor is wired incrementally. Live today: the inbound establish
//! (`handle_msg1` → `step(InboundMsg1)`), the outbound msg2 promote
//! The executor is wired incrementally. Live today: the outbound msg2 promote
//! (`handle_msg2` looks up the dial-persisted machine), the connectionless
//! outbound msg1 send (`SendHandshake` with `their_index == None` →
//! `send_stored_msg1`, driven from `initiate_connection`), the
@@ -20,6 +19,15 @@
//! `CompleteDrain` arms), and the liveness reap (`route_link_dead` →
//! `LinkDeadSuspected`, driving `InvalidateSendState` → `remove_active_peer`).
//!
//! Inbound msg1 is not machine-driven here: `handle_msg1` builds and sends
//! msg2 inline (persisting the leg's machine parked at `SentMsg2` alongside),
//! so `PeerEvent::InboundMsg1` is never dispatched and the `SendHandshake`
//! `their_index == Some` (msg2) branch stays dormant. Inbound msg3 IS
//! machine-driven: `handle_msg3` steps the leg's persistent machine and this
//! executor performs its verdict (`PromoteToActive`, `SwapToInboundSession`,
//! `RekeyRespondTrigger`), disposing the machine on every path that consumes
//! the leg without promoting it.
//!
//! The genuine inert stubs remaining are `SendRekey`, `SendLinkMessage`, and
//! the connected-UDP arms. `RegisterDecryptSession` is a deliberate no-op —
//! see its arm for the note.
@@ -31,28 +39,28 @@
//! shell drivers — so populating them stays behavior-neutral.
use crate::PeerIdentity;
use crate::node::Node;
use crate::node::reject::{HandshakeReject, RejectReason};
use crate::node::{Node, NodeError};
use crate::peer::machine::{LostKind, PeerAction, PeerEvent};
use crate::proto::fmp::PromotionResult;
use crate::proto::fmp::wire::build_msg2;
use crate::transport::{LinkId, TransportAddr, TransportId};
use crate::utils::index::SessionIndex;
use std::collections::VecDeque;
use tracing::{debug, trace, warn};
use tracing::{debug, info, trace, warn};
/// Ambient shell facts a [`PeerAction`] executor needs that the machine's
/// runtime-agnostic action payloads deliberately omit (verified identity,
/// transport target, the msg2 framing indices, the promotion timestamp).
///
/// Unlike a machine event/action payload this is **executor-side**, so it may
/// hold real values resolved from the wire context (cf. `handle_msg1`'s
/// `wire`/`packet` locals and `promote_connection`'s ambient args). It is
/// built fresh per driven step by the caller at cutover time.
/// hold real values resolved from the wire context (cf. `handle_msg3`'s
/// `wire`/`packet` locals and `promote_connection`'s ambient args). It is built
/// fresh per driven step by the caller at cutover time.
#[allow(dead_code)]
pub(in crate::node) struct PeerActionCtx {
/// The authenticated peer identity: `PromoteToActive` /
/// `InvalidateSendState` resolve their `NodeAddr` from this.
/// The authenticated peer identity (`PromoteToActive` / `InvalidateSendState`
/// / `SwapSendState` resolve their `NodeAddr` from this).
pub(in crate::node) verified_identity: PeerIdentity,
/// The transport the exchange is happening over (msg2 send target, decrypt
/// cache-key transport half).
@@ -61,30 +69,32 @@ pub(in crate::node) struct PeerActionCtx {
pub(in crate::node) remote_addr: TransportAddr,
/// Our session index for this exchange (msg2 framing sender_idx).
pub(in crate::node) our_index: Option<SessionIndex>,
/// The peer's session index for this exchange (msg2 framing
/// receiver_idx).
/// The peer's session index for this exchange (msg2 framing receiver_idx).
pub(in crate::node) their_index: Option<SessionIndex>,
/// The wire timestamp driving this step (promotion ts / loss-report clock).
pub(in crate::node) now_ms: u64,
/// Establish direction for this exchange. Discriminates the
/// `PromoteToActive` failure cleanup: the pre-refactor inbound
/// (`handle_msg1`) and outbound (`handle_msg2`) promote-Err arms were NOT
/// byte-identical, so the executor must reproduce each. `false` = inbound
/// (drop link + reverse map + free index), `true` = outbound (record the
/// reject only; leave the dead link/`addr_to_link` for the stale-connection
/// reaper, matching old `handle_msg2`).
/// Establish direction for this exchange. `false` = inbound, `true` =
/// outbound. `PromoteToActive` reads this to pick the direction-specific
/// promote tail: the outbound branch logs a `Peer promoted to active` line
/// and clears `pending_outbound`, and its promote-Err cleanup is warn-only
/// (no link/index teardown), unlike the inbound branch.
pub(in crate::node) is_outbound: bool,
/// The `pending_outbound` key for an outbound promote, cleared on success.
/// `Some` only on the outbound driven step (the map entry keyed by the wire
/// `receiver_idx`); `None` on the inbound and maintenance paths, which have
/// no `pending_outbound` entry to clear.
pub(in crate::node) pending_outbound_key: Option<(TransportId, u32)>,
}
impl Node {
/// Advance the machine for `link` by one event and execute the resulting
/// actions.
///
/// The borrow structure the whole seam turns on: the machine
/// needs `&mut IndexAllocator` as a synchronous capability *while it is
/// itself borrowed mutably out of `peer_machines`*. `peer_machines` and
/// `index_allocator` are **distinct `Node` fields**, so the collect below is
/// a disjoint two-field borrow the checker accepts; once the actions are
/// The borrow structure the whole seam turns on: the machine needs
/// `&mut IndexAllocator` as a synchronous capability *while it is itself
/// borrowed mutably out of `peer_machines`*. `peer_machines` and
/// `index_allocator` are **distinct `Node` fields**, so the collect below is a
/// disjoint two-field borrow the checker accepts; once the actions are
/// collected both borrows drop and the executor runs against `&mut self`.
pub(in crate::node) async fn advance_peer_machine(
&mut self,
@@ -104,12 +114,10 @@ impl Node {
/// Map each [`PeerAction`] onto its shell call.
///
/// `PromoteToActive` feeds its [`PromotionResult`](crate::proto::fmp::PromotionResult)
/// back into the machine and appends the follow-up actions to the same
/// worklist — a queue rather than self-recursion so the async executor stays a
/// single flat future (no boxing) and the emitted order is preserved (the
/// establish sequences always end in `PromoteToActive`, so its follow-ups run
/// after any siblings).
/// The worklist is a `VecDeque` rather than self-recursion so the async
/// executor stays a single flat future (no boxing) and the emitted order is
/// preserved. `PromoteToActive` (deferred) will feed its resolution back into
/// the machine and fold follow-up actions onto this same queue.
pub(in crate::node) async fn execute_peer_actions(
&mut self,
link: LinkId,
@@ -147,7 +155,7 @@ impl Node {
link_id: link,
transport_id,
remote_addr,
peer_identity: ambient.verified_identity,
peer_identity: Some(ambient.verified_identity),
});
}
Err(_e) => {
@@ -218,179 +226,252 @@ impl Node {
}
}
PeerAction::SendRekey { .. } => {
// Rekey msg2 framing (`build_msg2(our_new_index, …)`,
// `handshake.rs:365`) + send. Rekey fold is not yet wired.
// Rekey msg framing (`build_msg2(our_new_index, …)`) + send.
// Rekey fold is out of scope here.
}
PeerAction::SendLinkMessage { .. } => {
// Encrypt + send a link-control frame (heartbeat / filter
// / tree / disconnect). Data-plane-owned; not yet wired.
// Encrypt + send a link-control frame (heartbeat / filter /
// tree / disconnect). Data-plane-owned; out of scope here.
}
PeerAction::PromoteToActive { link: promote_link } => {
// Ambient supplies the verified identity + promotion ts
// that `promote_connection` needs (resolved from the wire ctx).
match self.promote_connection(
// Establish promote, driven through the machine. Transcribes
// `handle_msg3`'s shared inbound promote block verbatim, adapted
// to the executor's ambient context. One XX-specific choice vs
// the IK-lineage executor: the decrypt-worker register stays
// INSIDE `promote_connection` (NOT relocated here) — re-
// registering would double-register. The promoted leg's machine
// (msg1-born inbound, dial-born outbound) survives the promotion
// and is crystallized in place by the `PromotionResolved`
// feedback fed back after the Ok handling below.
// Capture msg2 BEFORE `promote_connection` removes the pending
// connection, so a duplicate msg1 can be answered with it. Only
// the inbound promote answers a duplicate inbound msg1; the
// outbound side has no stored msg2 to resend.
let wire_msg2 = if ambient.is_outbound {
None
} else {
self.peer_machines
.get(&promote_link)
.and_then(|m| m.conn_handshake_msg2().map(|b| b.to_vec()))
};
if ambient.is_outbound {
debug!(
// Relocated from `handlers/handshake.rs`: pin the target
// so it stays visible under the harness's
// `fips::node::handlers::handshake=debug` filter.
target: "fips::node::handlers::handshake",
peer = %self.peer_display_name(ambient.verified_identity.node_addr()),
link_id = %promote_link,
"handle_msg2: promoting outbound, peers_has_key={}",
self.peers.contains_key(ambient.verified_identity.node_addr()),
);
} else {
debug!(
// Relocated from `handlers/handshake.rs`: pin the target
// so it stays visible under the harness's
// `fips::node::handlers::handshake=debug` filter.
target: "fips::node::handlers::handshake",
peer = %self.peer_display_name(ambient.verified_identity.node_addr()),
link_id = %promote_link,
our_index = ?ambient.our_index,
"handle_msg3: promoting inbound, peers_has_key={}",
self.peers.contains_key(ambient.verified_identity.node_addr()),
);
}
let promote_result = self.promote_connection(
promote_link,
ambient.verified_identity,
ambient.now_ms,
) {
Ok(result) => {
// The decrypt-worker registration relocated
// OUT of `promote_connection` into THIS single executor
// arm — the one live caller of `promote_connection` (both
// the inbound `handle_msg1` and outbound `handle_msg2`
// net-new establish paths reach it here). Register iff the
// promotion actually created or replaced a peer
// (`Promoted | CrossConnectionWon`), NEVER on
// `CrossConnectionLost`. Run synchronously right after
// `promote_connection` returns, before feeding
// `PromotionResolved` and before any await — the exact
// synchronous point (and Promoted/Won gating) of the
// pre-refactor in-`promote_connection` call. No-op when
// the worker pool isn't spawned (`register_...` early-
// returns), so the direct `promote_connection` test
// callers (which bypass this executor) are unaffected.
#[cfg(unix)]
match result {
PromotionResult::Promoted(node_addr)
| PromotionResult::CrossConnectionWon { node_addr, .. } => {
self.register_decrypt_worker_session(&node_addr);
);
match &promote_result {
Ok(PromotionResult::Promoted(node_addr)) => {
let node_addr = *node_addr;
if ambient.is_outbound {
// The outbound promote logs a second line here in
// addition to `promote_connection`'s "Connection
// promoted to active peer". Pin the target so the
// relocated line keeps the module it filtered under.
info!(
target: "fips::node::handlers::handshake",
peer = %self.peer_display_name(&node_addr),
"Peer promoted to active"
);
} else {
// Store msg2 on peer for resend on duplicate msg1
if let (Some(peer), Some(msg2)) =
(self.peers.get_mut(&node_addr), wire_msg2)
{
peer.set_handshake_msg2(msg2);
}
PromotionResult::CrossConnectionLost { .. } => {}
// Promotion is logged once by `promote_connection`
// ("Connection promoted to active peer"); no separate
// inbound-path line.
}
// Feed the outcome back into the machine and fold the
// follow-up actions (RegisterDecryptSession — now a
// redundant no-op, see its arm — and the cross-conn index
// frees) into the worklist. Disjoint field borrow again.
let follow = match self.peer_machines.get_mut(&promote_link) {
Some(machine) => machine.step(
PeerEvent::PromotionResolved { result },
ambient.now_ms,
&mut self.index_allocator,
),
None => Vec::new(),
};
queue.extend(follow);
// Defensive cross-connection loser-link surgery.
// LINK-ONLY: close the losing transport connection, drop
// its link, and re-point `addr_to_link`, reproducing the
// pre-refactor inline `handle_msg2`/`handle_msg1` per-arm
// order EXACTLY. The index-plane frees/unregisters are
// owned by the machine's `PromotionResolved{Won/Lost}`
// follow-up (queued just above), so NOTHING here touches
// an index — no double-free.
//
// UNREACHABLE on every current driven path: the inbound
// and outbound net-new establish arms only route to the
// machine when no promoted peer exists for the node_addr
// (and `RestartThenPromote` removes the old peer first),
// so `promote_connection` always returns `Promoted`. The
// `debug_assert!(false, ..)` catches any future path that
// drives a cross-connection through the executor without
// the matching send-state handling.
match result {
PromotionResult::CrossConnectionWon { loser_link_id, .. } => {
debug_assert!(
false,
"executor CrossConnectionWon is unreachable on \
driven net-new establish paths"
);
// Close the losing transport connection (no-op for
// connectionless) via the LOSER link's own
// transport/addr, then drop the losing link.
if let Some(loser_link) = self.links.get(&loser_link_id) {
let loser_tid = loser_link.transport_id();
let loser_addr = loser_link.remote_addr().clone();
if let Some(transport) = self.transports.get(&loser_tid) {
transport.close_connection(&loser_addr).await;
}
}
self.remove_link(&loser_link_id);
// Point `addr_to_link` at the winning (current)
// link.
self.addr_to_link.insert(
(ambient.transport_id, ambient.remote_addr.clone()),
promote_link,
);
}
PromotionResult::CrossConnectionLost { winner_link_id } => {
debug_assert!(
false,
"executor CrossConnectionLost is unreachable on \
driven net-new establish paths"
);
// Close this (losing) connection, drop its link,
// and restore `addr_to_link` to the winner.
if let Some(transport) =
self.transports.get(&ambient.transport_id)
{
transport.close_connection(&ambient.remote_addr).await;
}
self.remove_link(&promote_link);
self.addr_to_link.insert(
(ambient.transport_id, ambient.remote_addr.clone()),
winner_link_id,
);
}
PromotionResult::Promoted(_) => {}
// Send initial tree announce to new peer
if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await {
debug!(peer = %self.peer_display_name(&node_addr), error = %e, "Failed to send initial TreeAnnounce");
}
// Schedule filter announce (sent on next tick via debounce)
self.bloom_state.mark_update_needed(node_addr);
self.reset_lookup_backoff();
// Clear the pending outbound entry on promote success
// only; a failed promote leaves it for the stale-
// connection reaper.
if let Some(k) = ambient.pending_outbound_key {
self.pending_outbound.remove(&k);
}
}
Ok(PromotionResult::CrossConnectionWon {
loser_link_id,
node_addr,
}) => {
let (loser_link_id, node_addr) = (*loser_link_id, *node_addr);
// UNREACHABLE on driven XX establish paths: `Promote`
// and `RestartThenPromote` (which removes the old peer
// first) both imply no existing peer at promote time, so
// `promote_connection` returns `Promoted`. Body kept
// byte-equivalent to next so a future path that drives a
// cross-connection through the executor trips the assert.
debug_assert!(
false,
"executor CrossConnectionWon is unreachable on driven \
XX inbound establish paths"
);
// Store msg2 on peer for resend on duplicate msg1
if let (Some(peer), Some(msg2)) =
(self.peers.get_mut(&node_addr), wire_msg2)
{
peer.set_handshake_msg2(msg2);
}
// Close the losing TCP connection (no-op for connectionless)
if let Some(loser_link) = self.links.get(&loser_link_id) {
let loser_tid = loser_link.transport_id();
let loser_addr = loser_link.remote_addr().clone();
if let Some(transport) = self.transports.get(&loser_tid) {
transport.close_connection(&loser_addr).await;
}
}
// Clean up the losing connection's link
self.remove_link(&loser_link_id);
debug!(
peer = %self.peer_display_name(&node_addr),
loser_link_id = %loser_link_id,
"Inbound cross-connection won, loser link cleaned up"
);
if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await {
debug!(peer = %self.peer_display_name(&node_addr), error = %e, "Failed to send initial TreeAnnounce");
}
self.bloom_state.mark_update_needed(node_addr);
self.reset_lookup_backoff();
}
Ok(PromotionResult::CrossConnectionLost { winner_link_id }) => {
let winner_link_id = *winner_link_id;
// UNREACHABLE on driven XX establish paths (see the Won
// arm). Body kept byte-equivalent to next; uses the
// ambient transport/addr in place of next's `packet.*`.
debug_assert!(
false,
"executor CrossConnectionLost is unreachable on driven \
XX inbound establish paths"
);
// Close the losing TCP connection (no-op for connectionless)
if let Some(transport) = self.transports.get(&ambient.transport_id) {
transport.close_connection(&ambient.remote_addr).await;
}
// This connection lost — clean up its link
self.remove_link(&promote_link);
// Restore addr_to_link for the winner's link
self.addr_to_link.insert(
(ambient.transport_id, ambient.remote_addr.clone()),
winner_link_id,
);
debug!(
winner_link_id = %winner_link_id,
"Inbound cross-connection lost, keeping existing"
);
}
Err(e) if ambient.is_outbound => {
// The outbound promote-failure path is warn-only: it
// records the reject but performs no link/index teardown
// and leaves the `pending_outbound` entry for the stale-
// connection reaper. The leg's machine must go with the
// leg, though: `promote_connection` took the pending
// connection off the machine before erring, so the reaper
// (which sweeps the machines' embedded legs) can never
// reach this link's machine — dropping it here is the
// only disposal point.
warn!(
target: "fips::node::handlers::handshake",
link_id = %promote_link,
error = %e,
"Failed to promote connection"
);
self.remove_peer_machine(promote_link);
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
}
Err(e) => {
// Promotion failed. `promote_connection` already
// removed `connections[link]` and (on error) handled its
// own index internally. The pre-refactor inbound and
// outbound promote-Err arms were NOT byte-identical, so
// discriminate on `ambient.is_outbound`. The queue is
// drained (PromoteToActive is the last establish action),
// so no explicit abort.
if ambient.is_outbound {
// OLD outbound (`handle_msg2` promote-Err): warn +
// record_reject ONLY. NO `remove_link`, NO
// `index_allocator.free`, NO `addr_to_link` removal —
// the dead link/addr_to_link/pending_outbound were
// left for the 30s stale-connection reaper
// (`promote_connection` already handled
// `connections[link]`/its index on error). Restored
// pre-refactor outbound warn! ("Failed to promote
// connection").
//
// The outbound machine was persisted at dial; it is
// additive state that did not exist pre-refactor, so
// removing it on promote failure is neutral vs old and
// prevents a leak.
warn!(
// A max_peers rejection is expected policy, not a fault —
// log it at debug to avoid WARN spam when a cap'd node is
// under sustained inbound pressure. Other promotion
// failures remain at warn.
if matches!(e, NodeError::MaxPeersExceeded { .. }) {
debug!(
// Emit under the handshake target (same as the
// "promoting inbound" line above) so this stays
// visible wherever inbound handshake events are
// logged at debug, independent of this module's
// own log level.
target: "fips::node::handlers::handshake",
link_id = %promote_link,
error = %e,
"Failed to promote connection"
peer = %self.peer_display_name(ambient.verified_identity.node_addr()),
max = self.max_peers(),
"Rejecting inbound connection at max_peers cap (no promotion)"
);
self.stats_mut().record_reject(RejectReason::Handshake(
HandshakeReject::BadState,
));
self.remove_peer_machine(promote_link);
} else {
// OLD inbound (`handle_msg1` L587-591): drop the link
// + reverse map, free our index, discard the machine,
// and record the reject. Restored pre-refactor inbound
// promote-failure warn! (`handle_msg1` L757).
warn!(
target: "fips::node::handlers::handshake",
link_id = %promote_link,
error = %e,
"Failed to promote inbound connection"
);
self.remove_link(&promote_link);
if let Some(idx) = ambient.our_index {
let _ = self.index_allocator.free(idx);
}
self.remove_peer_machine(promote_link);
self.stats_mut().record_reject(RejectReason::Handshake(
HandshakeReject::BadState,
));
}
// Clean up on promotion failure. promote_connection
// already freed our_index in its MaxPeersExceeded path;
// freeing again here is benign (IndexAllocator::free is a
// HashSet::remove, the second call returns Err(NotFound)
// and is ignored).
self.remove_link(&promote_link);
if let Some(idx) = ambient.our_index {
let _ = self.index_allocator.free(idx);
}
self.remove_peer_machine(promote_link);
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
}
}
// Feed the promotion outcome back into the surviving machine
// and fold the follow-up actions onto the worklist (the
// `Promoted` arm crystallizes the machine in place and emits
// `RegisterDecryptSession`, a redundant no-op here — see its
// arm). Unconditional across Ok variants; on
// `CrossConnectionLost` the losing leg's machine was disposed
// inside `promote_connection`, so the lookup misses and no
// machine-side index free can double the inline one. Disjoint
// field borrow again.
if let Ok(result) = promote_result {
let follow = match self.peer_machines.get_mut(&promote_link) {
Some(machine) => machine.step(
PeerEvent::PromotionResolved { result },
ambient.now_ms,
&mut self.index_allocator,
),
None => Vec::new(),
};
queue.extend(follow);
}
}
PeerAction::ResolveCrossConnection { .. } => {
// A decision token, not an effect: the outbound msg2
@@ -426,14 +507,21 @@ impl Node {
)),
"peers_by_index should contain pre-registered new index after cutover"
);
debug!(
let our_index = peer.our_index();
let their_index = peer.their_index();
info!(
// Pin the target to the pre-refactor module: this
// cutover log relocated from handlers/rekey.rs into
// the executor, but operators (and the test harness)
// filter it under fips::node::handlers::rekey. Keeping
// the target preserves the observable log contract.
// filter it under fips::node::handlers::rekey.
// Keeping the target preserves the observable log
// contract (level + fields match next's rekey.rs).
target: "fips::node::handlers::rekey",
peer = %self.peer_display_name(&node_addr),
our_addr = %self.identity().node_addr(),
their_addr = %node_addr,
our_index = ?our_index,
their_index = ?their_index,
"Rekey cutover complete (initiator), K-bit flipped"
);
true
@@ -444,9 +532,9 @@ impl Node {
false
};
// Re-register the new session with the decrypt worker — the
// cache_key (transport_id, our_index) just changed, so the
// old worker entry is stale and every packet on the new
// session would miss the worker's HashMap lookup.
// cache_key (transport_id, our_index) just changed, so the old
// worker entry is stale and every packet on the new session
// would miss the worker's HashMap lookup.
#[cfg(unix)]
if did_cutover {
self.register_decrypt_worker_session(&node_addr);
@@ -455,11 +543,14 @@ impl Node {
let _ = did_cutover;
}
PeerAction::CompleteDrain { peer: node_addr } => {
// Initiator drain completion: the live authoritative
// rekey-cadence path, routed here from `check_rekey` via
// Drain completion: the live authoritative rekey-cadence
// path, routed here from `check_rekey` via
// `route_rekey_cadence` → `PeerEvent::RekeyConsume`; the
// inline body survives only as `drain_peer_inline`, a
// debug-assert release fallback. Extract the real previous
// debug-assert release fallback. Reached in either rekey
// role — a responder's cutover demotes a session the same
// way an initiator's does — including on a node whose own
// trigger is off. Extract the real previous
// index + transport_id under the peer borrow, drop the
// borrow, then run the cache_key cleanup (which takes
// &mut self for unregister_decrypt_worker_session).
@@ -486,25 +577,205 @@ impl Node {
}
}
PeerAction::InvalidateSendState => {
// The FULL teardown. `remove_active_peer`
// (`dispatch.rs:107`) frees the four index slots
// (current/rekey/pending/previous), drops `peers_by_index`,
// unregisters the decrypt worker, removes the FSP `sessions`
// entry and `pending_tun_packets`. The machine emits NO
// `FreeIndex` for those slots, so there is no double-free.
// The FULL teardown. `remove_active_peer` frees the four index
// slots (current/rekey/pending/previous), drops
// `peers_by_index`, unregisters the decrypt worker, removes the
// FSP `sessions` entry and `pending_tun_packets`. The machine
// emits NO `FreeIndex` for those slots, so there is no
// double-free.
self.remove_active_peer(ambient.verified_identity.node_addr());
}
PeerAction::SwapToInboundSession {
peer,
our_index,
our_inbound_wins,
} => {
// Simultaneous-init cross-connection resolved at msg3 (msg2-then-
// msg3 ordering): apply the same tie-breaker the inverse ordering
// uses so both sides converge on a single Noise session pair.
let their_index = ambient
.their_index
.expect("cross-connection swap carries the peer session index");
if our_inbound_wins {
// Larger node side: swap to the inbound session so it pairs
// with the peer's kept outbound session.
let inbound_session = match self
.peer_machines
.get_mut(&link)
.and_then(|m| m.take_session())
{
Some(s) => s,
None => {
self.remove_link(&link);
self.remove_peer_machine(link);
self.stats_mut().record_reject(RejectReason::Handshake(
HandshakeReject::BadState,
));
return;
}
};
// A rekey of ours in flight is now stale: the session it
// was negotiated against is about to be replaced, and
// `replace_session` rewrites the session and both indices
// while touching no rekey state. Abandoning here is what
// lets the classifier take this arm unconditionally —
// declining the swap instead would desynchronize us from
// the peer's outbound half, which cannot see our rekey.
// Same clearing the rekey-responder arm does on its
// `abandon_first` path.
if let Some(peer_ref) = self.peers.get_mut(&peer)
&& let Some(idx) = peer_ref.abandon_rekey()
{
if let Some(tid) = peer_ref.transport_id() {
self.peers_by_index.remove(&(tid, idx.as_u32()));
self.pending_outbound.remove(&(tid, idx.as_u32()));
}
let _ = self.index_allocator.free(idx);
}
if let Some(peer_ref) = self.peers.get_mut(&peer) {
let old_our_index =
peer_ref.replace_session(inbound_session, our_index, their_index);
let Some(transport_id) = peer_ref.transport_id() else {
self.remove_link(&link);
self.remove_peer_machine(link);
self.stats_mut().record_reject(RejectReason::Handshake(
HandshakeReject::BadState,
));
return;
};
if let Some(old_idx) = old_our_index {
self.peers_by_index
.remove(&(transport_id, old_idx.as_u32()));
let _ = self.index_allocator.free(old_idx);
}
self.peers_by_index
.insert((transport_id, our_index.as_u32()), peer);
debug!(
peer = %self.peer_display_name(&peer),
new_our_index = %our_index,
new_their_index = %their_index,
"Simultaneous-init (msg3): swapped to inbound session (our inbound wins)"
);
}
} else {
// Smaller node side: keep the existing outbound session, drop
// the inbound leg's allocated index.
let _ = self.index_allocator.free(our_index);
debug!(
peer = %self.peer_display_name(&peer),
"Simultaneous-init (msg3): keeping outbound session (our outbound wins)"
);
}
// Both branches tear down the temporary inbound link fully
// (including its `addr_to_link` mapping) via `remove_link`,
// disposing the leg's machine (and its embedded connection)
// with it.
self.remove_link(&link);
self.remove_peer_machine(link);
return;
}
PeerAction::RekeyRespondTrigger {
peer,
our_index,
abandon_first,
} => {
// Rekey-responder resolved at msg3: store the new session as
// pending on the existing peer, awaiting the K-bit cutover.
let their_index = ambient
.their_index
.expect("rekey-responder trigger carries the peer session index");
if abandon_first {
// We lose the dual-rekey tie-break (larger addr): abandon our
// own rekey/pending and fall through as responder.
// `abandon_rekey` clears both the in-progress flag and any
// pending session state, returning whichever index needs
// freeing.
info!(
peer = %self.peer_display_name(&peer),
our_addr = %self.identity().node_addr(),
their_addr = %peer,
"rekey-msg3 tie-break: we lose (larger addr), abandon ours"
);
if let Some(peer_ref) = self.peers.get_mut(&peer)
&& let Some(idx) = peer_ref.abandon_rekey()
{
if let Some(tid) = peer_ref.transport_id() {
self.peers_by_index.remove(&(tid, idx.as_u32()));
self.pending_outbound.remove(&(tid, idx.as_u32()));
}
let _ = self.index_allocator.free(idx);
}
}
// Rekey: process as responder, store new session as pending.
let noise_session = {
let Some(machine) = self.peer_machines.get_mut(&link) else {
warn!(link_id = %link, "Connection removed during rekey msg3 processing");
self.links.remove(&link);
self.remove_peer_machine(link);
self.stats_mut().record_reject(RejectReason::Handshake(
HandshakeReject::UnknownConnection,
));
return;
};
machine.take_session()
};
let our_new_index = our_index;
let noise_session = match noise_session {
Some(s) => s,
None => {
warn!("Rekey msg3: no session from handshake");
self.links.remove(&link);
self.remove_peer_machine(link);
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
}
};
// Store pending session on the existing peer
if let Some(peer_ref) = self.peers.get_mut(&peer) {
peer_ref.set_pending_session(noise_session, our_new_index, their_index);
peer_ref.record_peer_rekey();
}
// Register new index in peers_by_index
self.peers_by_index
.insert((ambient.transport_id, our_new_index.as_u32()), peer);
// Clean up: remove the temporary link and the leg's machine
// (dropping its embedded connection; the established peer
// keeps its own machine, keyed by its own link). Do NOT
// remove addr_to_link — the entry must remain pointing to
// the original link so the established peer stays routable,
// so this uses the bare `links.remove` rather than the full
// `remove_link`.
self.links.remove(&link);
self.remove_peer_machine(link);
debug!(
peer = %self.peer_display_name(&peer),
our_addr = %self.identity().node_addr(),
new_our_index = %our_new_index,
new_their_index = %their_index,
"rekey-msg3 responder: pending session set, awaiting K-bit cutover"
);
return;
}
PeerAction::RegisterDecryptSession { index } => {
let _ = index;
// No-op by design. The decrypt-worker
// registration relocated into the `PromoteToActive` Ok arm above, gated on
// the returned `PromotionResult`, so it runs once per live
// promote (Promoted/Won) at the pre-refactor synchronous point.
// This machine-emitted action is now redundant with that arm;
// No-op by design. The rekey-cutover decrypt-worker register
// relocates into the driven `SwapSendState` site above (gated
// on `did_cutover`); the establish-promote register stays INSIDE
// `promote_connection`, so `PromoteToActive` does not re-register
// either. This machine-emitted action is redundant with both;
// kept as an inert no-op (rather than removing the emission) so
// the machine's action sequence and its unit tests stay
// unchanged. The keyed-by-NodeAddr register does not need the
// machine's `index` payload.
// the machine's action sequence and unit tests stay unchanged.
// The keyed-by-NodeAddr register does not need the machine's
// `index` payload.
}
PeerAction::UnregisterDecryptSession { index } => {
// Executor supplies `transport_id` from ambient; keyed by
@@ -518,7 +789,8 @@ impl Node {
let _ = self.index_allocator.free(index);
}
PeerAction::ActivateConnectedUdp | PeerAction::TeardownConnectedUdp => {
// Connected-UDP plane ownership (`connected_udp.rs`).
// Connected-UDP plane ownership (`connected_udp.rs`). Out of
// scope for now.
}
PeerAction::SetTimer { kind, at_ms } => {
// Populate the per-peer timer store (overwrite = reschedule).
+7
View File
@@ -4,6 +4,7 @@ use crate::control::{ControlSocket, commands};
use crate::node::{Node, NodeError};
use crate::proto::fmp::wire::{
COMMON_PREFIX_SIZE, CommonPrefix, FMP_VERSION, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2,
PHASE_MSG3,
};
use crate::transport::ReceivedPacket;
use std::time::Duration;
@@ -29,6 +30,7 @@ impl Node {
/// - Phase 0x0: Encrypted frame (session data)
/// - Phase 0x1: Handshake message 1 (initiator -> responder)
/// - Phase 0x2: Handshake message 2 (responder -> initiator)
/// - Phase 0x3: Handshake message 3 (initiator -> responder, XX completion)
///
/// Also processes outbound IPv6 packets from the TUN reader for session
/// encapsulation and routing through the mesh.
@@ -363,6 +365,8 @@ impl Node {
self.drive_peer_timers(now_ms).await);
instr_step!(instr_on, crate::instr::Domain::Tick, crate::instr::Step::ResendPendingRekeys,
self.resend_pending_rekeys(now_ms).await);
instr_step!(instr_on, crate::instr::Domain::Tick, crate::instr::Step::ResendPendingFmpRekeyMsg3,
self.resend_pending_fmp_rekey_msg3(now_ms).await);
instr_step!(instr_on, crate::instr::Domain::Tick, crate::instr::Step::ResendPendingSessionHandshakes,
self.resend_pending_session_handshakes(now_ms).await);
instr_step!(instr_on, crate::instr::Domain::Tick, crate::instr::Step::ResendPendingSessionMsg3,
@@ -499,6 +503,9 @@ impl Node {
PHASE_MSG2 => {
self.handle_msg2(packet).await;
}
PHASE_MSG3 => {
self.handle_msg3(packet).await;
}
_ => {
debug!(
phase = prefix.phase,
+8 -9
View File
@@ -112,8 +112,8 @@ pub(crate) struct DecryptJob {
pub fmp_counter: u64,
/// Flag byte from the FMP outer header. Carried through the
/// fallback so the rx_loop bounce arm can extract `CE` and `SP`
/// for ECN propagation, MMP stats, and spin-bit RTT
/// observation — these used to be dropped on the worker path
/// for ECN propagation and MMP stats — these used to be dropped
/// on the worker path
/// because the bounce hardcoded `fmp_flags: 0`.
pub fmp_flags: u8,
/// 16-byte FMP outer header used as AAD during AEAD open.
@@ -544,8 +544,7 @@ mod tests {
/// `DecryptFallback.fmp_flags`. Pre-fix the worker hardcoded
/// `fmp_flags: 0`, dropping CE / SP on every packet handled by
/// the production worker path (i.e. every bulk-data packet).
/// Loss of CE wrecks ECN propagation; loss of SP wrecks
/// spin-bit RTT observation.
/// Loss of CE wrecks ECN propagation.
///
/// Drives the worker's `handle_job` directly: build an FMP wire
/// packet sealed with a known cipher, ship a `DecryptJob` with
@@ -567,9 +566,9 @@ mod tests {
// double as AAD and as the on-wire prefix.
let mut wire = Vec::with_capacity(HDR + 4 + 1 + 16);
// Header: fill the flags byte (the second byte) with both
// FLAG_CE and FLAG_SP set; the rest is uninterpreted by the
// worker (it just AADs the whole 16 bytes).
let flags_byte = crate::proto::fmp::wire::FLAG_CE | crate::proto::fmp::wire::FLAG_SP;
// FLAG_CE and FLAG_KEY_EPOCH set; the rest is uninterpreted by
// the worker (it just AADs the whole 16 bytes).
let flags_byte = crate::proto::fmp::wire::FLAG_CE | crate::proto::fmp::wire::FLAG_KEY_EPOCH;
let mut header = [0u8; HDR];
header[1] = flags_byte;
wire.extend_from_slice(&header);
@@ -630,8 +629,8 @@ mod tests {
"FLAG_CE bit lost on worker path"
);
assert!(
fallback.fmp_flags & crate::proto::fmp::wire::FLAG_SP != 0,
"FLAG_SP bit lost on worker path"
fallback.fmp_flags & crate::proto::fmp::wire::FLAG_KEY_EPOCH != 0,
"FLAG_KEY_EPOCH bit lost on worker path"
);
}
File diff suppressed because it is too large Load Diff
+47 -7
View File
@@ -38,6 +38,21 @@ impl crate::proto::lookup::RoutingView for NodeRoutingView<'_> {
.map(|(addr, _)| *addr)
.collect()
}
fn node_is_leaf(&self) -> bool {
self.node.node_profile() == crate::proto::fmp::NodeProfile::Leaf
}
fn peer_is_full(&self, addr: &NodeAddr) -> bool {
self.node
.peers
.get(addr)
.is_some_and(|peer| peer.peer_profile() == crate::proto::fmp::NodeProfile::Full)
}
fn peer_meets_mtu(&self, addr: &NodeAddr, min_mtu: u16) -> bool {
self.node
.peers
.get(addr)
.is_some_and(|peer| self.node.peer_meets_mtu(peer, min_mtu))
}
}
impl Node {
@@ -435,14 +450,16 @@ impl Node {
/// stale bloom filters, tree restructuring, or transit node failures.
async fn forward_lookup_request(&mut self, mut request: LookupRequest) {
// Plan the forward with the sans-IO decision core. The core owns the
// TTL decrement, tree/fallback peer selection, and single-encode
// fan-out; the shell keeps all metrics/logging and drives the sends.
// TTL decrement, Leaf suppression, Full+MTU eligibility, tree/fallback
// peer selection, and single-encode fan-out; the shell keeps all
// metrics/logging and drives the sends.
let outcome = {
let rv = NodeRoutingView { node: self };
crate::proto::lookup::plan_forward(&mut request, &rv)
};
match outcome {
crate::proto::lookup::ForwardOutcome::TtlExhausted => {}
crate::proto::lookup::ForwardOutcome::LeafNoForward => {}
crate::proto::lookup::ForwardOutcome::NoPeers => {
self.metrics().lookup.req_no_tree_peer.inc();
trace!(
@@ -498,16 +515,17 @@ impl Node {
self.metrics().lookup.req_initiated.inc();
let origin = *self.node_addr();
let origin_coords = self.tree_state().my_coords().clone();
let min_mtu = self.config().tun.mtu();
let request_id = {
use rand::RngExt;
rand::rng().random()
};
let request = LookupRequest::new(request_id, *target, origin, origin_coords, ttl, 0);
let request = LookupRequest::new(request_id, *target, origin, ttl, min_mtu);
// Tree-peer bloom-match selection + single encode live in the sans-IO
// core. The core keeps the tree-only (no non-tree fallback) behavior;
// the shell drives the sends and keeps all metrics/logging.
// Tree-peer selection restricted to Full peers meeting min_mtu, plus the
// single encode, live in the sans-IO core. The core keeps the tree-only
// (no non-tree fallback) behavior; the shell drives the sends and keeps
// all metrics/logging.
let actions = {
let rv = NodeRoutingView { node: self };
crate::proto::lookup::plan_initiate(&request, &rv)
@@ -651,6 +669,28 @@ impl Node {
}
}
/// Check if a peer's outgoing link MTU meets the min_mtu requirement.
///
/// Returns true if min_mtu is 0 (no requirement) or if the peer's
/// transport link MTU is >= min_mtu.
fn peer_meets_mtu(&self, peer: &crate::peer::ActivePeer, min_mtu: u16) -> bool {
if min_mtu == 0 {
return true;
}
if let Some(tid) = peer.transport_id()
&& let Some(transport) = self.transports.get(&tid)
{
let link_mtu = peer
.current_addr()
.map(|addr| transport.link_mtu(addr))
.unwrap_or_else(|| transport.mtu());
link_mtu >= min_mtu
} else {
// No transport info available — don't prune
true
}
}
/// Min-fold our outgoing-link MTU into a LookupResponse's `path_mtu`.
///
/// Used at both transit-side reverse-path forward and at the target's
+27 -27
View File
@@ -107,6 +107,7 @@ impl Node {
trace!(
from = %self.peer_display_name(from),
cum_pkts = sr.cumulative_packets_sent,
interval_pkts = sr.interval_packets_sent,
interval_bytes = sr.interval_bytes_sent,
"Received SenderReport"
);
@@ -202,13 +203,11 @@ impl Node {
.map(|d| d.as_secs())
.unwrap_or(0);
let mono_now_ms = crate::time::mono_ms();
let skip = self.non_full_peers();
// Compute the flap-dampening / hold-down veto at the edge; a mandatory
// switch bypasses it, a discretionary one is taken only if not suppressed.
let switch_suppressed = self.tree_state.is_switch_suppressed(mono_now_ms);
let new_parent = match self
.tree_state
.evaluate_parent(&peer_costs, &std::collections::BTreeSet::new())
{
let new_parent = match self.tree_state.evaluate_parent(&peer_costs, &skip) {
ParentEval::Mandatory(p) => Some(p),
ParentEval::Discretionary(p) if !switch_suppressed => Some(p),
ParentEval::Discretionary(_) | ParentEval::None => None,
@@ -289,12 +288,12 @@ impl Node {
let now_ms = crate::time::mono_ms();
// Build one report-gating snapshot per peer, resolving every timing read
// shell-side into a `bool`. `send_sr`/`send_rr` are `true` on the master
// (IK) line — there is no profile negotiation here; the forward-merge to
// `-next` wires them to `peer.send_sr()`/`peer.send_rr()` (plan spot c).
// The snapshots own only `NodeAddr`/`MmpMode`/`bool`, so the
// peer-iteration borrow is released before the pure decision runs and the
// driving loop mutates the reporting state.
// shell-side into a `bool`. `send_sr`/`send_rr` come from the peer's
// negotiated profile (whether it provides/wants each report); the core
// ANDs them into the mode/timing gate. The snapshots own only
// `NodeAddr`/`MmpMode`/`bool`, so the peer-iteration borrow is released
// before the pure decision runs and the driving loop mutates the
// reporting state.
let snapshots: Vec<LinkReportSnapshot> = self
.peers
.iter()
@@ -303,8 +302,8 @@ impl Node {
Some(LinkReportSnapshot {
peer: *node_addr,
mode: mmp.mode(),
send_sr: true,
send_rr: true,
send_sr: peer.send_sr(),
send_rr: peer.send_rr(),
sr_due: mmp.sender.should_send_report(now_ms),
rr_due: mmp.receiver.should_send_report(now_ms),
log_due: mmp.should_log(now_ms),
@@ -419,7 +418,6 @@ impl Node {
etx = format_args!("{:.2}", m.etx),
goodput = %format_throughput(m.goodput_bps()),
tx_pkts = mmp.sender.cumulative_packets_sent(),
tx_bytes = mmp.sender.cumulative_bytes_sent(),
rx_pkts = mmp.receiver.cumulative_packets_recv(),
rx_bytes = mmp.receiver.cumulative_bytes_recv(),
"MMP link teardown"
@@ -465,14 +463,16 @@ impl Node {
};
// Suppress teardown while an FMP rekey is genuinely in flight
// with budget left: a rekey-handshake link is not silent. The
// msg1 resend cap guarantees this terminates (abandon on
// exhaustion or cutover on completion clears
// `rekey_in_progress`), so a truly dead link is reaped on the
// next cycle.
let rekey_active = peer.rekey_in_progress()
// with budget left: a rekey-handshake link is not silent
// whether mid-msg1 or mid-msg3 retransmit. The resend caps
// guarantee this terminates (abandon on exhaustion or cutover on
// completion clears the rekey state), so a truly dead link is
// reaped on the next cycle.
let rekey_active = (peer.rekey_in_progress()
&& peer.rekey_msg1_resend_count() < max_resends
&& peer.rekey_msg1().is_some();
&& peer.rekey_msg1().is_some())
|| (peer.rekey_msg3_payload().is_some()
&& peer.rekey_msg3_resend_count() < max_resends);
// Check if heartbeat is due.
let heartbeat_due = match peer.last_heartbeat_sent() {
@@ -533,12 +533,12 @@ impl Node {
}
/// Route a link-dead liveness reap through the peer machine + executor.
/// Mirrors [`route_rekey_cadence`](Node::route_rekey_cadence): the
/// shell already decided (the tick sweep's `plan_heartbeats` batch emitted
/// this `ReapPeer` in phase order), so the machine only CONSUMES the decision
/// via [`PeerEvent::LinkDeadSuspected`]. The resulting executor arms
/// The shell already decided (the tick sweep's `plan_heartbeats` batch
/// emitted this `ReapPeer` in phase order), so the machine only CONSUMES the
/// decision via [`PeerEvent::LinkDeadSuspected`]. The resulting executor arms
/// (`InvalidateSendState` → `remove_active_peer`, `ReportLost` →
/// `note_link_dead`) reproduce the pre-refactor inline reap body exactly.
/// `note_link_dead`) reproduce the pre-refactor inline reap body exactly, in
/// that order.
///
/// An established peer always has a `peer_machine`. If the peer vanished
/// between snapshot and effect, the old inline body was already a
@@ -564,8 +564,7 @@ impl Node {
.await;
}
/// Ambient shell facts for the routed liveness reap. Mirrors
/// [`rekey_cadence_ctx`](Node::rekey_cadence_ctx). The executor reads only
/// Ambient shell facts for the routed liveness reap. The executor reads only
/// `verified_identity` (`InvalidateSendState` → `remove_active_peer` resolves
/// its `NodeAddr` from it, so it must equal `node_addr`) and `now_ms`
/// (`ReportLost` → `note_link_dead`, the wall-clock reconnect basis). The
@@ -586,6 +585,7 @@ impl Node {
their_index: peer.their_index(),
now_ms,
is_outbound: false,
pending_outbound_key: None,
}
}
}
+129 -24
View File
@@ -8,6 +8,7 @@
use crate::NodeAddr;
use crate::node::Node;
use crate::node::dataplane::PeerActionCtx;
use crate::node::reject::{HandshakeReject, RejectReason};
use crate::noise::HandshakeState;
use crate::peer::machine::PeerEvent;
use crate::proto::fmp::wire::build_msg1;
@@ -17,7 +18,7 @@ use crate::proto::fsp::{
};
use crate::proto::link::SessionDatagram;
use crate::transport::{TransportAddr, TransportId};
use tracing::{debug, trace, warn};
use tracing::{debug, info, trace, warn};
/// Keep previous session alive for this long after cutover.
///
@@ -36,12 +37,17 @@ impl Node {
/// - If the initiator has a pending session, perform K-bit cutover
/// - If the drain window has expired, clean up the previous session
/// - If the rekey timer/counter fires, initiate a new handshake
///
/// Runs whatever `node.rekey.enabled` says, because the drain in the middle
/// of that list is not initiation: a node that never initiates still accepts
/// its peers' rekeys, and each acceptance demotes a live session that only
/// this path releases. The flag rides into the core as `RekeyCfg::initiate`
/// and suppresses the two initiating arms there, so which arms a disabled
/// node runs stays one decision in one place rather than an early return
/// here and a rule there.
pub(in crate::node) async fn check_rekey(&mut self) {
if !self.config().node.rekey.enabled {
return;
}
let cfg = RekeyCfg {
initiate: self.config().node.rekey.enabled,
after_secs: self.config().node.rekey.after_secs,
after_messages: self.config().node.rekey.after_messages,
};
@@ -49,11 +55,6 @@ impl Node {
// The shell snapshots each healthy peer's rekey ages/flags (every clock
// read resolved here); the core decides cutover/drain/trigger with no
// clock, phase-grouped to preserve the pre-refactor execution order.
// The batch `poll_rekey` + snapshots STAY SHELL-SIDE and BYTE-UNCHANGED:
// the cross-peer phase-grouping (all Cutover → all Drain →
// all InitiateRekey) governs the shared `index_allocator` free-then-alloc
// SEQUENCE that appears on the wire. The machine must NOT re-poll; it
// CONSUMES each decided `ConnAction` in the same order the batch returned.
let snapshots = self.rekey_peers();
for action in self.fmp.poll_rekey(snapshots, &cfg) {
match action {
@@ -166,6 +167,7 @@ impl Node {
their_index: peer.their_index(),
now_ms: Self::now_ms(),
is_outbound: false,
pending_outbound_key: None,
}
}
@@ -186,8 +188,14 @@ impl Node {
)),
"peers_by_index should contain pre-registered new index after cutover"
);
debug!(
let our_index = peer.our_index();
let their_index = peer.their_index();
info!(
peer = %self.peer_display_name(node_addr),
our_addr = %self.identity().node_addr(),
their_addr = %node_addr,
our_index = ?our_index,
their_index = ?their_index,
"Rekey cutover complete (initiator), K-bit flipped"
);
true
@@ -240,7 +248,7 @@ impl Node {
/// applies the thresholds without reading a clock (see [`PeerSnapshot`]).
///
/// Lives here, beside the drain/dampening constants and the FSP analog, so
/// the forward-merge onto `next` reconciles rekey timing in one place.
/// rekey timing is reconciled in one place.
pub(in crate::node) fn rekey_peer_snapshots(&self) -> Vec<PeerSnapshot> {
self.peers
.iter()
@@ -252,6 +260,7 @@ impl Node {
is_draining: peer.is_draining(),
drain_expired: peer.drain_expired(DRAIN_WINDOW_SECS),
is_dampened: peer.is_rekey_dampened(REKEY_DAMPENING_SECS),
rekey_msg3_pending: peer.rekey_msg3_payload().is_some(),
elapsed_secs: peer.session_established_at().elapsed().as_secs(),
counter: peer
.noise_session()
@@ -264,9 +273,9 @@ impl Node {
/// Initiate an outbound rekey to a peer.
///
/// Creates a new IK handshake as initiator, sends msg1 over the existing
/// Creates a new XX handshake as initiator, sends msg1 over the existing
/// link (same transport, same remote address), and stores the handshake
/// state on the ActivePeer. No new Link or PeerConnection is created.
/// state on the ActivePeer. No new Link or handshake leg is created.
async fn initiate_rekey(&mut self, node_addr: &NodeAddr) {
let peer = match self.peers.get(node_addr) {
Some(p) => p,
@@ -282,7 +291,6 @@ impl Node {
None => return,
};
let link_id = peer.link_id();
let peer_pubkey = peer.identity().pubkey_full();
// Allocate a new session index for the rekey
let our_index = match self.index_allocator.allocate() {
@@ -293,13 +301,15 @@ impl Node {
error = %e,
"Failed to allocate index for rekey"
);
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
}
};
// Create IK initiator handshake directly (no PeerConnection)
// Create XX initiator handshake directly (no handshake leg)
let our_keypair = self.identity().keypair();
let mut hs = HandshakeState::new_initiator(our_keypair, peer_pubkey);
let mut hs = HandshakeState::new_initiator(our_keypair);
hs.set_local_epoch(self.startup_epoch());
let noise_msg1 = match hs.write_message_1() {
@@ -311,6 +321,8 @@ impl Node {
"Failed to generate rekey msg1"
);
let _ = self.index_allocator.free(our_index);
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
}
};
@@ -334,12 +346,14 @@ impl Node {
"Failed to send rekey msg1"
);
let _ = self.index_allocator.free(our_index);
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
}
}
}
// Store handshake state on the ActivePeer (not a separate PeerConnection)
// Store handshake state on the ActivePeer (not a separate handshake leg)
let resend_interval = self.config().node.rate_limit.handshake_resend_interval_ms;
let now_ms = Self::now_ms();
if let Some(peer) = self.peers.get_mut(node_addr) {
@@ -434,6 +448,93 @@ impl Node {
.collect()
}
/// Retransmit FMP rekey msg3 until the responder is confirmed on the
/// new epoch.
///
/// FMP sends the rekey msg3 exactly once (in `handle_msg2`). If that
/// single datagram is lost, the responder never derives the new
/// session: when the initiator later cuts over on its own timer, its
/// new-epoch frames land on a `peers_by_index` index the responder
/// never built a session for, so they silently miss and the link dies
/// at the 30s heartbeat timeout. This driver retransmits the retained
/// msg3 over the existing link until the responder confirms.
///
/// Liveness only — overlapping-epoch / pending-authenticated decrypt
/// covers cutover skew. The retained payload is cleared (confirmed)
/// when an inbound peer frame authenticates against `pending` (peer cut
/// over first, in `handle_encrypted_frame`) or against the post-cutover
/// `current` session (initiator already cut over, responder reached the
/// new epoch, in `process_authentic_fmp_plaintext`). After
/// `handshake_max_resends` with no confirmation the cycle is abandoned.
pub(in crate::node) async fn resend_pending_fmp_rekey_msg3(&mut self, now_ms: u64) {
if !self.config().node.rekey.enabled {
return;
}
let interval_ms = self.config().node.rate_limit.handshake_resend_interval_ms;
let backoff = self.config().node.rate_limit.handshake_resend_backoff;
let max_resends = self.config().node.rate_limit.handshake_max_resends;
let mut to_resend: Vec<(NodeAddr, Vec<u8>)> = Vec::new();
let mut to_abandon: Vec<NodeAddr> = Vec::new();
for (node_addr, peer) in &self.peers {
let payload = match peer.rekey_msg3_payload() {
Some(p) => p,
None => continue,
};
if peer.rekey_msg3_next_resend_ms() == 0 || now_ms < peer.rekey_msg3_next_resend_ms() {
continue;
}
if peer.rekey_msg3_resend_count() >= max_resends {
to_abandon.push(*node_addr);
continue;
}
to_resend.push((*node_addr, payload.to_vec()));
}
// Clear retained payload on cycles that exhausted their budget.
// The new session may still cut over via the normal path; the
// responder will recover on its own next rekey if it never got
// msg3. Stopping retransmission just bounds the effort.
for node_addr in to_abandon {
if let Some(peer) = self.peers.get_mut(&node_addr) {
peer.clear_rekey_msg3_payload();
}
trace!(
peer = %self.peer_display_name(&node_addr),
"FMP rekey msg3 unconfirmed after max retransmissions, stopping resend"
);
}
for (node_addr, payload) in to_resend {
let (transport_id, remote_addr) = match self.peers.get(&node_addr) {
Some(p) => match (p.transport_id(), p.current_addr()) {
(Some(tid), Some(addr)) => (tid, addr.clone()),
_ => continue,
},
None => continue,
};
let sent = if let Some(transport) = self.transports.get(&transport_id) {
transport.send(&remote_addr, &payload).await.is_ok()
} else {
false
};
if sent && let Some(peer) = self.peers.get_mut(&node_addr) {
let count = peer.rekey_msg3_resend_count() + 1;
let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
peer.record_rekey_msg3_resend(next);
trace!(
peer = %self.peer_display_name(&node_addr),
resend = count,
"Resent FMP rekey msg3"
);
}
}
}
/// Retransmit FSP rekey msg3 until the responder is confirmed on the
/// new epoch.
///
@@ -627,7 +728,7 @@ impl Node {
/// Initiate an FSP session rekey.
///
/// Creates a new XK handshake as initiator, sends SessionSetup msg1
/// Creates a new XX handshake as initiator, sends SessionSetup msg1
/// through the mesh, and stores the handshake state on the existing entry.
async fn initiate_session_rekey(&mut self, dest_addr: &NodeAddr) {
// Check route availability before paying crypto cost
@@ -643,21 +744,23 @@ impl Node {
Some(e) => e,
None => return,
};
let dest_pubkey = *entry.remote_pubkey();
let _dest_pubkey = *entry.remote_pubkey();
// Create Noise XK initiator handshake
// Create Noise XX initiator handshake (rekey: no negotiation payload)
let our_keypair = self.identity().keypair();
let mut handshake = HandshakeState::new_xk_initiator(our_keypair, dest_pubkey);
let mut handshake = HandshakeState::new_initiator(our_keypair);
handshake.set_local_epoch(self.startup_epoch());
let msg1 = match handshake.write_xk_message_1() {
let msg1 = match handshake.write_message_1() {
Ok(m) => m,
Err(e) => {
warn!(
peer = %self.peer_display_name(dest_addr),
error = %e,
"Failed to generate FSP rekey XK msg1"
"Failed to generate FSP rekey XX msg1"
);
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
}
};
@@ -679,6 +782,8 @@ impl Node {
error = %e,
"Failed to send FSP rekey SessionSetup"
);
self.stats_mut()
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
return;
}
+193 -111
View File
@@ -2,7 +2,7 @@
//!
//! Handles locally-delivered session payloads from SessionDatagram envelopes.
//! Dispatches based on FSP common prefix phase to specific handlers for
//! SessionSetup (Noise XK msg1), SessionAck (msg2), SessionMsg3 (msg3),
//! SessionSetup (Noise XX msg1), SessionAck (msg2), SessionMsg3 (msg3),
//! encrypted data, and error signals (CoordsRequired, PathBroken).
use crate::NodeAddr;
@@ -10,13 +10,10 @@ use crate::node::handlers::mmp::format_throughput;
use crate::node::reject::{RejectReason, SessionReject};
use crate::node::session::{EndToEndState, EpochSlot, SessionEntry};
use crate::node::{Node, NodeError};
use crate::noise::{
HandshakeState, XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE, XK_HANDSHAKE_MSG3_SIZE,
};
use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, HANDSHAKE_MSG3_SIZE, HandshakeState};
use crate::proto::fmp::NegotiationPayload;
#[cfg(unix)]
use crate::proto::fmp::wire::{
ESTABLISHED_HEADER_SIZE, FLAG_KEY_EPOCH, FLAG_SP, build_established_header,
};
use crate::proto::fmp::wire::{ESTABLISHED_HEADER_SIZE, FLAG_KEY_EPOCH, build_established_header};
use crate::proto::fsp::wire::{
FSP_COMMON_PREFIX_SIZE, FSP_FLAG_CP, FSP_FLAG_K, FSP_HEADER_SIZE, FSP_PHASE_ESTABLISHED,
FSP_PHASE_MSG1, FSP_PHASE_MSG2, FSP_PHASE_MSG3, FSP_PORT_HEADER_SIZE, FSP_PORT_IPV6_SHIM,
@@ -68,7 +65,7 @@ impl Node {
///
/// - Phase 0x1 → SessionSetup (handshake msg1)
/// - Phase 0x2 → SessionAck (handshake msg2)
/// - Phase 0x3 → SessionMsg3 (XK handshake msg3)
/// - Phase 0x3 → SessionMsg3 (XX handshake msg3)
/// - Phase 0x0 + U flag → plaintext error signal (CoordsRequired/PathBroken)
/// - Phase 0x0 + !U → encrypted session message (data, reports, etc.)
pub(in crate::node) async fn handle_session_payload(
@@ -203,7 +200,7 @@ impl Node {
}
};
// Drop encrypted data if session is not yet established.
// With XK, the responder must wait for msg3 before it can decrypt.
// With XX, the responder must wait for msg3 before it can decrypt.
if !entry.is_established() {
debug!(
src = %self.peer_display_name(src_addr),
@@ -327,13 +324,7 @@ impl Node {
let now_ms = crate::time::mono_ms();
mmp.receiver
.record_recv(header.counter, timestamp, plaintext.len(), ce_flag, now_ms);
// Spin bit: advance state machine for correct TX reflection.
// RTT samples not fed into SRTT — timestamp-echo provides
// accurate RTT; spin bit includes variable inter-frame delays.
let inner_flags = FspInnerFlags::from_byte(inner_flags_byte);
let _spin_rtt = mmp
.spin_bit
.rx_observe(inner_flags.spin_bit, header.counter, now_ms);
let _inner_flags = FspInnerFlags::from_byte(inner_flags_byte);
}
// Feed path_mtu from datagram envelope to MMP path MTU tracking.
@@ -436,10 +427,10 @@ impl Node {
self.flush_pending_packets(src_addr).await;
}
/// Handle an incoming SessionSetup (Noise XK msg1).
/// Handle an incoming SessionSetup (Noise XX msg1).
///
/// The remote node wants to establish an end-to-end session with us.
/// We create an XK responder handshake, process msg1, send SessionAck with msg2,
/// We create an XX responder handshake, process msg1, send SessionAck with msg2,
/// and transition to AwaitingMsg3.
async fn handle_session_setup(&mut self, src_addr: &NodeAddr, inner: &[u8]) {
let setup = match SessionSetup::decode(inner) {
@@ -450,10 +441,10 @@ impl Node {
}
};
if setup.handshake_payload.len() != XK_HANDSHAKE_MSG1_SIZE {
if setup.handshake_payload.len() != HANDSHAKE_MSG1_SIZE {
debug!(
len = setup.handshake_payload.len(),
expected = XK_HANDSHAKE_MSG1_SIZE,
expected = HANDSHAKE_MSG1_SIZE,
"Invalid handshake payload size in SessionSetup"
);
return;
@@ -497,50 +488,59 @@ impl Node {
let rekey_in_progress = existing.has_rekey_in_progress();
let has_pending = existing.pending_new_session().is_some();
// Dual-initiation detection: both sides sent SessionSetup
// simultaneously. Apply tie-breaker — smaller NodeAddr
// wins as initiator (same as initial session setup).
if rekey_in_progress {
// Dual-initiation detection: both sides initiated rekey
// simultaneously. Two states can reach this point:
// - rekey_in_progress=true: both sides still mid-handshake
// - pending_new_session=Some && !rekey_in_progress: we've
// already completed our initiator path
// (set_pending_session cleared rekey_in_progress) and
// the peer's msg1 arrives now. Symmetric of the FMP
// msg3 case at handle_msg3.
// Apply the smaller-NodeAddr tie-breaker uniformly so
// both sides converge on a single Noise session.
if rekey_in_progress || has_pending {
if crate::proto::fsp::initiation_winner(
self.identity().node_addr(),
src_addr,
) {
// We win as initiator — drop their msg1.
debug!(
// We win — keep our session, drop their msg1.
info!(
src = %self.peer_display_name(src_addr),
"Dual FSP rekey initiation: we win (smaller addr), dropping their msg1"
our_addr = %self.identity().node_addr(),
their_addr = %src_addr,
rekey_in_progress = rekey_in_progress,
pending_new_session = has_pending,
"FSP rekey-msg1 tie-break: we win (smaller addr), drop their msg1"
);
return;
}
// We lose — abandon our rekey, become responder below.
debug!(
// We lose — abandon our rekey/pending, fall through as responder.
info!(
src = %self.peer_display_name(src_addr),
"Dual FSP rekey initiation: we lose (larger addr), abandoning ours"
our_addr = %self.identity().node_addr(),
their_addr = %src_addr,
rekey_in_progress = rekey_in_progress,
pending_new_session = has_pending,
"FSP rekey-msg1 tie-break: we lose (larger addr), abandon ours"
);
let entry = self.sessions.get_mut(src_addr).unwrap();
entry.abandon_rekey();
} else if has_pending {
// Guard: already have a pending session waiting for K-bit cutover
debug!(
src = %self.peer_display_name(src_addr),
"FSP rekey msg1 received but already have pending session, dropping"
);
return;
if let Some(entry) = self.sessions.get_mut(src_addr) {
entry.abandon_rekey();
}
}
let our_keypair = self.identity().keypair();
let mut handshake = HandshakeState::new_xk_responder(our_keypair);
let mut handshake = HandshakeState::new_responder(our_keypair);
handshake.set_local_epoch(self.startup_epoch());
if let Err(e) = handshake.read_xk_message_1(&setup.handshake_payload) {
debug!(error = %e, "Failed to process rekey XK msg1");
if let Err(e) = handshake.read_message_1(&setup.handshake_payload) {
debug!(error = %e, "Failed to process rekey XX msg1");
return;
}
// Generate msg2
let msg2 = match handshake.write_xk_message_2() {
let msg2 = match handshake.write_message_2() {
Ok(m) => m,
Err(e) => {
debug!(error = %e, "Failed to generate rekey XK msg2");
debug!(error = %e, "Failed to generate rekey XX msg2");
return;
}
};
@@ -560,9 +560,10 @@ impl Node {
// Store rekey state on the existing entry
let now_ms = Self::now_ms();
let entry = self.sessions.get_mut(src_addr).unwrap();
entry.set_rekey_state(handshake, false);
entry.record_peer_rekey(now_ms);
if let Some(entry) = self.sessions.get_mut(src_addr) {
entry.set_rekey_state(handshake, false);
entry.record_peer_rekey(now_ms);
}
debug!(
src = %self.peer_display_name(src_addr),
@@ -576,29 +577,39 @@ impl Node {
}
}
// Create XK responder handshake and process msg1
// Create XX responder handshake and process msg1
let our_keypair = self.identity().keypair();
let mut handshake = HandshakeState::new_xk_responder(our_keypair);
let mut handshake = HandshakeState::new_responder(our_keypair);
handshake.set_local_epoch(self.startup_epoch());
if let Err(e) = handshake.read_xk_message_1(&setup.handshake_payload) {
debug!(error = %e, "Failed to process Noise XK msg1 in SessionSetup");
if let Err(e) = handshake.read_message_1(&setup.handshake_payload) {
debug!(error = %e, "Failed to process Noise XX msg1 in SessionSetup");
return;
}
// XK: responder does NOT learn initiator's identity until msg3
// XX: responder does NOT learn initiator's identity until msg3
// Use a placeholder pubkey from src_addr for the session entry.
// The real pubkey will be registered when msg3 arrives.
// Generate msg2
let msg2 = match handshake.write_xk_message_2() {
// Generate msg2 with negotiation payload
let mut msg2 = match handshake.write_message_2() {
Ok(m) => m,
Err(e) => {
debug!(error = %e, "Failed to generate Noise XK msg2 for SessionAck");
debug!(error = %e, "Failed to generate Noise XX msg2 for SessionAck");
return;
}
};
// Encrypt FSP negotiation payload (version [0,0], features=0)
let neg_payload = NegotiationPayload::new(0, 0, 0).encode();
match handshake.encrypt_payload(&neg_payload) {
Ok(encrypted) => msg2.extend_from_slice(&encrypted),
Err(e) => {
debug!(error = %e, "Failed to encrypt negotiation payload for SessionAck");
return;
}
}
// Build and send SessionAck (include initiator's coords for return-path warming)
let our_coords = self.tree_state.my_coords().clone();
let ack = SessionAck::new(our_coords, setup.src_coords).with_handshake(msg2);
@@ -629,10 +640,10 @@ impl Node {
entry.set_handshake_payload(ack_payload, now_ms + resend_interval);
self.sessions.insert(*src_addr, entry);
debug!(src = %self.peer_display_name(src_addr), "SessionSetup processed (XK), SessionAck sent, awaiting msg3");
debug!(src = %self.peer_display_name(src_addr), "SessionSetup processed (XX), SessionAck sent, awaiting msg3");
}
/// Handle an incoming SessionAck (Noise XK msg2).
/// Handle an incoming SessionAck (Noise XX msg2).
///
/// Processes msg2, generates and sends msg3, then transitions to Established.
async fn handle_session_ack(&mut self, src_addr: &NodeAddr, inner: &[u8]) {
@@ -644,11 +655,11 @@ impl Node {
}
};
if ack.handshake_payload.len() != XK_HANDSHAKE_MSG2_SIZE {
if ack.handshake_payload.len() < HANDSHAKE_MSG2_SIZE {
debug!(
len = ack.handshake_payload.len(),
expected = XK_HANDSHAKE_MSG2_SIZE,
"Invalid handshake payload size in SessionAck"
min = HANDSHAKE_MSG2_SIZE,
"Handshake payload too short in SessionAck"
);
return;
}
@@ -674,19 +685,19 @@ impl Node {
}
};
// Process XK msg2
if let Err(e) = handshake.read_xk_message_2(&ack.handshake_payload) {
debug!(error = %e, "Failed to process rekey XK msg2");
// Process XX msg2
if let Err(e) = handshake.read_message_2(&ack.handshake_payload) {
debug!(error = %e, "Failed to process rekey XX msg2");
entry.abandon_rekey();
self.sessions.insert(*src_addr, entry);
return;
}
// Generate XK msg3
let msg3 = match handshake.write_xk_message_3() {
// Generate XX msg3
let msg3 = match handshake.write_message_3() {
Ok(m) => m,
Err(e) => {
debug!(error = %e, "Failed to generate rekey XK msg3");
debug!(error = %e, "Failed to generate rekey XX msg3");
entry.abandon_rekey();
self.sessions.insert(*src_addr, entry);
return;
@@ -711,7 +722,7 @@ impl Node {
let session = match handshake.into_session() {
Ok(s) => s,
Err(e) => {
debug!(error = %e, "Failed to create session from rekey XK");
debug!(error = %e, "Failed to create session from rekey XX");
entry.abandon_rekey();
self.sessions.insert(*src_addr, entry);
return;
@@ -735,7 +746,9 @@ impl Node {
debug!(
src = %self.peer_display_name(src_addr),
"FSP rekey: completed XK as initiator, msg3 sent, pending cutover"
our_addr = %self.identity().node_addr(),
their_addr = %src_addr,
"FSP rekey: completed XX as initiator, msg3 sent, pending cutover"
);
return;
}
@@ -753,21 +766,68 @@ impl Node {
_ => unreachable!("checked is_initiating above"),
};
// Process XK msg2: read_xk_message_2 (extracts responder's epoch)
if let Err(e) = handshake.read_xk_message_2(&ack.handshake_payload) {
debug!(error = %e, "Failed to process Noise XK msg2 in SessionAck");
return; // Entry was already removed, don't put back a broken session
// Split msg2 into base XX part and optional negotiation payload
let (base_msg2, neg_bytes) = if ack.handshake_payload.len() > HANDSHAKE_MSG2_SIZE {
(
&ack.handshake_payload[..HANDSHAKE_MSG2_SIZE],
Some(&ack.handshake_payload[HANDSHAKE_MSG2_SIZE..]),
)
} else {
(ack.handshake_payload.as_slice(), None)
};
// Process XX msg2 (learns responder's identity and epoch)
if let Err(e) = handshake.read_message_2(base_msg2) {
debug!(error = %e, "Failed to process Noise XX msg2 in SessionAck");
return;
}
// Generate XK msg3: write_xk_message_3 (sends encrypted static + epoch)
let msg3 = match handshake.write_xk_message_3() {
// Decrypt negotiation payload from msg2 if present
if let Some(encrypted_neg) = neg_bytes {
match handshake.decrypt_payload(encrypted_neg) {
Ok(_negotiation) => {
// FSP negotiation payload received — currently unused (version [0,0])
}
Err(e) => {
debug!(error = %e, "Failed to decrypt negotiation payload from SessionAck");
return;
}
}
}
// XX: verify responder's identity matches the target we intended to reach.
// Compare x-only keys to avoid parity mismatch: npub-derived keys always
// have even parity (0x02), but the Noise handshake reveals the real parity.
let expected_xonly = entry.remote_pubkey().x_only_public_key().0;
if let Some(remote_pk) = handshake.remote_static()
&& remote_pk.x_only_public_key().0 != expected_xonly
{
debug!(
src = %self.peer_display_name(src_addr),
"Responder identity mismatch in SessionAck — disconnecting"
);
return;
}
// Generate XX msg3 with negotiation payload
let mut msg3 = match handshake.write_message_3() {
Ok(m) => m,
Err(e) => {
debug!(error = %e, "Failed to generate Noise XK msg3");
debug!(error = %e, "Failed to generate Noise XX msg3");
return;
}
};
// Encrypt FSP negotiation payload for msg3
let neg_payload = NegotiationPayload::new(0, 0, 0).encode();
match handshake.encrypt_payload(&neg_payload) {
Ok(encrypted) => msg3.extend_from_slice(&encrypted),
Err(e) => {
debug!(error = %e, "Failed to encrypt negotiation payload for SessionMsg3");
return;
}
}
// Send SessionMsg3 (phase 0x3)
let msg3_wire = SessionMsg3::new(msg3);
let msg3_payload = msg3_wire.encode();
@@ -784,7 +844,7 @@ impl Node {
let session = match handshake.into_session() {
Ok(s) => s,
Err(e) => {
debug!(error = %e, "Failed to create session after XK msg3");
debug!(error = %e, "Failed to create session after XX msg3");
return;
}
};
@@ -802,10 +862,10 @@ impl Node {
// Flush any queued outbound packets for this destination
self.flush_pending_packets(src_addr).await;
info!(src = %self.peer_display_name(src_addr), "Session established (initiator, XK)");
info!(src = %self.peer_display_name(src_addr), "Session established (initiator, XX)");
}
/// Handle an incoming SessionMsg3 (Noise XK msg3).
/// Handle an incoming SessionMsg3 (Noise XX msg3).
///
/// The initiator reveals their encrypted static key. The responder
/// processes msg3, learns the initiator's identity, and transitions
@@ -819,11 +879,11 @@ impl Node {
}
};
if msg3.handshake_payload.len() != XK_HANDSHAKE_MSG3_SIZE {
if msg3.handshake_payload.len() < HANDSHAKE_MSG3_SIZE {
debug!(
len = msg3.handshake_payload.len(),
expected = XK_HANDSHAKE_MSG3_SIZE,
"Invalid handshake payload size in SessionMsg3"
min = HANDSHAKE_MSG3_SIZE,
"Handshake payload too short in SessionMsg3"
);
return;
}
@@ -849,9 +909,9 @@ impl Node {
}
};
// Process XK msg3
if let Err(e) = handshake.read_xk_message_3(&msg3.handshake_payload) {
debug!(error = %e, "Failed to process rekey XK msg3");
// Process XX msg3
if let Err(e) = handshake.read_message_3(&msg3.handshake_payload) {
debug!(error = %e, "Failed to process rekey XX msg3");
entry.abandon_rekey();
self.sessions.insert(*src_addr, entry);
return;
@@ -861,7 +921,7 @@ impl Node {
let session = match handshake.into_session() {
Ok(s) => s,
Err(e) => {
debug!(error = %e, "Failed to create session from rekey XK msg3");
debug!(error = %e, "Failed to create session from rekey XX msg3");
entry.abandon_rekey();
self.sessions.insert(*src_addr, entry);
return;
@@ -873,7 +933,9 @@ impl Node {
debug!(
src = %self.peer_display_name(src_addr),
"FSP rekey: completed XK as responder, pending cutover"
our_addr = %self.identity().node_addr(),
their_addr = %src_addr,
"FSP rekey: completed XX as responder, pending cutover"
);
return;
}
@@ -891,17 +953,40 @@ impl Node {
_ => unreachable!("checked is_awaiting_msg3 above"),
};
// Process XK msg3: read_xk_message_3 (extracts initiator's static key and epoch)
if let Err(e) = handshake.read_xk_message_3(&msg3.handshake_payload) {
debug!(error = %e, "Failed to process Noise XK msg3");
return; // Entry was already removed
// Split msg3 into base XX part and optional negotiation payload
let (base_msg3, neg_bytes) = if msg3.handshake_payload.len() > HANDSHAKE_MSG3_SIZE {
(
&msg3.handshake_payload[..HANDSHAKE_MSG3_SIZE],
Some(&msg3.handshake_payload[HANDSHAKE_MSG3_SIZE..]),
)
} else {
(msg3.handshake_payload.as_slice(), None)
};
// Process XX msg3 (learns initiator's identity and epoch)
if let Err(e) = handshake.read_message_3(base_msg3) {
debug!(error = %e, "Failed to process Noise XX msg3");
return;
}
// Decrypt negotiation payload from msg3 if present
if let Some(encrypted_neg) = neg_bytes {
match handshake.decrypt_payload(encrypted_neg) {
Ok(_negotiation) => {
// FSP negotiation payload received — currently unused (version [0,0])
}
Err(e) => {
debug!(error = %e, "Failed to decrypt negotiation payload from SessionMsg3");
return;
}
}
}
// Extract the initiator's static public key (now available after msg3)
let remote_pubkey = match handshake.remote_static() {
Some(pk) => *pk,
None => {
debug!("No remote static key after processing XK msg3");
debug!("No remote static key after processing XX msg3");
return;
}
};
@@ -913,7 +998,7 @@ impl Node {
let session = match handshake.into_session() {
Ok(s) => s,
Err(e) => {
debug!(error = %e, "Failed to create session from XK handshake");
debug!(error = %e, "Failed to create session from XX handshake");
return;
}
};
@@ -936,7 +1021,7 @@ impl Node {
// Flush any pending packets
self.flush_pending_packets(src_addr).await;
info!(src = %self.peer_display_name(src_addr), "Session established (responder, XK)");
info!(src = %self.peer_display_name(src_addr), "Session established (responder, XX)");
}
// === Session-layer MMP report handlers ===
@@ -1149,7 +1234,6 @@ impl Node {
send_mtu = mmp.path_mtu.current_mtu(),
observed_mtu = mmp.path_mtu.last_observed_mtu(),
tx_pkts = mmp.sender.cumulative_packets_sent(),
tx_bytes = mmp.sender.cumulative_bytes_sent(),
rx_pkts = mmp.receiver.cumulative_packets_recv(),
rx_bytes = mmp.receiver.cumulative_bytes_recv(),
"MMP session teardown"
@@ -1172,6 +1256,7 @@ impl Node {
trace!(
src = %self.peer_display_name(src_addr),
cum_pkts = sr.cumulative_packets_sent,
interval_pkts = sr.interval_packets_sent,
interval_bytes = sr.interval_bytes_sent,
"Received SessionSenderReport"
);
@@ -1455,7 +1540,7 @@ impl Node {
// Invalidate stale cached coordinates, then (only if the target's
// identity is cached — else the LookupResponse proof cannot be verified,
// e.g. when the XK responder receives PathBroken before msg3 completes)
// e.g. when the XX responder receives PathBroken before msg3 completes)
// trigger re-discovery. The core emits invalidate-then-lookup in order.
let has_cached_identity = self.has_cached_identity(&msg.dest_addr);
let actions = self
@@ -1586,7 +1671,7 @@ impl Node {
/// Initiate an end-to-end session with a remote node.
///
/// Creates a Noise XK handshake as initiator, wraps msg1 in a
/// Creates a Noise XX handshake as initiator, wraps msg1 in a
/// SessionSetup, encapsulates in a SessionDatagram, and routes
/// toward the destination.
pub(in crate::node) async fn initiate_session(
@@ -1601,15 +1686,15 @@ impl Node {
return Ok(());
}
// Create Noise XK initiator handshake
// Create Noise XX initiator handshake
let our_keypair = self.identity().keypair();
let mut handshake = HandshakeState::new_xk_initiator(our_keypair, dest_pubkey);
let mut handshake = HandshakeState::new_initiator(our_keypair);
handshake.set_local_epoch(self.startup_epoch());
let msg1 = handshake
.write_xk_message_1()
.write_message_1()
.map_err(|e| NodeError::SendFailed {
node_addr: dest_addr,
reason: format!("Noise XK msg1 generation failed: {}", e),
reason: format!("Noise XX msg1 generation failed: {}", e),
})?;
// Build SessionSetup with coordinates
@@ -1674,7 +1759,6 @@ impl Node {
})?;
let wants_coords = entry.coords_warmup_remaining() > 0;
let timestamp = entry.session_timestamp(now_ms);
let spin_bit = entry.mmp().is_some_and(|m| m.spin_bit.tx_bit());
if !entry.is_established() {
return Err(NodeError::SendFailed {
node_addr: *dest_addr,
@@ -1690,7 +1774,7 @@ impl Node {
// Build inner plaintext (doesn't depend on counter)
let msg_type = SessionMessageType::DataPacket.to_byte(); // 0x10
let inner_flags = FspInnerFlags { spin_bit }.to_byte();
let inner_flags = FspInnerFlags::new().to_byte();
let inner_plaintext =
fsp_prepend_inner_header(timestamp, msg_type, inner_flags, &port_payload);
@@ -1882,8 +1966,7 @@ impl Node {
reason: "no current_addr".into(),
})?;
let timestamp_ms = peer.session_elapsed_ms();
let sp_flag = peer.mmp().map(|m| m.spin_bit.tx_bit()).unwrap_or(false);
let mut fmp_flags = if sp_flag { FLAG_SP } else { 0 };
let mut fmp_flags = 0u8;
if peer.current_k_bit() {
fmp_flags |= FLAG_KEY_EPOCH;
}
@@ -2115,7 +2198,7 @@ impl Node {
/// Similar to `send_session_data()` but:
/// - Takes an explicit `msg_type` byte (0x11, 0x12, 0x13, etc.)
/// - Never includes COORDS_PRESENT (reports are lightweight)
/// - Reads spin bit from MMP state for the inner header
/// - Reads the session timestamp for the inner header
/// - Records the send in MMP sender state
pub(in crate::node) async fn send_session_msg(
&mut self,
@@ -2125,7 +2208,7 @@ impl Node {
) -> Result<(), NodeError> {
let now_ms = Self::now_ms();
// Read spin bit and session timestamp from entry
// Read session timestamp from entry
let entry = self
.sessions
.get(dest_addr)
@@ -2134,10 +2217,8 @@ impl Node {
reason: "no session".into(),
})?;
let timestamp = entry.session_timestamp(now_ms);
let spin_bit = entry.mmp().is_some_and(|m| m.spin_bit.tx_bit());
// Build inner flags with spin bit
let inner_flags = FspInnerFlags { spin_bit }.to_byte();
let inner_flags = FspInnerFlags::new().to_byte();
// Get mutable access for encryption
let entry = self
@@ -2221,7 +2302,6 @@ impl Node {
reason: "no session".into(),
})?;
let timestamp = entry.session_timestamp(now_ms);
let spin_bit = entry.mmp().is_some_and(|m| m.spin_bit.tx_bit());
// Get mutable access for encryption
let entry = self
@@ -2245,7 +2325,7 @@ impl Node {
// FSP inner header only, no body payload
let msg_type = SessionMessageType::CoordsWarmup.to_byte();
let inner_flags = FspInnerFlags { spin_bit }.to_byte();
let inner_flags = FspInnerFlags::new().to_byte();
let inner_plaintext = fsp_prepend_inner_header(timestamp, msg_type, inner_flags, &[]);
// Build FSP header with CP flag
@@ -2466,6 +2546,7 @@ impl Node {
if original_packet.len() < 40 {
return;
}
// SAFETY: slice is exactly 16 bytes; length validated above (>= 40)
let src_addr = Ipv6Addr::from(<[u8; 16]>::try_from(&original_packet[8..24]).unwrap());
// Rate limit ICMP PTB messages per source
@@ -2481,6 +2562,7 @@ impl Node {
// kernel sees the PTB coming from a remote router, not from itself.
// Linux ignores PTBs whose source matches a local address, which
// causes a PMTUD blackhole when both src and ICMP-src are local.
// SAFETY: slice is exactly 16 bytes; length validated above (>= 40)
let dest_addr = Ipv6Addr::from(<[u8; 16]>::try_from(&original_packet[24..40]).unwrap());
if let Some(response) = build_packet_too_big(original_packet, mtu, dest_addr)
&& let Some(tun_tx) = &self.supervisor.tun_tx
+15 -1
View File
@@ -136,10 +136,11 @@ impl Node {
};
self.remove_peer_machine(link_id);
// Free session index and pending_outbound if allocated
// Free session index and pending_outbound/pending_inbound if allocated
if let Some(idx) = our_index {
if let Some(tid) = transport_id {
self.pending_outbound.remove(&(tid, idx.as_u32()));
self.pending_inbound.remove(&(tid, idx.as_u32()));
}
let _ = self.index_allocator.free(idx);
}
@@ -273,6 +274,19 @@ impl Node {
let mut candidates: Vec<ConnSnapshot> = Vec::new();
let mut drop_timers: Vec<LinkId> = Vec::new();
for link in due {
// Skip a resend whose target peer already promoted via the inbound
// cross-connection path — resending msg1 would start a new handshake
// (session mismatch).
if let Some(machine) = self.peer_machines.get(&link)
&& machine.leg().is_some()
&& machine
.conn_expected_identity()
.map(|id| self.peers.contains_key(id.node_addr()))
.unwrap_or(false)
{
drop_timers.push(link);
continue;
}
let armed = match self.peer_machines.get(&link) {
Some(machine)
if machine.is_handshaking_sent_msg1()
+408 -164
View File
@@ -394,7 +394,10 @@ impl Node {
&& machine.conn_transport_id() == Some(transport_id)
&& machine.conn_source_addr() == Some(remote_addr)
}) || self.peering.pending_connects.iter().any(|pending| {
pending.peer_identity.node_addr() == peer_node_addr
pending
.peer_identity
.as_ref()
.is_some_and(|id| id.node_addr() == peer_node_addr)
&& pending.transport_id == transport_id
&& pending.remote_addr == *remote_addr
})
@@ -428,7 +431,7 @@ impl Node {
/// Initiate a connection to a peer on a specific transport and address.
///
/// For connectionless transports (UDP, Ethernet): allocates a link, starts
/// the Noise IK handshake, sends msg1, and registers the connection for
/// the Noise XX handshake, sends msg1, and registers the connection for
/// msg2 dispatch.
///
/// For connection-oriented transports (TCP, Tor): allocates a link and
@@ -439,14 +442,16 @@ impl Node {
&mut self,
transport_id: TransportId,
remote_addr: TransportAddr,
peer_identity: PeerIdentity,
peer_identity: Option<PeerIdentity>,
) -> Result<(), NodeError> {
self.authorize_peer(
&peer_identity,
PeerAclContext::OutboundConnect,
transport_id,
&remote_addr,
)?;
if let Some(ref identity) = peer_identity {
self.authorize_peer(
identity,
PeerAclContext::OutboundConnect,
transport_id,
&remote_addr,
)?;
}
let is_connection_oriented = self
.transports
@@ -481,97 +486,118 @@ impl Node {
self.addr_to_link
.insert((transport_id, remote_addr.clone()), link_id);
// Persist the outbound control machine at dial, keyed by the same
// `link_id` as the (soon-to-be-built) connection. It parks in
// `Discovered` (inert to reap and rekey — absent from `peers`, never
// established) until `handle_msg2` looks it up to drive the promote, or
// a connectionless dial drives it to `Handshaking` to send. Its carrier
// index is written at msg1 preparation (`prepare_outbound_msg1`); a later
// inbound msg1 for the same peer is handled on a FRESH `new_inbound`
// machine (`handle_msg1`), never by driving this map-resident machine
// through `inbound_msg1`, so it does not restart through the decrypt-
// unregister path in production. It is removed on every failure path in
// the dial window (below and in `prepare_outbound_msg1` /
// `poll_pending_connects`), mirroring the connection's own lifetime.
let machine = PeerMachine::new_outbound(link_id, peer_identity, Self::now_ms());
self.peer_machines.insert(link_id, machine);
match peer_identity {
Some(identity) => {
// Identified dial: persist the outbound control machine at dial,
// keyed by the same `link_id` as the (soon-to-be-built)
// connection. It parks in `Discovered` (inert to reap and rekey —
// absent from `peers`, never established) until `handle_msg2`
// looks it up to drive the promote, or a connectionless dial
// drives it to `Handshaking` to send. Its carrier index is
// written at msg1 preparation (`prepare_outbound_msg1` /
// `start_handshake`); a later inbound msg1 for the same peer is
// handled on a FRESH `new_inbound` machine (`handle_msg1`), never
// by driving this map-resident machine through the inbound path.
// It is removed on every failure path in the dial window
// (`prepare_outbound_msg1` / `poll_pending_connects`), mirroring
// the connection's lifetime.
let machine = PeerMachine::new_outbound(link_id, Some(identity), Self::now_ms());
self.peer_machines.insert(link_id, machine);
if is_connection_oriented {
// Connection-oriented: drive the machine to open the transport. The
// machine parks in `Connecting` and emits one `OpenTransport`; the
// executor performs the non-blocking `transport.connect` and pushes
// the `PendingConnect`. No index alloc or wire-arm happens here —
// that is deferred to `prepare_outbound_msg1` at connect-resolution
// time (`poll_pending_connects`), so `our_index` stays `None` on both
// the (not-yet-built) connection and the machine.
let now = Self::now_ms();
let ambient = PeerActionCtx {
verified_identity: peer_identity,
transport_id,
remote_addr: remote_addr.clone(),
our_index: None,
their_index: None,
now_ms: now,
is_outbound: true,
};
self.advance_peer_machine(
link_id,
PeerEvent::Dial {
if !is_connection_oriented {
// Connectionless: no connect step. Prepare msg1 in the shell —
// the index alloc, Noise leaf, and framing can fail and the
// error must propagate to the caller — then drive the machine
// to send it (the executor's `SendHandshake` msg1 branch sends
// the wire `prepare_outbound_msg1` armed on the connection).
self.prepare_outbound_msg1(link_id, transport_id, &remote_addr, identity)?;
}
// Drive the machine: connection-oriented dials emit `OpenTransport`
// (the executor connects and pushes `PendingConnect`); connectionless
// dials emit `SendHandshake` (msg1) for the wire armed above.
let now = Self::now_ms();
let ambient = PeerActionCtx {
verified_identity: identity,
transport_id,
remote_addr,
peer_identity,
connection_oriented: true,
},
now,
&ambient,
)
.await;
Ok(())
} else {
// Connectionless: no connect step. Prepare msg1 in the shell — the
// index alloc, Noise leaf, and framing can fail and the error must
// propagate to the caller (matching the pre-cutover path) — then drive
// the machine to send it. The machine goes straight to
// `start_outbound_handshake`; the executor's `SendHandshake` msg1
// branch sends the wire `prepare_outbound_msg1` armed on the
// connection.
self.prepare_outbound_msg1(link_id, transport_id, &remote_addr, peer_identity)?;
let now = Self::now_ms();
let ambient = PeerActionCtx {
verified_identity: peer_identity,
transport_id,
remote_addr: remote_addr.clone(),
our_index: None,
their_index: None,
now_ms: now,
is_outbound: true,
};
self.advance_peer_machine(
link_id,
PeerEvent::Dial {
transport_id,
remote_addr,
peer_identity,
connection_oriented: false,
},
now,
&ambient,
)
.await;
Ok(())
remote_addr: remote_addr.clone(),
our_index: None,
their_index: None,
now_ms: now,
is_outbound: true,
pending_outbound_key: None,
};
self.advance_peer_machine(
link_id,
PeerEvent::Dial {
transport_id,
remote_addr,
peer_identity: identity,
connection_oriented: is_connection_oriented,
},
now,
&ambient,
)
.await;
Ok(())
}
None => {
// Anonymous-discovery dial: the inline path. The leg's control
// machine is born inside `start_handshake` at leg creation
// (identity-less; `handle_msg2` crystallizes the identity the XX
// handshake learns). The connection-oriented connect window below
// deliberately has no leg and no machine yet — the Failed arm of
// `poll_pending_connects` only disposes machines for identified
// legs, and stays correct because anonymous machines don't exist
// until connect resolution reaches `start_handshake`.
if is_connection_oriented {
// Connection-oriented: start non-blocking connect, defer handshake.
if let Some(transport) = self.transports.get(&transport_id) {
match transport.connect(&remote_addr).await {
Ok(()) => {
debug!(
transport_id = %transport_id,
remote_addr = %remote_addr,
link_id = %link_id,
"Transport connect initiated (anonymous discovery)"
);
self.peering.pending_connects.push(super::PendingConnect {
link_id,
transport_id,
remote_addr,
peer_identity: None,
});
}
Err(e) => {
// Clean up link
self.links.remove(&link_id);
self.addr_to_link.remove(&(transport_id, remote_addr));
return Err(NodeError::TransportError(e.to_string()));
}
}
}
Ok(())
} else {
// Connectionless: proceed with immediate handshake.
self.start_handshake(link_id, transport_id, remote_addr, None)
.await
}
}
}
}
/// Prepare an outbound Noise msg1 at dial: allocate the session index, run
/// the Noise leaf, frame the wire, arm the shell-side resend, track
/// `pending_outbound`, and embed the prepared connection on the dial-time
/// control machine. Returns `Err` on index-allocation or Noise failure
/// (cleaning the partial leg, including the dial-time control machine),
/// leaving the armed wire on the connection for `send_stored_msg1` to
/// transmit. Does NOT send — so the fallible setup can propagate its error
/// synchronously before any machine drive. The control machine itself is
/// persisted at dial in `initiate_connection`; this function hands it the
/// connection on success and cleans it up on the failure paths.
/// Prepare an outbound Noise msg1 at dial for an IDENTIFIED leg: allocate the
/// session index, run the Noise leaf, frame the wire, arm the shell-side
/// resend, track `pending_outbound`, and embed the prepared connection on
/// the dial-time control machine. Returns `Err` on index-allocation or
/// Noise failure (cleaning the partial leg, including the dial-time control
/// machine), leaving the armed wire on the connection for
/// `send_stored_msg1` to transmit. Does NOT send — so the fallible setup
/// can propagate its error synchronously before any machine drive.
/// Anonymous-discovery legs use the monolithic `start_handshake` instead,
/// whose machine is born after its fallible setup — so this is the only
/// caller path whose Err arms clean up `peer_machines` (the dial-time
/// machine predates the failure).
pub(in crate::node) fn prepare_outbound_msg1(
&mut self,
link_id: LinkId,
@@ -593,7 +619,9 @@ impl Node {
);
self.peer_machines
.entry(link_id)
.or_insert_with(|| PeerMachine::new_outbound(link_id, peer_identity, current_time_ms))
.or_insert_with(|| {
PeerMachine::new_outbound(link_id, Some(peer_identity), current_time_ms)
})
.set_leg(HandshakeCrypto::new());
// Allocate a session index for this handshake
@@ -681,6 +709,146 @@ impl Node {
Ok(())
}
/// Start an outbound Noise handshake inline (allocate index, run the Noise
/// leaf, arm the resend, track `pending_outbound`, persist the leg's
/// control machine, and send msg1). Used by the anonymous-discovery dial
/// paths (connectionless dial and the connection-oriented
/// connect-resolution). Anonymous discovery (no `peer_identity`) leaves
/// identity to be learned from the XX msg2, which crystallizes it onto the
/// leg-born machine.
pub(super) async fn start_handshake(
&mut self,
link_id: LinkId,
transport_id: TransportId,
remote_addr: TransportAddr,
peer_identity: Option<PeerIdentity>,
) -> Result<(), NodeError> {
// Create the control machine in handshake phase. Anonymous discovery
// (no peer_identity) leaves identity to be learned from XX msg2.
//
// The machine drives the Noise crypto, so it is built before the leaf
// runs — but it stays a LOCAL until all fallible setup has succeeded.
// The index-alloc and Noise Err returns below therefore still just drop
// it, leaving no registry trace and needing no disposal, exactly as the
// pre-collapse local connection did.
let current_time_ms = Self::now_ms();
let mut machine = PeerMachine::new_outbound(link_id, peer_identity, current_time_ms);
machine.set_leg(HandshakeCrypto::new());
// Allocate a session index for this handshake
let our_index = match self.index_allocator.allocate() {
Ok(idx) => idx,
Err(e) => {
// Clean up the link we just created
self.links.remove(&link_id);
self.addr_to_link.remove(&(transport_id, remote_addr));
return Err(NodeError::IndexAllocationFailed(e.to_string()));
}
};
// Start the Noise handshake and get message 1
let our_keypair = self.identity().keypair();
let noise_msg1 =
match machine.start_handshake(our_keypair, self.startup_epoch(), current_time_ms) {
Ok(msg) => msg,
Err(e) => {
// Clean up the index and link
let _ = self.index_allocator.free(our_index);
self.links.remove(&link_id);
self.addr_to_link.remove(&(transport_id, remote_addr));
return Err(NodeError::HandshakeFailed(e.to_string()));
}
};
// Set index and transport info on the surviving carrier
machine.set_conn_our_index(our_index);
machine.set_conn_transport_id(transport_id);
machine.set_conn_source_addr(remote_addr.clone());
// Build wire format msg1: [0x01][sender_idx:4 LE][noise_msg1:82]
let wire_msg1 = build_msg1(our_index, &noise_msg1);
if let Some(id) = machine.conn_expected_identity() {
debug!(
peer = %self.peer_display_name(id.node_addr()),
transport_id = %transport_id,
remote_addr = %remote_addr,
link_id = %link_id,
our_index = %our_index,
"Connection initiated"
);
} else {
debug!(
transport_id = %transport_id,
remote_addr = %remote_addr,
link_id = %link_id,
our_index = %our_index,
"Anonymous discovery connection initiated"
);
}
// Store msg1 for resend and schedule first resend. The carrier holds
// the resend wire; the retransmit driver reads it from there.
let resend_interval = self.config().node.rate_limit.handshake_resend_interval_ms;
let first_resend_at_ms = current_time_ms + resend_interval;
machine.set_conn_handshake_msg1(wire_msg1.clone(), first_resend_at_ms);
// Track in pending_outbound for msg2 dispatch
self.pending_outbound
.insert((transport_id, our_index.as_u32()), link_id);
// The persistent control machine enters the registry here, after all
// fallible setup. Both production callers dial anonymously (identified
// dials persist their machine at dial and go through
// `prepare_outbound_msg1`), so the machine starts identity-less;
// `handle_msg2` crystallizes the identity the XX handshake learns.
// Inserted before the send below so no suspension point observes a
// handshake in flight without a machine. The send-failure arm retains
// the failed handshake and the machine keeps carrying it — the
// stale-connection reaper disposes both together. No timers are armed
// and no event is dispatched: this path sends msg1 inline, so the
// machine parks at `Discovered` until msg2. The carrier was born on
// this same `current_time_ms`, so its `started_at`/`last_activity`
// already carry the msg1-prep provenance.
self.peer_machines.insert(link_id, machine);
// Send the wire format handshake message
if let Some(transport) = self.transports.get(&transport_id) {
match transport.send(&remote_addr, &wire_msg1).await {
Ok(bytes) => {
debug!(
link_id = %link_id,
our_index = %our_index,
bytes,
"Sent Noise handshake message 1 (wire format)"
);
}
Err(e) => {
warn!(
link_id = %link_id,
error = %e,
"Failed to send handshake message"
);
// The machine marks its leg failed but retains it —
// the stale-connection sweep reclaims it, and the
// event loop can handle retry logic until then.
if let Some(machine) = self.peer_machines.get_mut(&link_id) {
let actions = machine.step(
PeerEvent::HandshakeSendFailed,
current_time_ms,
&mut self.index_allocator,
);
debug_assert!(
actions.is_empty(),
"HandshakeSendFailed must emit no actions"
);
}
}
}
}
Ok(())
}
/// Send the msg1 wire that `prepare_outbound_msg1` armed on the connection.
/// On send error, steps the machine with `HandshakeSendFailed` — the machine
/// marks its embedded leg failed and RETAINS it (the legacy resend tick
@@ -766,6 +934,13 @@ impl Node {
// Collect-then-dial (as before): the pool snapshot is frozen while the
// dataplane maps are unmutated, so the core's per-peer cap sees a stable
// in-flight count — the same guarantee the old collect-then-dial had.
//
// Anonymous (None-identity) beacons are first-contact links learned from
// an unauthenticated shared-media beacon; their identity is only learned
// from XX msg2. They carry `active_refresh = false`, have no NodeAddr to
// key the per-peer cap, and — matching next's poll_transport_discovery —
// bypass the discovery connect budget in the core. The driver-only
// `addr_to_link` dedup below is their sole gate.
let mut transport_neighbors: Vec<Candidate> = Vec::new();
for (transport_id, transport) in &self.transports {
if !transport.is_operational() {
@@ -782,6 +957,21 @@ impl Node {
};
for peer in discovered {
let Some(pubkey) = peer.pubkey_hint else {
// Anonymous discovery (shared-media beacon without identity).
// Identity will be learned from XX handshake msg2. Dedup by
// transport address — skip if a link already exists.
if self
.addr_to_link
.contains_key(&(*transport_id, peer.addr.clone()))
{
continue;
}
transport_neighbors.push(Candidate {
transport_id: *transport_id,
remote_addr: peer.addr,
identity: None,
active_refresh: false,
});
continue;
};
let identity = PeerIdentity::from_pubkey(pubkey);
@@ -855,18 +1045,27 @@ impl Node {
let PeeringAction::Connect(candidate) = action else {
continue;
};
let Some(identity) = candidate.identity else {
continue;
};
info!(
peer = %self.peer_display_name(identity.node_addr()),
transport_id = %candidate.transport_id,
remote_addr = %candidate.remote_addr,
active_refresh = candidate.active_refresh,
"Auto-connecting to discovered peer"
);
if let Some(ref id) = candidate.identity {
info!(
peer = %self.peer_display_name(id.node_addr()),
transport_id = %candidate.transport_id,
remote_addr = %candidate.remote_addr,
active_refresh = candidate.active_refresh,
"Auto-connecting to discovered peer"
);
} else {
info!(
transport_id = %candidate.transport_id,
remote_addr = %candidate.remote_addr,
"Auto-connecting to anonymous discovered peer"
);
}
if let Err(e) = self
.initiate_connection(candidate.transport_id, candidate.remote_addr, identity)
.initiate_connection(
candidate.transport_id,
candidate.remote_addr,
candidate.identity,
)
.await
{
warn!(error = %e, "Failed to auto-connect to discovered peer");
@@ -1100,9 +1299,9 @@ impl Node {
}
}
/// Drain mDNS-discovered peers and initiate Noise IK handshakes.
/// Drain mDNS-discovered peers and initiate Noise XX handshakes.
/// The handshake itself is the authentication — a spoofed mDNS advert
/// with someone else's npub fails the IK exchange and is dropped.
/// with someone else's npub fails the XX exchange and is dropped.
pub(super) async fn poll_lan_rendezvous(&mut self) {
let Some(runtime) = self.supervisor.lan_rendezvous.clone() else {
return;
@@ -1197,7 +1396,11 @@ impl Node {
"lan: initiating handshake to discovered peer"
);
if let Err(err) = self
.initiate_connection(candidate.transport_id, candidate.remote_addr, identity)
.initiate_connection(
candidate.transport_id,
candidate.remote_addr,
Some(identity),
)
.await
{
debug!(
@@ -1257,61 +1460,89 @@ impl Node {
}
debug!(
peer = %self.peer_display_name(pending.peer_identity.node_addr()),
transport_id = %pending.transport_id,
remote_addr = %pending.remote_addr,
link_id = %pending.link_id,
"Transport connected, starting handshake"
);
// Prepare msg1 now that the transport is connected, then drive
// the machine to send it. The prepare (index alloc, Noise leaf,
// wire arm) MUST run BEFORE the `TransportConnected` drive: the
// executor's `SendHandshake` msg1 branch only transmits the wire
// this armed on the connection — a drive-only path would find no
// armed wire and silently send nothing.
if let Err(e) = self.prepare_outbound_msg1(
pending.link_id,
pending.transport_id,
&pending.remote_addr,
pending.peer_identity,
) {
warn!(
link_id = %pending.link_id,
error = %e,
"Failed to start handshake after transport connect"
);
// Clean up link and dial-time machine on handshake failure
self.remove_link(&pending.link_id);
self.remove_peer_machine(pending.link_id);
} else {
// Drive the dial-persisted machine: `Connecting` →
// `on_transport_connected` → `start_outbound_handshake`,
// emitting `SendHandshake` (msg1) whose executor arm sends the
// wire just armed. `our_index`/`their_index` stay `None` so the
// executor takes the `their_index == None` msg1 branch.
let now = Self::now_ms();
let ambient = PeerActionCtx {
verified_identity: pending.peer_identity,
transport_id: pending.transport_id,
remote_addr: pending.remote_addr.clone(),
our_index: None,
their_index: None,
now_ms: now,
is_outbound: true,
};
self.advance_peer_machine(
pending.link_id,
PeerEvent::TransportConnected,
now,
&ambient,
)
.await;
match pending.peer_identity {
Some(identity) => {
// Identified leg: prepare msg1 now that the transport is
// connected, then drive the dial-persisted machine to send
// it. The prepare (index alloc, Noise leaf, wire arm) MUST
// run BEFORE the `TransportConnected` drive: the executor's
// `SendHandshake` msg1 branch only transmits the wire this
// armed on the connection — a drive-only path would find no
// armed wire and silently send nothing.
if let Err(e) = self.prepare_outbound_msg1(
pending.link_id,
pending.transport_id,
&pending.remote_addr,
identity,
) {
warn!(
link_id = %pending.link_id,
error = %e,
"Failed to start handshake after transport connect"
);
// Clean up link and dial-time machine on handshake failure
self.remove_link(&pending.link_id);
self.remove_peer_machine(pending.link_id);
} else {
// Drive the dial-persisted machine: `Connecting` →
// `on_transport_connected` → `start_outbound_handshake`,
// emitting `SendHandshake` (msg1) whose executor arm
// sends the wire just armed. `our_index`/`their_index`
// stay `None` so the executor takes the
// `their_index == None` msg1 branch.
let now = Self::now_ms();
let ambient = PeerActionCtx {
verified_identity: identity,
transport_id: pending.transport_id,
remote_addr: pending.remote_addr.clone(),
our_index: None,
their_index: None,
now_ms: now,
is_outbound: true,
pending_outbound_key: None,
};
self.advance_peer_machine(
pending.link_id,
PeerEvent::TransportConnected,
now,
&ambient,
)
.await;
}
}
None => {
// Anonymous-discovery leg: the inline handshake persists
// the leg's control machine at leg birth. Its Err returns
// all precede that birth, so the cleanup below has no
// machine to dispose.
if let Err(e) = self
.start_handshake(
pending.link_id,
pending.transport_id,
pending.remote_addr.clone(),
None,
)
.await
{
warn!(
link_id = %pending.link_id,
error = %e,
"Failed to start handshake after transport connect"
);
// Clean up link on handshake failure
self.remove_link(&pending.link_id);
}
}
}
} else {
let reason = reason.unwrap_or_default();
warn!(
peer = %self.peer_display_name(pending.peer_identity.node_addr()),
transport_id = %pending.transport_id,
remote_addr = %pending.remote_addr,
link_id = %pending.link_id,
@@ -1319,11 +1550,16 @@ impl Node {
"Transport connect failed"
);
// Clean up link and dial-time machine, then schedule retry
// Clean up link and, for identified legs, the dial-time machine,
// then schedule retry. Anonymous discovery connections (no expected
// identity) don't retry — they'll be rediscovered via the
// shared-medium beacon.
self.remove_link(&pending.link_id);
self.links.remove(&pending.link_id);
self.remove_peer_machine(pending.link_id);
self.note_handshake_timeout(*pending.peer_identity.node_addr(), Self::now_ms());
if let Some(id) = &pending.peer_identity {
self.remove_peer_machine(pending.link_id);
self.note_handshake_timeout(*id.node_addr(), Self::now_ms());
}
}
}
}
@@ -2423,7 +2659,7 @@ impl Node {
}
match self
.initiate_connection(transport_id, remote_addr, peer_identity)
.initiate_connection(transport_id, remote_addr, Some(peer_identity))
.await
{
Ok(()) => attempted = attempted.saturating_add(1),
@@ -2747,9 +2983,12 @@ impl Node {
}
}
for pending in &self.peering.pending_connects {
*in_flight_by_peer
.entry(*pending.peer_identity.node_addr())
.or_default() += 1;
// Anonymous first-contact legs have no NodeAddr and are correctly
// excluded from the per-peer in-flight count (they never retry and
// cannot key the per-peer cap).
if let Some(id) = &pending.peer_identity {
*in_flight_by_peer.entry(*id.node_addr()).or_default() += 1;
}
}
Observed {
connected,
@@ -2822,7 +3061,12 @@ impl Node {
self.peering
.pending_connects
.iter()
.filter(|pending| pending.peer_identity.node_addr() == peer_node_addr)
.filter(|pending| {
pending
.peer_identity
.as_ref()
.is_some_and(|id| id.node_addr() == peer_node_addr)
})
.count(),
);
@@ -3289,7 +3533,7 @@ impl Node {
let remote_addr = TransportAddr::from_string(&traversal.remote_addr.to_string());
if let Err(err) = self
.initiate_connection(transport_id, remote_addr.clone(), peer_identity)
.initiate_connection(transport_id, remote_addr.clone(), Some(peer_identity))
.await
{
self.supervisor
+17
View File
@@ -344,6 +344,16 @@ pub struct BloomMetrics {
pub sent: Counter,
pub debounce_suppressed: Counter,
pub send_failed: Counter,
// Delta compression
pub deltas_sent: Counter,
pub full_sends: Counter,
pub nacks_sent: Counter,
pub nacks_received: Counter,
// Adaptive sizing
pub size_changes: Counter,
// Compression tracking
pub total_compressed_bytes: Counter,
pub total_raw_bytes: Counter,
}
impl BloomMetrics {
@@ -375,6 +385,13 @@ impl BloomMetrics {
sent: self.sent.get(),
debounce_suppressed: self.debounce_suppressed.get(),
send_failed: self.send_failed.get(),
deltas_sent: self.deltas_sent.get(),
full_sends: self.full_sends.get(),
nacks_sent: self.nacks_sent.get(),
nacks_received: self.nacks_received.get(),
size_changes: self.size_changes.get(),
total_compressed_bytes: self.total_compressed_bytes.get(),
total_raw_bytes: self.total_raw_bytes.get(),
}
}
}
+141 -37
View File
@@ -34,6 +34,21 @@ use self::reloadable::Reloadable;
/// +REKEY_JITTER_SECS]` seconds at construction. Desynchronizes
/// dual-initiation in symmetric-start meshes; the configured
/// `node.rekey.after_secs` remains the nominal interval (mean preserved).
///
/// Re-enabled on next after the jitter × XX rekey interaction was closed.
/// The jitter was previously disabled (set to 0) on next because applying it
/// to the XX FMP rekey path produced reproducible post-cutover
/// routing-convergence failures (~50% Phase 5 ping loss in the `rekey`
/// integration suite). Root cause: under jitter a recent cutover resets the
/// session-age clock, so a concurrent rekey msg3 (always on a temp link) was
/// caught by the initial-handshake cross-connection tie-breaker in
/// `handle_msg3` and, on the "our outbound wins" side, the peer's rekey
/// session was discarded with no `pending` slot — yet the peer cut over to it
/// regardless, starving the discarding node until the 30s dead-timer. First
/// bounded by an age floor on that cross-connection branch; the floor is gone
/// now, and a rekey msg3 reaches the rekey-responder path because its sender
/// declares it there (`TLV_REKEY_OF`), which no amount of jitter on the
/// session-age clock can perturb. See CHANGELOG.
pub(crate) const REKEY_JITTER_SECS: i64 = 15;
use crate::cache::CoordCache;
use crate::node::session::SessionEntry;
@@ -41,9 +56,10 @@ use crate::peer::ActivePeer;
use crate::peer::machine::{PeerMachine, TimerKind};
use crate::proto::bloom::{BloomFilter, BloomState};
use crate::proto::fmp::Fmp;
use crate::proto::fmp::NodeProfile;
use crate::proto::fmp::wire::{
ESTABLISHED_HEADER_SIZE, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, build_encrypted,
build_established_header, prepend_inner_header,
ESTABLISHED_HEADER_SIZE, FLAG_CE, FLAG_KEY_EPOCH, build_encrypted, build_established_header,
prepend_inner_header,
};
use crate::proto::fsp::Fsp;
use crate::proto::lookup::{Lookup, LookupBackoff, LookupForwardRateLimiter};
@@ -271,7 +287,9 @@ struct PendingConnect {
/// The remote address being connected to.
remote_addr: TransportAddr,
/// The peer identity (for handshake initiation).
peer_identity: PeerIdentity,
/// None for anonymous discovery connections where identity isn't
/// known until the XX handshake completes.
peer_identity: Option<PeerIdentity>,
}
/// A running FIPS node instance.
@@ -289,8 +307,9 @@ struct PendingConnect {
/// link. It is **not** a packet-dispatch path, despite what this comment used
/// to say: `find_link_by_addr` has no callers outside its own tests, and
/// encrypted frames are dispatched by session index. Its live readers are the
/// `should_admit_msg1` fast path and the duplicate-inbound-handshake check in
/// `handle_msg1`.
/// `should_admit_msg1` fast path, the duplicate-inbound-handshake check in
/// `handle_msg1`, and the anonymous-beacon dial dedup in
/// `poll_transport_discovery`.
///
/// **Do not key a peer-identity question on it.** The address form is not
/// canonical: an outbound dial registers the literal configured string, which
@@ -468,6 +487,10 @@ pub struct Node {
/// Pending outbound handshakes by our sender_idx.
/// Tracks which LinkId corresponds to which session index.
pending_outbound: HashMap<(TransportId, u32), LinkId>,
/// Pending inbound connections awaiting msg3 (XX pattern), keyed by
/// (transport_id, our_index). The responder stores the connection here
/// after sending msg2 and awaits msg3 to learn the initiator's identity.
pending_inbound: HashMap<(TransportId, u32), LinkId>,
// === Rate Limiting ===
/// Rate limiter for msg1 processing (DoS protection).
@@ -564,6 +587,7 @@ impl Node {
let identity = config.create_identity()?;
let node_addr = *identity.node_addr();
let is_leaf_only = config.is_leaf_only();
let node_profile = config.node_profile();
let mut startup_epoch = [0u8; 8];
rand::rng().fill_bytes(&mut startup_epoch);
@@ -587,6 +611,7 @@ impl Node {
.map(|d| d.as_secs())
.unwrap_or(0);
let mut tree_state = TreeState::new(node_addr, tree_now_secs);
tree_state.set_self_is_leaf(node_profile == NodeProfile::Leaf);
tree_state.set_parent_hysteresis(config.node.tree.parent_hysteresis);
tree_state.set_hold_down(config.node.tree.hold_down_secs);
tree_state.set_flap_dampening(
@@ -637,6 +662,7 @@ impl Node {
startup_epoch,
started_at,
is_leaf_only,
node_profile,
max_connections,
max_peers,
max_links,
@@ -680,6 +706,7 @@ impl Node {
index_allocator: IndexAllocator::new(),
peers_by_index: HashMap::new(),
pending_outbound: HashMap::new(),
pending_inbound: HashMap::new(),
msg1_rate_limiter,
icmp_rate_limiter: IcmpRateLimiter::new(),
routing: Router::new(),
@@ -735,6 +762,7 @@ impl Node {
.map(|d| d.as_secs())
.unwrap_or(0);
let mut tree_state = TreeState::new(node_addr, tree_now_secs);
tree_state.set_self_is_leaf(config.node_profile() == NodeProfile::Leaf);
tree_state.set_parent_hysteresis(config.node.tree.parent_hysteresis);
tree_state.set_hold_down(config.node.tree.hold_down_secs);
tree_state.set_flap_dampening(
@@ -745,7 +773,11 @@ impl Node {
tree::sign_declaration(tree_state.my_declaration_mut(), &identity)
.expect("signing own declaration should never fail");
let mut bloom_state = BloomState::new(node_addr);
let mut bloom_state = if config.is_leaf_only() {
BloomState::leaf_only(node_addr)
} else {
BloomState::new(node_addr)
};
bloom_state.set_update_debounce_ms(config.node.bloom.update_debounce_ms);
let coord_cache = CoordCache::new(
@@ -784,7 +816,8 @@ impl Node {
identity.clone(),
startup_epoch,
started_at,
false,
config.is_leaf_only(),
config.node_profile(),
max_connections,
max_peers,
max_links,
@@ -828,6 +861,7 @@ impl Node {
index_allocator: IndexAllocator::new(),
peers_by_index: HashMap::new(),
pending_outbound: HashMap::new(),
pending_inbound: HashMap::new(),
msg1_rate_limiter,
icmp_rate_limiter: IcmpRateLimiter::new(),
routing: Router::new(),
@@ -861,7 +895,11 @@ impl Node {
pub fn leaf_only(config: Config) -> Result<Self, NodeError> {
let mut node = Self::new(config)?;
node.bloom_state = BloomState::leaf_only(*node.node_addr());
node.replace_context(|ctx| ctx.is_leaf_only = true);
node.replace_context(|ctx| {
ctx.is_leaf_only = true;
ctx.node_profile = NodeProfile::Leaf;
});
node.tree_state.set_self_is_leaf(true);
Ok(node)
}
@@ -897,12 +935,9 @@ impl Node {
.iter()
.map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
.collect();
let xonly = self.identity().pubkey();
for (name, eth_config) in eth_instances {
let transport_id = self.allocate_transport_id();
let mut eth =
EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone());
eth.set_local_pubkey(xonly);
let eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone());
transports.push(TransportHandle::Ethernet(eth));
}
}
@@ -976,14 +1011,13 @@ impl Node {
let mtu = ble_config.mtu();
match crate::transport::ble::io::BluerIo::new(&adapter, mtu).await {
Ok(io) => {
let mut ble = crate::transport::ble::BleTransport::new(
let ble = crate::transport::ble::BleTransport::new(
transport_id,
name,
ble_config,
io,
packet_tx.clone(),
);
ble.set_local_pubkey(self.identity().pubkey().serialize());
transports.push(TransportHandle::Ble(ble));
}
Err(e) => {
@@ -1232,6 +1266,22 @@ impl Node {
self.context.is_leaf_only
}
/// Get the node's profile (Full, NonRouting, Leaf).
pub fn node_profile(&self) -> NodeProfile {
self.context.node_profile
}
/// Collect the set of peers that are not full nodes (non-routing/leaf).
///
/// Used by tree and routing functions to skip non-transit peers.
fn non_full_peers(&self) -> BTreeSet<NodeAddr> {
self.peers
.iter()
.filter(|(_, p)| p.peer_profile() != NodeProfile::Full)
.map(|(addr, _)| *addr)
.collect()
}
// === Tree State ===
/// Get the tree state.
@@ -2053,7 +2103,6 @@ impl Node {
loss_rate: metrics.loss_rate(),
etx: metrics.etx,
goodput_bps: metrics.goodput_bps,
spin_bit_initiator: mmp.spin_bit.is_initiator(),
smoothed_loss: metrics.smoothed_loss(),
smoothed_etx,
srtt_ms,
@@ -2457,7 +2506,7 @@ impl Node {
self.peer_machines
.entry(link_id)
.or_insert_with(|| match expected_identity {
Some(identity) => PeerMachine::new_outbound(link_id, identity, started_at_ms),
Some(_) => PeerMachine::new_outbound(link_id, expected_identity, started_at_ms),
None => PeerMachine::new_inbound(link_id, started_at_ms),
});
if let Some(index) = seed.our_index {
@@ -2505,6 +2554,14 @@ impl Node {
}
/// Remove a peer.
///
/// Raw `pub` accessor with ZERO callers in the crate (production or test) —
/// the production teardown path is [`remove_active_peer`](Self::remove_active_peer),
/// which drops the peer's `peer_machines` entry. This helper deliberately
/// does NOT touch `peer_machines`: it never runs against an established peer
/// in production, so it cannot orphan a machine. If it is ever
/// wired to remove established peers, mirror the `remove_active_peer` cleanup
/// (`self.peer_machines.remove(&peer.link_id())`) here.
pub fn remove_peer(&mut self, node_addr: &NodeAddr) -> Option<ActivePeer> {
self.peers.remove(node_addr)
}
@@ -2773,11 +2830,9 @@ impl Node {
return self.peers.get(&next_hop);
}
// 4. Greedy tree routing fallback. No peers are excluded from transit
// on this branch; the non-full/leaf skip is a next-only refinement.
let next_hop_id = self
.tree_state
.find_next_hop(&dest_coords, &BTreeSet::new())?;
// 4. Greedy tree routing fallback (skip non-routing/leaf peers)
let skip = self.non_full_peers();
let next_hop_id = self.tree_state.find_next_hop(&dest_coords, &skip)?;
self.peers.get(&next_hop_id).filter(|p| p.can_send())
}
@@ -2901,9 +2956,7 @@ impl Node {
// Prepend 4-byte session-relative timestamp (inner header)
let timestamp_ms = peer.session_elapsed_ms();
// MMP: read spin bit value before entering session borrow
let sp_flag = peer.mmp().map(|mmp| mmp.spin_bit.tx_bit()).unwrap_or(false);
let mut flags = if sp_flag { FLAG_SP } else { 0 };
let mut flags = 0u8;
if ce_flag {
flags |= FLAG_CE;
}
@@ -3079,6 +3132,64 @@ impl Node {
Ok(())
}
/// Encrypt and send a link-layer message using a raw Noise session.
///
/// Unlike `send_encrypted_link_message`, this does not look the peer up
/// in `self.peers`. It takes the session and wire parameters directly,
/// so it can be used during handshake teardown (before promotion) where
/// the peer state lives on the pending connection embedded in its control
/// machine rather than in `self.peers`.
///
/// The inner-header timestamp is set to 0 — the session has just been
/// established and no session-elapsed reference is available yet; this
/// is acceptable because the frame is a one-shot sent before teardown.
/// No MMP stats or K-bit flag are recorded.
pub(super) async fn send_encrypted_link_message_raw(
&self,
node_addr: NodeAddr,
transport_id: crate::transport::TransportId,
remote_addr: &crate::transport::TransportAddr,
session: &mut crate::noise::NoiseSession,
their_index: crate::utils::index::SessionIndex,
plaintext: &[u8],
) -> Result<(), NodeError> {
let inner_plaintext = prepend_inner_header(0, plaintext);
let counter = session.current_send_counter();
let payload_len = inner_plaintext.len() as u16;
let header = build_established_header(their_index, counter, 0u8, payload_len);
let ciphertext = session
.encrypt_with_aad(&inner_plaintext, &header)
.map_err(|e| NodeError::SendFailed {
node_addr,
reason: format!("encryption failed: {}", e),
})?;
let wire_packet = build_encrypted(&header, &ciphertext);
let transport = self
.transports
.get(&transport_id)
.ok_or(NodeError::TransportNotFound(transport_id))?;
transport
.send(remote_addr, &wire_packet)
.await
.map(|_| ())
.map_err(|e| match e {
TransportError::MtuExceeded { packet_size, mtu } => NodeError::MtuExceeded {
node_addr,
packet_size,
mtu,
},
other => NodeError::SendFailed {
node_addr,
reason: format!("transport send: {}", other),
},
})
}
}
/// Shell-side [`routing::RoutingView`] seam over live `Node` state — the sole
@@ -3135,6 +3246,12 @@ impl routing::RoutingView for NodeRoutingView<'_> {
fn peer_coords(&self, peer: &NodeAddr) -> Option<TreeCoordinate> {
self.tree_state.peer_coords(peer).cloned()
}
fn peer_is_full(&self, peer: &NodeAddr) -> bool {
self.peers
.get(peer)
.is_some_and(|p| p.peer_profile() == NodeProfile::Full)
}
}
/// Project an MMP metrics block into the snapshot
@@ -3219,19 +3336,6 @@ impl HandshakeSeed {
}
}
/// Inbound leg: identity is unknown until msg1 decrypts.
pub(crate) fn inbound(link_id: LinkId, started_at_ms: u64) -> Self {
Self {
link_id,
expected_identity: None,
started_at_ms,
transport_id: None,
source_addr: None,
our_index: None,
their_index: None,
}
}
pub(crate) fn with_transport_id(mut self, transport_id: TransportId) -> Self {
self.transport_id = Some(transport_id);
self
+52 -1
View File
@@ -725,7 +725,16 @@ impl PeeringReconciler {
for cand in &pools.transport_neighbors {
let Some(identity) = cand.identity else {
continue; // anonymous leg cannot key the per-peer cap
// Anonymous first-contact leg (next-only XX path): it has no
// NodeAddr to key the per-peer cap, and — matching next's OLD
// `poll_transport_discovery` — it consumes NO connect_budget and
// is subject to NO cap. The driver already applied the sole gate
// (the `addr_to_link` dedup) when building the pool, so emit
// unconditionally. On master this branch is inert (IK discovery
// always yields `Some`), so master-line behavior is unchanged;
// this only re-adds next's dial-every-anonymous-beacon behavior.
actions.push(PeeringAction::Connect(cand.clone()));
continue;
};
let addr = *identity.node_addr();
let queued = queued_per_peer.get(&addr).copied().unwrap_or(0);
@@ -1342,6 +1351,48 @@ mod tests {
assert_eq!(count_connects(&actions), 4);
}
// ---- Parity: anonymous first-contact legs bypass budget + per-peer cap -
#[test]
fn anonymous_transport_neighbors_emit_unconditionally() {
// Anonymous (None-identity) first-contact beacons must be dialed even
// when the named-leg connect budget is fully exhausted — reproducing
// next's OLD poll_transport_discovery, where the anonymous branch checked
// only the driver-side addr_to_link dedup and neither consumed
// connect_budget nor applied the per-peer cap. (next-only XX path; inert
// on master, where discovery always yields Some.)
let anon = |i: u8| Candidate {
transport_id: TransportId::new(1),
remote_addr: TransportAddr::from_string(&format!("203.0.113.{i}:2121")),
identity: None,
active_refresh: false,
};
let pools = DiscoveryPools {
transport_neighbors: (0..20).map(anon).collect(),
..DiscoveryPools::default()
};
let mut r = PeeringReconciler::default();
// handshake_slots == 0 drives connect_budget to 0, which would block every
// NAMED leg; anonymous legs bypass it entirely, so all 20 still emit
// (also proving they ignore the 16 discovery_per_tick cap).
let mut b = ample_budget();
b.handshake_slots = 0;
let actions = r.reconcile(
&base_policy(),
&Observed::default(),
&b,
&pools,
10_000,
Gate::Reconciling,
);
assert_eq!(
count_connects(&actions),
20,
"anonymous legs bypass connect_budget and the per-tick/per-peer caps"
);
}
// ---- Parity: per-tick retry cap (17 due → 16 dial + 1 deferred) --------
#[test]
+18 -19
View File
@@ -1,7 +1,7 @@
//! End-to-end session state.
//!
//! Tracks Noise XK sessions between this node and remote endpoints.
//! Sessions are established via a three-message XK handshake
//! Tracks Noise XX sessions between this node and remote endpoints.
//! Sessions are established via a three-message XX handshake
//! (SessionSetup/SessionAck/SessionMsg3) carried inside SessionDatagram
//! envelopes through the mesh.
@@ -29,9 +29,9 @@ fn draw_rekey_jitter() -> i64 {
/// on every encrypt/decrypt, working against that win.
#[allow(clippy::large_enum_variant)]
pub(crate) enum EndToEndState {
/// We initiated: sent SessionSetup with Noise XK msg1, awaiting SessionAck.
/// We initiated: sent SessionSetup with Noise XX msg1, awaiting SessionAck.
Initiating(HandshakeState),
/// XK responder: processed msg1, sent msg2, awaiting msg3.
/// XX responder: processed msg1, sent msg2, awaiting msg3.
/// Transitions to Established when msg3 arrives.
AwaitingMsg3(HandshakeState),
/// Handshake complete, NoiseSession available for encrypt/decrypt.
@@ -63,7 +63,7 @@ impl EndToEndState {
matches!(self, EndToEndState::Initiating(_))
}
/// Check if we are an XK responder awaiting msg3.
/// Check if we are an XX responder awaiting msg3.
pub(crate) fn is_awaiting_msg3(&self) -> bool {
matches!(self, EndToEndState::AwaitingMsg3(_))
}
@@ -98,7 +98,7 @@ pub(crate) struct SessionEntry {
/// reset on CoordsRequired receipt.
coords_warmup_remaining: u8,
/// Whether this node initiated the Noise handshake.
/// Used for spin bit role assignment in session-layer MMP.
/// Surfaced through the `is_initiator()` accessor.
is_initiator: bool,
/// Session-layer MMP state. Initialized on Established transition.
mmp: Option<MmpSessionState>,
@@ -275,7 +275,7 @@ impl SessionEntry {
self.state.as_ref().is_some_and(|s| s.is_initiating())
}
/// Check if we are an XK responder awaiting msg3.
/// Check if we are an XX responder awaiting msg3.
pub(crate) fn is_awaiting_msg3(&self) -> bool {
self.state.as_ref().is_some_and(|s| s.is_awaiting_msg3())
}
@@ -340,7 +340,6 @@ impl SessionEntry {
config.mode,
config.log_interval_secs,
config.owd_window_size,
self.is_initiator,
));
}
@@ -624,7 +623,7 @@ impl SessionEntry {
self.rekey_state = None;
}
/// Set the rekey handshake state (in-progress XK handshake).
/// Set the rekey handshake state (in-progress XX handshake).
pub(crate) fn set_rekey_state(&mut self, state: HandshakeState, is_initiator: bool) {
self.rekey_state = Some(state);
self.rekey_initiator = is_initiator;
@@ -814,22 +813,22 @@ mod overlapping_epoch_tests {
Keypair::from_secret_key(&secp, &sk)
}
/// Run a full XK handshake and return `(initiator_session,
/// Run a full XX handshake and return `(initiator_session,
/// responder_session)` — a paired sender/receiver.
fn xk_pair(init_seed: u8, resp_seed: u8) -> (NoiseSession, NoiseSession) {
let init_kp = keypair(init_seed);
let resp_kp = keypair(resp_seed);
let mut initiator = HandshakeState::new_xk_initiator(init_kp, resp_kp.public_key());
let mut initiator = HandshakeState::new_initiator(init_kp);
initiator.set_local_epoch([0xA1, 0xB2, 0xC3, 0xD4, 0x11, 0x22, 0x33, 0x44]);
let mut responder = HandshakeState::new_xk_responder(resp_kp);
let mut responder = HandshakeState::new_responder(resp_kp);
responder.set_local_epoch([0xD4, 0xC3, 0xB2, 0xA1, 0x44, 0x33, 0x22, 0x11]);
let msg1 = initiator.write_xk_message_1().unwrap();
responder.read_xk_message_1(&msg1).unwrap();
let msg2 = responder.write_xk_message_2().unwrap();
initiator.read_xk_message_2(&msg2).unwrap();
let msg3 = initiator.write_xk_message_3().unwrap();
responder.read_xk_message_3(&msg3).unwrap();
let msg1 = initiator.write_message_1().unwrap();
responder.read_message_1(&msg1).unwrap();
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
let msg3 = initiator.write_message_3().unwrap();
responder.read_message_3(&msg3).unwrap();
(
initiator.into_session().unwrap(),
@@ -1284,7 +1283,7 @@ mod overlapping_epoch_tests {
let mut entry = entry_with_current(cur_recv);
// Mark ourselves the rekey initiator, then land the completed session
// as pending (clears rekey_state, so has_rekey_in_progress() == false).
entry.set_rekey_state(HandshakeState::new_xk_responder(keypair(7)), true);
entry.set_rekey_state(HandshakeState::new_responder(keypair(7)), true);
entry.set_pending_session(new_recv);
entry.set_rekey_completed_ms(rekey_completed_ms);
entry
+7
View File
@@ -297,6 +297,13 @@ pub struct BloomStatsSnapshot {
pub sent: u64,
pub debounce_suppressed: u64,
pub send_failed: u64,
pub deltas_sent: u64,
pub full_sends: u64,
pub nacks_sent: u64,
pub nacks_received: u64,
pub size_changes: u64,
pub total_compressed_bytes: u64,
pub total_raw_bytes: u64,
}
#[derive(Clone, Debug, Default, Serialize)]
+245 -52
View File
@@ -2,7 +2,7 @@ use super::*;
use crate::ReceivedPacket;
use crate::node::acl::PeerAclReloader;
use crate::node::reloadable::HostMapReloadable;
use crate::proto::fmp::wire::{build_msg1, build_msg2};
use crate::proto::fmp::wire::build_msg2;
use crate::upper::hosts::HostMap;
use crate::utils::index::SessionIndex;
use std::path::PathBuf;
@@ -37,7 +37,7 @@ async fn test_outbound_connect_denied_by_denylist() {
.initiate_connection(
TransportId::new(1),
TransportAddr::from_string("127.0.0.1:9000"),
PeerIdentity::from_pubkey_full(denied.pubkey_full()),
Some(PeerIdentity::from_pubkey_full(denied.pubkey_full())),
)
.await;
@@ -47,33 +47,11 @@ async fn test_outbound_connect_denied_by_denylist() {
assert_eq!(node.peer_count(), 0);
}
#[tokio::test]
async fn test_inbound_msg1_denied_by_acl() {
let (dir, mut node_b) = make_acl_node();
let node_a = make_node();
std::fs::write(deny_path(&dir), format!("{}\n", node_a.npub())).unwrap();
node_b.reload_peer_acl().await;
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full());
let mut conn_a = outbound_leg(LinkId::new(1), peer_b_identity, 1000);
let noise_msg1 = conn_a
.start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000)
.unwrap();
let wire_msg1 = build_msg1(SessionIndex::new(7), &noise_msg1);
let packet = ReceivedPacket::with_timestamp(
TransportId::new(1),
TransportAddr::from_string("127.0.0.1:5000"),
wire_msg1,
1000,
);
node_b.handle_msg1(packet).await;
assert_eq!(node_b.peer_count(), 0);
assert_eq!(node_b.connection_count(), 0);
assert_eq!(node_b.link_count(), 0);
}
// The master-line `test_inbound_msg1_denied_by_acl` has no counterpart here.
// Under XX, msg1 is an ephemeral-only exchange that carries no identity, so
// there is no peer to authorize at that step and no ACL decision to assert.
// The equivalent gate on this line runs once msg3 has revealed the initiator's
// static key; `test_inbound_msg3_denied_by_acl` covers it there.
#[tokio::test]
async fn test_outbound_msg2_denied_after_acl_reload() {
@@ -124,6 +102,7 @@ async fn test_outbound_msg2_denied_after_acl_reload() {
node_b.identity().keypair(),
responder_epoch,
&noise_msg1,
None,
1000,
)
.unwrap();
@@ -142,6 +121,171 @@ async fn test_outbound_msg2_denied_after_acl_reload() {
assert!(node_a.pending_outbound.is_empty());
}
/// Inbound rejection at msg3 must also cut down the initiator.
///
/// Under Noise XX the responder only sees the initiator's identity after
/// processing msg3, so by the time the inbound ACL fires, the initiator
/// has already completed its side of the handshake and promoted the peer
/// locally. Without an explicit rejection signal the initiator would sit
/// as a "zombie peer" until link-dead timeout — several seconds too slow
/// for the `acl-allowlist` integration test's 5s convergence check.
///
/// Exercises the full round trip: responder sends an encrypted
/// `Disconnect(Other)` on the newly-established Noise session, and the
/// initiator's existing `handle_disconnect` path tears the peer down.
#[tokio::test]
async fn test_inbound_msg3_denied_triggers_disconnect() {
use crate::config::UdpConfig;
use crate::node::acl::PeerAclReloader;
use crate::proto::fmp::wire::build_msg1;
use crate::transport::udp::UdpTransport;
use crate::transport::{TransportHandle, packet_channel};
use tokio::time::{Duration, timeout};
// === Setup: node A (initiator) and node B (responder) over UDP ===
let mut node_a = make_node();
let dir_b = tempfile::tempdir().unwrap();
let mut node_b = make_node();
node_b.peer_acl = PeerAclReloader::with_paths(
dir_b.path().join("peers.allow"),
dir_b.path().join("peers.deny"),
);
std::fs::write(
dir_b.path().join("peers.deny"),
format!("{}\n", node_a.npub()),
)
.unwrap();
assert!(node_b.reload_peer_acl().await);
let transport_id_a = TransportId::new(1);
let transport_id_b = TransportId::new(1);
let udp_config = UdpConfig {
bind_addr: Some("127.0.0.1:0".to_string()),
mtu: Some(1280),
..Default::default()
};
let (packet_tx_a, mut packet_rx_a) = packet_channel(64);
let (packet_tx_b, mut packet_rx_b) = packet_channel(64);
let mut transport_a = UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a);
let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
transport_a.start_async().await.unwrap();
transport_b.start_async().await.unwrap();
let addr_b = TransportAddr::from_string(&transport_b.local_addr().unwrap().to_string());
node_a
.transports
.insert(transport_id_a, TransportHandle::Udp(transport_a));
node_b
.transports
.insert(transport_id_b, TransportHandle::Udp(transport_b));
// === A initiates the handshake ===
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full());
let link_id_a = node_a.allocate_link_id();
let our_index_a = node_a.index_allocator.allocate().unwrap();
// Mirror the production dial path: the seam seeds the outbound leg's
// control machine, which owns the handshake crypto.
node_a
.seed_handshake_machine(
HandshakeSeed::outbound(link_id_a, peer_b_identity, 1000)
.with_our_index(our_index_a)
.with_transport_id(transport_id_a)
.with_source_addr(addr_b.clone()),
)
.unwrap();
let our_keypair_a = node_a.identity().keypair();
let startup_epoch_a = node_a.startup_epoch();
let noise_msg1 = node_a
.peer_machines
.get_mut(&link_id_a)
.unwrap()
.start_handshake(our_keypair_a, startup_epoch_a, 1000)
.unwrap();
let wire_msg1 = build_msg1(our_index_a, &noise_msg1);
let link_a = Link::connectionless(
link_id_a,
transport_id_a,
addr_b.clone(),
LinkDirection::Outbound,
Duration::from_millis(100),
);
node_a.links.insert(link_id_a, link_a);
node_a
.addr_to_link
.insert((transport_id_a, addr_b.clone()), link_id_a);
node_a
.pending_outbound
.insert((transport_id_a, our_index_a.as_u32()), link_id_a);
let transport = node_a.transports.get(&transport_id_a).unwrap();
transport
.send(&addr_b, &wire_msg1)
.await
.expect("Failed to send msg1");
// === B: msg1 → msg2 (no ACL check yet, identity unknown) ===
let packet_b_msg1 = timeout(Duration::from_secs(1), packet_rx_b.recv())
.await
.expect("Timeout waiting for msg1")
.expect("Channel closed");
node_b.handle_msg1(packet_b_msg1).await;
assert_eq!(node_b.peer_count(), 0, "B should not promote at msg1 (XX)");
assert_eq!(node_b.connection_count(), 1, "B should hold pending conn");
// === A: msg2 → promotes, sends msg3 ===
let packet_a_msg2 = timeout(Duration::from_secs(1), packet_rx_a.recv())
.await
.expect("Timeout waiting for msg2")
.expect("Channel closed");
node_a.handle_msg2(packet_a_msg2).await;
assert_eq!(node_a.peer_count(), 1, "A promoted after msg2 (XX zombie)");
// === B: msg3 → ACL reject, sends encrypted Disconnect, tears down ===
let packet_b_msg3 = timeout(Duration::from_secs(1), packet_rx_b.recv())
.await
.expect("Timeout waiting for msg3")
.expect("Channel closed");
node_b.handle_msg3(packet_b_msg3).await;
assert_eq!(node_b.peer_count(), 0, "B must not promote a denied peer");
assert_eq!(node_b.connection_count(), 0, "B pending conn cleaned up");
assert_eq!(node_b.link_count(), 0, "B link cleaned up");
// === A: encrypted frame arrives → handle_encrypted_frame →
// handle_disconnect → remove_active_peer ===
let disconnect_packet = timeout(Duration::from_secs(1), packet_rx_a.recv())
.await
.expect("Timeout waiting for Disconnect from B")
.expect("Channel closed");
node_a.handle_encrypted_frame(disconnect_packet).await;
assert_eq!(
node_a.peer_count(),
0,
"A must drop the zombie peer after receiving Disconnect"
);
// Clean up transports
for (_, t) in node_a.transports.iter_mut() {
t.stop().await.ok();
}
for (_, t) in node_b.transports.iter_mut() {
t.stop().await.ok();
}
}
#[tokio::test]
async fn test_host_map_hot_reloads_from_tick() {
let dir = tempfile::tempdir().unwrap();
@@ -180,52 +324,101 @@ async fn test_outbound_connect_not_denied_by_allowlist_miss() {
.initiate_connection(
TransportId::new(1),
TransportAddr::from_string("127.0.0.1:9000"),
PeerIdentity::from_pubkey_full(denied.pubkey_full()),
Some(PeerIdentity::from_pubkey_full(denied.pubkey_full())),
)
.await;
assert!(!matches!(result, Err(NodeError::AccessDenied(_))));
}
/// The ACL-rejected arm of the same property the Noise-failure arm pins in
/// `unit.rs`: a msg1 that is admitted by the crypto but denied by the ACL
/// still leaves nothing behind. The control machine is built above the crypto
/// so it can drive the handshake, but it stays a local until a promote tail
/// inserts it, so a denial drops it.
/// A peer the ACL turns away must leave nothing behind in the registry.
///
/// Under Noise XX the inbound admission gate cannot sit at msg1 — no static
/// key has crossed the wire yet — so it sits at the msg3 `authorize_peer`
/// call, by which point the responder has built a control machine, allocated
/// a session index, opened a link, and completed the Noise session. All of
/// that has to come back down on the denial. A machine left in
/// `peer_machines` is unreachable by every teardown path and holds a
/// peering-budget slot forever, so that is what this pins, along with the
/// link and the connection count.
///
/// What it deliberately does NOT pin is session-index hygiene. This arm does
/// not return the index to the allocator today, unlike the sibling bad-state
/// arm just above it; that gap is tracked and is left exactly as it is here,
/// since this change adds tests only. Asserting on `peers_by_index` would
/// look like coverage of it and would be worthless: nothing maps an index
/// until promotion, which is downstream of this gate, so such an assertion
/// holds no matter what the denial cleans up.
#[tokio::test]
async fn test_acl_rejected_msg1_leaves_no_registry_trace() {
let (dir, mut node_b) = make_acl_node();
async fn test_acl_rejected_msg3_leaves_no_registry_trace() {
use crate::proto::fmp::wire::{Msg2Header, build_msg1, build_msg3};
let (dir_b, mut node_b) = make_acl_node();
let node_a = make_node();
std::fs::write(deny_path(&dir_b), format!("{}\n", node_a.npub())).unwrap();
assert!(node_b.reload_peer_acl().await);
std::fs::write(deny_path(&dir), format!("{}\n", node_a.npub())).unwrap();
node_b.reload_peer_acl().await;
let transport_id = TransportId::new(1);
let remote_addr = TransportAddr::from_string("127.0.0.1:5000");
// Neither node has a transport installed, so the wire bytes are carried
// across by hand. A dials B and B answers msg2.
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full());
let our_index_a = SessionIndex::new(7);
let mut conn_a = outbound_leg(LinkId::new(1), peer_b_identity, 1000);
let noise_msg1 = conn_a
.start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000)
.unwrap();
let wire_msg1 = build_msg1(SessionIndex::new(7), &noise_msg1);
let packet = ReceivedPacket::with_timestamp(
TransportId::new(1),
TransportAddr::from_string("127.0.0.1:5000"),
wire_msg1,
1000,
);
node_b.handle_msg1(packet).await;
node_b
.handle_msg1(ReceivedPacket::with_timestamp(
transport_id,
remote_addr.clone(),
build_msg1(our_index_a, &noise_msg1),
1000,
))
.await;
// B has parked a machine awaiting msg3. No admission decision has run:
// it still does not know who dialed it.
let link_id_b = *node_b
.peer_machines
.keys()
.next()
.expect("msg1 parks a machine on the responder");
let machine_b = node_b.peer_machines.get(&link_id_b).unwrap();
let our_index_b = machine_b
.our_index()
.expect("msg1 allocates the responder's session index");
let wire_msg2 = machine_b
.conn_handshake_msg2()
.expect("msg1 stores the framed msg2 on the carrier")
.to_vec();
assert_eq!(node_b.link_count(), 1);
// A completes on msg2 and answers msg3, which is where its static key —
// and therefore the denial — first reaches B.
let msg2_header = Msg2Header::parse(&wire_msg2).unwrap();
let (noise_msg3, _) = conn_a
.complete_handshake(msg2_header.noise_msg2(&wire_msg2), None, 1100)
.unwrap();
node_b
.handle_msg3(ReceivedPacket::with_timestamp(
transport_id,
remote_addr,
build_msg3(our_index_a, our_index_b, &noise_msg3),
1200,
))
.await;
assert!(
node_b.peer_machines.is_empty(),
"an ACL-denied msg1 must leave no control machine behind"
"a denied peer must leave no control machine behind"
);
assert_eq!(node_b.connection_count(), 0);
assert_eq!(node_b.peer_count(), 0);
assert_eq!(node_b.link_count(), 0);
assert!(
node_b.peers_by_index.is_empty(),
"an ACL-denied msg1 must allocate no session index"
);
assert_eq!(
node_b.stats().handshake.bad_state,
1,
+7 -1
View File
@@ -278,6 +278,12 @@ async fn test_ble_discovery() {
};
let io = MockBleIo::new("hci0", addr.clone());
// Probe connect must succeed for peers to reach the discovery buffer
let local = addr.clone();
io.set_connect_handler(move |target, _psm| {
let (stream, _peer) = MockBleStream::pair(local.clone(), target.clone(), 2048);
Ok(stream)
});
let (packet_tx, packet_rx) = packet_channel(256);
let mut transport = BleTransport::new(transport_id, None, config, io, packet_tx);
transport.start_async().await.unwrap();
@@ -292,7 +298,7 @@ async fn test_ble_discovery() {
tokio::time::advance(std::time::Duration::from_secs(6)).await;
tokio::task::yield_now().await;
// Without pubkey set, peers appear as bare MACs in discovery buffer
// Peers appear as bare addresses in discovery buffer after probe
let peers = transport.discover().unwrap();
assert_eq!(peers.len(), 2);
+57 -16
View File
@@ -3,7 +3,7 @@
use super::*;
use crate::EstablishedTraversal;
use crate::config::{TransportInstances, UdpConfig};
use crate::proto::fmp::wire::{PHASE_MSG1, PHASE_MSG2};
use crate::proto::fmp::wire::{PHASE_MSG1, PHASE_MSG2, PHASE_MSG3};
use crate::transport::udp::UdpTransport;
use crate::utils::index::IndexAllocator;
use std::collections::HashMap;
@@ -47,19 +47,35 @@ async fn test_adopted_udp_traversal_completes_handshake() {
assert_eq!(result.remote_addr, addr_b);
assert!(node_a.get_transport(&result.transport_id).is_some());
tokio::select! {
result = node_b.run_rx_loop() => {
panic!("node_b rx loop exited unexpectedly: {:?}", result);
}
_ = tokio::time::sleep(Duration::from_millis(500)) => {}
}
// XX three-way handshake (drive directly so the cancellation pattern
// doesn't strand `packet_rx` after one rx_loop iteration).
// 1. node_a (initiator) sent msg1 in adopt_established_traversal
// 2. node_b receives msg1, generates msg2 (reveals node_b identity)
// 3. node_a receives msg2, generates msg3 (reveals node_a identity)
// 4. node_b receives msg3, promotes node_a as peer
let mut rx_a = node_a.packet_rx.take().expect("node_a packet_rx");
let mut rx_b = node_b.packet_rx.take().expect("node_b packet_rx");
tokio::select! {
result = node_a.run_rx_loop() => {
panic!("node_a rx loop exited unexpectedly: {:?}", result);
}
_ = tokio::time::sleep(Duration::from_millis(500)) => {}
}
let pkt_at_b = timeout(Duration::from_secs(1), rx_b.recv())
.await
.expect("timeout waiting for node_a -> node_b msg1")
.expect("node_b channel closed");
assert_eq!(pkt_at_b.data[0] & 0x0f, PHASE_MSG1);
node_b.handle_msg1(pkt_at_b).await;
let pkt_at_a = timeout(Duration::from_secs(1), rx_a.recv())
.await
.expect("timeout waiting for node_b -> node_a msg2")
.expect("node_a channel closed");
assert_eq!(pkt_at_a.data[0] & 0x0f, PHASE_MSG2);
node_a.handle_msg2(pkt_at_a).await;
let pkt_at_b = timeout(Duration::from_secs(1), rx_b.recv())
.await
.expect("timeout waiting for node_a -> node_b msg3")
.expect("node_b channel closed");
assert_eq!(pkt_at_b.data[0] & 0x0f, PHASE_MSG3);
node_b.handle_msg3(pkt_at_b).await;
let peer_a_node_addr =
*PeerIdentity::from_pubkey_full(node_a.identity().pubkey_full()).node_addr();
@@ -69,12 +85,12 @@ async fn test_adopted_udp_traversal_completes_handshake() {
assert_eq!(
node_a.peer_count(),
1,
"node_a should promote node_b after handoff"
"node_a should promote node_b after receiving msg2"
);
assert_eq!(
node_b.peer_count(),
1,
"node_b should promote node_a after receiving msg1"
"node_b should promote node_a after receiving msg3"
);
assert!(node_a.get_peer(&peer_b_node_addr).unwrap().has_session());
assert!(node_b.get_peer(&peer_a_node_addr).unwrap().has_session());
@@ -222,6 +238,15 @@ async fn test_third_peer_can_handshake_via_adopted_transport_socket() {
assert_eq!(pkt_at_b.data[0] & 0x0f, PHASE_MSG2);
node_b.handle_msg2(pkt_at_b).await;
// XX msg3: Bob (initiator of Alice/Bob sub-handshake) sent msg3 in
// response to msg2; Alice receives it and promotes Bob.
let pkt_at_a = timeout(Duration::from_secs(1), rx_a.recv())
.await
.expect("timeout waiting for Bob->Alice msg3")
.expect("node_a channel closed");
assert_eq!(pkt_at_a.data[0] & 0x0f, PHASE_MSG3);
node_a.handle_msg3(pkt_at_a).await;
let node_a_addr = *PeerIdentity::from_pubkey_full(node_a.identity().pubkey_full()).node_addr();
assert!(
node_b.get_peer(&node_a_addr).is_some(),
@@ -239,7 +264,7 @@ async fn test_third_peer_can_handshake_via_adopted_transport_socket() {
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full());
let adopted_addr = TransportAddr::from_string(&handoff_result.local_addr.to_string());
node_c
.initiate_connection(transport_id_c, adopted_addr, peer_b_identity)
.initiate_connection(transport_id_c, adopted_addr, Some(peer_b_identity))
.await
.unwrap();
@@ -272,6 +297,22 @@ async fn test_third_peer_can_handshake_via_adopted_transport_socket() {
};
node_c.handle_msg2(pkt_at_c).await;
// XX msg3: node_c (initiator) sent msg3 in response to msg2.
// node_b receives msg3 and promotes node_c.
let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
let pkt_at_b = loop {
let pkt = timeout_at(deadline, rx_b.recv())
.await
.expect("timeout waiting for Colin->Bob msg3")
.expect("node_b channel closed");
if pkt.remote_addr.as_str() == Some(&addr_c.to_string())
&& pkt.data.first().map(|b| b & 0x0f) == Some(PHASE_MSG3)
{
break pkt;
}
};
node_b.handle_msg3(pkt_at_b).await;
let node_c_addr = *PeerIdentity::from_pubkey_full(node_c.identity().pubkey_full()).node_addr();
assert!(
node_b.get_peer(&node_c_addr).is_some(),
+4 -3
View File
@@ -253,13 +253,12 @@ async fn test_disconnect_clears_session() {
let node1_addr = *nodes[1].node.node_addr();
// Inject a synthetic Established session entry into node 1's session table
// to simulate the state after a completed XK handshake with node 0.
// to simulate the state after a completed XX handshake with node 0.
let remote_identity = Identity::generate();
{
let our_identity = nodes[1].node.identity();
let mut initiator =
HandshakeState::new_initiator(our_identity.keypair(), remote_identity.pubkey_full());
let mut initiator = HandshakeState::new_initiator(our_identity.keypair());
let mut responder = HandshakeState::new_responder(remote_identity.keypair());
let mut init_epoch = [0u8; 8];
rand::Rng::fill_bytes(&mut rand::rng(), &mut init_epoch);
@@ -271,6 +270,8 @@ async fn test_disconnect_clears_session() {
responder.read_message_1(&msg1).unwrap();
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
let msg3 = initiator.write_message_3().unwrap();
responder.read_message_3(&msg3).unwrap();
let session = initiator.into_session().unwrap();
let entry = SessionEntry::new(
+140 -18
View File
@@ -9,7 +9,7 @@ use crate::proto::lookup::{LookupRequest, LookupResponse, RecentRequest};
use crate::proto::stp::TreeCoordinate;
use spanning_tree::{
cleanup_nodes, generate_random_edges, lock_large_network_test, process_available_packets,
run_tree_test, verify_tree_convergence,
run_tree_test, run_tree_test_with_mtus, verify_tree_convergence,
};
// ============================================================================
@@ -31,9 +31,8 @@ async fn test_request_dedup() {
let from = make_node_addr(0xAA);
let target = make_node_addr(0xBB);
let origin = make_node_addr(0xCC);
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
let request = LookupRequest::new(999, target, origin, coords, 5, 0);
let request = LookupRequest::new(999, target, origin, 5, 0);
let payload = &request.encode()[1..]; // skip msg_type byte
// First request: accepted
@@ -51,10 +50,9 @@ async fn test_request_target_is_self() {
let from = make_node_addr(0xAA);
let origin = make_node_addr(0xCC);
let my_addr = *node.node_addr();
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
// Request targeting us
let request = LookupRequest::new(777, my_addr, origin, coords, 5, 0);
let request = LookupRequest::new(777, my_addr, origin, 5, 0);
let payload = &request.encode()[1..];
// Should succeed without panic (response send will fail silently
@@ -69,9 +67,8 @@ async fn test_request_ttl_zero_not_forwarded() {
let from = make_node_addr(0xAA);
let target = make_node_addr(0xBB);
let origin = make_node_addr(0xCC);
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
let request = LookupRequest::new(666, target, origin, coords, 0, 0);
let request = LookupRequest::new(666, target, origin, 0, 0);
let payload = &request.encode()[1..];
node.handle_lookup_request(&from, payload).await;
@@ -240,7 +237,7 @@ async fn test_response_proof_verification_failure() {
#[tokio::test]
async fn test_response_identity_cache_miss() {
// Verify that a response is discarded when the origin lacks the
// target's pubkey in identity_cache (e.g., XK responder before msg3).
// target's pubkey in identity_cache (e.g., XX responder before msg3).
let mut node = make_node();
let from = make_node_addr(0xAA);
@@ -333,8 +330,7 @@ async fn test_recent_request_expiry() {
// Trigger purge via a new lookup request
let target = make_node_addr(0xBB);
let origin = make_node_addr(0xCC);
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
let request = LookupRequest::new(789, target, origin, coords, 3, 0);
let request = LookupRequest::new(789, target, origin, 3, 0);
let payload = &request.encode()[1..];
node.handle_lookup_request(&make_node_addr(0xAA), payload)
.await;
@@ -359,10 +355,8 @@ async fn test_request_forwarding_two_node() {
let node0_addr = *nodes[0].node.node_addr();
let target = *nodes[1].node.node_addr(); // target node1 (in bloom filters)
let root = make_node_addr(0);
let coords = TreeCoordinate::from_addrs(vec![node0_addr, root]).unwrap();
let request = LookupRequest::new(42, target, node0_addr, coords, 5, 0);
let request = LookupRequest::new(42, target, node0_addr, 5, 0);
let payload = &request.encode()[1..];
// Handle on node0 as if we received it from outside
@@ -483,10 +477,8 @@ async fn test_request_dedup_convergent_paths() {
let node0_addr = *nodes[0].node.node_addr();
let target = *nodes[2].node.node_addr(); // target node2 (in bloom filters)
let root = make_node_addr(0);
let coords = TreeCoordinate::from_addrs(vec![node0_addr, root]).unwrap();
let request = LookupRequest::new(300, target, node0_addr, coords, 5, 0);
let request = LookupRequest::new(300, target, node0_addr, 5, 0);
let payload = &request.encode()[1..];
// Node0 handles the request (forwards to peers whose bloom filter
@@ -863,9 +855,8 @@ async fn test_request_min_mtu_preserved_through_encode_decode() {
// Verify min_mtu survives encode/decode in the handler test context
let target = make_node_addr(0xBB);
let origin = make_node_addr(0xCC);
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
let request = LookupRequest::new(100, target, origin, coords, 5, 1386);
let request = LookupRequest::new(100, target, origin, 5, 1386);
let encoded = request.encode();
let decoded = LookupRequest::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.min_mtu, 1386);
@@ -953,6 +944,137 @@ async fn test_originator_lookup_response_keeps_tighter_path_mtu_lookup() {
);
}
// ============================================================================
// Integration Tests — min_mtu transit pruning
// ============================================================================
#[tokio::test]
async fn test_transit_prunes_lookup_by_min_mtu() {
// Topology: node0(1280) — node1(800) — node2(1280)
// Node0 initiates lookup for node2 with min_mtu=1280 (default TUN MTU).
// Node1's transport MTU is 800 < 1280, so node1 should NOT forward
// the request to node2. The lookup should fail (no cache entry).
let mtus = [1280, 800, 1280];
let edges = vec![(0, 1), (1, 2)];
let mut nodes = run_tree_test_with_mtus(&mtus, &edges).await;
let node2_addr = *nodes[2].node.node_addr();
let node2_pubkey = nodes[2].node.identity().pubkey_full();
nodes[0].node.register_identity(node2_addr, node2_pubkey);
nodes[0].node.initiate_lookup(&node2_addr, 8).await;
for _ in 0..10 {
tokio::time::sleep(Duration::from_millis(100)).await;
process_available_packets(&mut nodes).await;
}
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
assert!(
!nodes[0].node.coord_cache().contains(&node2_addr, now_ms),
"Node0 should NOT have cached node2 route (transit pruned by min_mtu)"
);
cleanup_nodes(&mut nodes).await;
}
#[tokio::test]
async fn test_transit_forwards_when_mtu_sufficient() {
// Topology: node0(1280) — node1(1400) — node2(1280)
// Node0 initiates lookup for node2 with min_mtu=1280 (default TUN MTU).
// Node1's transport MTU is 1400 >= 1280, so the request passes through.
// Bottleneck min-fold accumulates contributions from BOTH the target's
// own outgoing-link MTU (the target-edge fold added with the
// direct-link/target-edge gap fix) and each transit node's outgoing-
// link MTU. With node2 (target) at 1280 and node1 (transit) at 1400,
// the bottleneck is min(1280, 1400) = 1280.
let mtus = [1280, 1400, 1280];
let edges = vec![(0, 1), (1, 2)];
let mut nodes = run_tree_test_with_mtus(&mtus, &edges).await;
let node2_addr = *nodes[2].node.node_addr();
let node2_pubkey = nodes[2].node.identity().pubkey_full();
nodes[0].node.register_identity(node2_addr, node2_pubkey);
nodes[0].node.initiate_lookup(&node2_addr, 8).await;
for _ in 0..10 {
tokio::time::sleep(Duration::from_millis(100)).await;
process_available_packets(&mut nodes).await;
}
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
assert!(
nodes[0].node.coord_cache().contains(&node2_addr, now_ms),
"Node0 should have cached node2 route (MTU sufficient)"
);
let entry = nodes[0].node.coord_cache().get_entry(&node2_addr).unwrap();
let path_mtu = entry.path_mtu().expect("path_mtu should be set");
assert_eq!(
path_mtu, 1280,
"path_mtu should be min(target-edge 1280, transit 1400) = 1280"
);
cleanup_nodes(&mut nodes).await;
}
#[tokio::test]
async fn test_response_path_mtu_four_node_chain() {
// Topology: node0(1280) — node1(1500) — node2(1350) — node3(1280)
// Node0 initiates lookup for node3. Response travels node3→node2→node1→node0.
// The bottleneck min-fold now accumulates contributions from the target's
// own outgoing link MTU (target-edge fold added with the direct-link gap
// fix) AND each transit node's outgoing link MTU on the reverse path.
// node3 (target, 1280) → 1280; node2 (transit, 1350) → min(1280, 1350) =
// 1280; node1 (transit, 1500) → min(1280, 1500) = 1280. Result: 1280.
//
// Note: min_mtu=1280 from TUN config. All transit MTUs ≥ 1280 so the
// forward request is not pruned; the test exercises the response-side
// min-fold accumulation explicitly.
let mtus = [1280, 1500, 1350, 1280];
let edges = vec![(0, 1), (1, 2), (2, 3)];
let mut nodes = run_tree_test_with_mtus(&mtus, &edges).await;
let node3_addr = *nodes[3].node.node_addr();
let node3_pubkey = nodes[3].node.identity().pubkey_full();
nodes[0].node.register_identity(node3_addr, node3_pubkey);
nodes[0].node.initiate_lookup(&node3_addr, 8).await;
for _ in 0..15 {
tokio::time::sleep(Duration::from_millis(100)).await;
process_available_packets(&mut nodes).await;
}
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
assert!(
nodes[0].node.coord_cache().contains(&node3_addr, now_ms),
"Node0 should have cached node3 route"
);
let entry = nodes[0].node.coord_cache().get_entry(&node3_addr).unwrap();
let path_mtu = entry.path_mtu().expect("path_mtu should be set");
assert_eq!(
path_mtu, 1280,
"Four-node chain path_mtu = min(target-edge 1280, transits 1350+1500) = 1280"
);
cleanup_nodes(&mut nodes).await;
}
// ============================================================================
// Open-Discovery Sweep — cache-injection unit test
// ============================================================================
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -17,9 +17,8 @@ use crate::utils::index::SessionIndex;
/// Arm a real (initiator) FMP rekey on the peer the given node holds for
/// `peer_addr`, so the msg1 resend budget can be exercised.
fn arm_rekey(node: &mut crate::node::Node, peer_addr: &NodeAddr) {
let remote = Identity::generate();
let local = Identity::generate();
let hs = HandshakeState::new_initiator(local.keypair(), remote.pubkey_full());
let hs = HandshakeState::new_initiator(local.keypair());
let peer = node.get_peer_mut(peer_addr).expect("peer present");
peer.set_rekey_state(hs, SessionIndex::new(7), vec![0xAB; 64], 0);
}
@@ -101,6 +100,44 @@ async fn heartbeat_resumes_after_budget_exhausted() {
cleanup_nodes(&mut nodes).await;
}
/// Next-only: a peer past the link-dead timeout is NOT reaped while a
/// retained FMP rekey msg3 is still being retransmitted with its budget
/// unexhausted. This is the XX/next msg3-liveness arm of the suppression
/// predicate, distinct from the msg1 arm above.
#[tokio::test]
async fn heartbeat_suppressed_during_msg3_retransmit() {
let mut nodes = run_tree_test(2, &[(0, 1)], false).await;
verify_tree_convergence(&nodes);
let addr_1 = *nodes[1].node.node_addr();
assert!(nodes[0].node.get_peer(&addr_1).is_some());
set_link_dead_timeout(&mut nodes[0].node, 0);
// Retain a msg3 payload with budget left (count 0 < max_resends), with
// no msg1 rekey in flight: only the msg3 arm of the guard can hold.
let peer = nodes[0].node.get_peer_mut(&addr_1).unwrap();
peer.set_rekey_msg3_payload(vec![0xCD; 64], 1000);
assert!(!peer.rekey_in_progress());
assert_eq!(
nodes[0]
.node
.get_peer(&addr_1)
.unwrap()
.rekey_msg3_resend_count(),
0
);
nodes[0].node.check_link_heartbeats().await;
assert!(
nodes[0].node.get_peer(&addr_1).is_some(),
"peer reaped despite a retained rekey msg3 with budget remaining"
);
cleanup_nodes(&mut nodes).await;
}
/// Regression guard: with no rekey in flight, a peer past the link-dead
/// timeout is reaped exactly as before.
#[tokio::test]
+4 -7
View File
@@ -58,13 +58,12 @@ fn arm_link_mmp(node: &mut Node, addr: &NodeAddr) {
.record_recv(1, 100, 500, false, crate::time::mono_ms());
}
/// Complete an in-memory Noise IK handshake, returning the initiator session.
/// Complete an in-memory Noise XX handshake, returning the initiator session.
fn make_noise_session(
our_identity: &crate::Identity,
remote_identity: &crate::Identity,
) -> crate::noise::NoiseSession {
let mut initiator =
HandshakeState::new_initiator(our_identity.keypair(), remote_identity.pubkey_full());
let mut initiator = HandshakeState::new_initiator(our_identity.keypair());
let mut responder = HandshakeState::new_responder(remote_identity.keypair());
let mut init_epoch = [0u8; 8];
@@ -78,6 +77,8 @@ fn make_noise_session(
responder.read_message_1(&msg1).unwrap();
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
let msg3 = initiator.write_message_3().unwrap();
responder.read_message_3(&msg3).unwrap();
initiator.into_session().unwrap()
}
@@ -359,15 +360,11 @@ fn craft_rr_payload(highest: u64, pkts: u64, bytes: u64) -> Vec<u8> {
cumulative_bytes_recv: bytes,
timestamp_echo: 1000,
dwell_time: 0,
max_burst_loss: 0,
mean_burst_loss: 0,
jitter: 0,
ecn_ce_count: 0,
owd_trend: 0,
burst_loss_count: 0,
cumulative_reorder_count: 0,
interval_packets_recv: pkts as u32,
interval_bytes_recv: bytes as u32,
};
// handle_receiver_report receives the body with the msg_type byte stripped.
rr.encode()[1..].to_vec()
+17 -16
View File
@@ -14,7 +14,6 @@ mod bootstrap;
mod decrypt_failure;
mod disconnect;
mod discovery;
mod establish_chartests;
mod forwarding;
mod handshake;
mod heartbeat;
@@ -91,7 +90,7 @@ pub(super) fn outbound_leg(
expected_identity: PeerIdentity,
current_time_ms: u64,
) -> PeerMachine {
let mut machine = PeerMachine::new_outbound(link_id, expected_identity, current_time_ms);
let mut machine = PeerMachine::new_outbound(link_id, Some(expected_identity), current_time_ms);
machine.set_leg(HandshakeCrypto::new());
machine
}
@@ -103,7 +102,7 @@ pub(super) fn inbound_leg(link_id: LinkId, current_time_ms: u64) -> PeerMachine
machine
}
/// Seed a control machine whose leg carries a completed Noise IK handshake.
/// Seed a control machine whose leg carries a completed Noise XX handshake.
///
/// Returns the peer identity. The leg is outbound, in Complete state, with
/// session, indices, and transport info set, and is installed on the node
@@ -126,15 +125,11 @@ pub(super) fn seed_completed_connection(
/// [`seed_completed_connection`] with the seed left to the caller, for tests
/// that need a leg deliberately missing one of the fields promotion requires.
///
/// The Noise exchange runs on the already-seeded leg, where it used to run
/// before the leg was handed over. That reordering is neutral, but not
/// because the handshake leaves the seeded fields alone —
/// `receive_handshake_init` does write `expected_identity`. It is neutral
/// because the only read of `expected_identity` is guarded by `is_outbound`:
/// an inbound leg takes the `new_inbound` arm whether or not the identity has
/// been learned, and an outbound leg never runs that method. The remaining
/// reads (`link_id`, `started_at`, `is_outbound`, `their_index`,
/// `transport_id`) are genuinely untouched by the handshake.
/// The Noise exchange runs on the already-seeded machine, where it used to
/// run before the leg was handed over. That reordering is neutral: on XX the
/// responder's `receive_handshake_init` learns nothing (identity arrives at
/// msg3), and the seeded reads (`link_id`, `started_at`, `is_outbound`,
/// `their_index`, `transport_id`) are untouched by the handshake.
pub(super) fn seed_completed_connection_with(
node: &mut Node,
link_id: LinkId,
@@ -168,14 +163,20 @@ pub(super) fn seed_completed_connection_with(
let mut resp_epoch = [0u8; 8];
rand::Rng::fill_bytes(&mut rand::rng(), &mut resp_epoch);
let msg2 = resp_conn
.receive_handshake_init(peer_keypair, resp_epoch, &msg1, current_time_ms)
.receive_handshake_init(peer_keypair, resp_epoch, &msg1, None, current_time_ms)
.unwrap();
// Complete initiator handshake
node.peer_machines
// Complete initiator handshake (XX: generates msg3)
let (msg3, _neg) = node
.peer_machines
.get_mut(&link_id)
.unwrap()
.complete_handshake(&msg2, current_time_ms)
.complete_handshake(&msg2, None, current_time_ms)
.unwrap();
// Complete responder handshake (XX: processes msg3)
resp_conn
.complete_handshake_msg3(&msg3, current_time_ms)
.unwrap();
peer_identity
+255 -44
View File
@@ -5,7 +5,8 @@ use crate::node::session::EndToEndState;
use crate::node::tests::spanning_tree::{
TestNode, cleanup_nodes, drain_all_packets, generate_random_edges, initiate_handshake,
lock_large_network_test, make_test_node_with_config, process_available_packets, run_tree_test,
run_tree_test_with_mtus, verify_tree_convergence,
run_tree_test_with_mtus, run_tree_test_with_profiles,
run_tree_test_with_profiles_leaf_smallest, verify_tree_convergence,
};
use crate::proto::fsp::SessionAck;
use crate::proto::link::SessionDatagram;
@@ -41,6 +42,28 @@ fn populate_all_coord_caches(nodes: &mut [TestNode]) {
}
}
/// Render each node's address and elected tree root.
///
/// Attached to mixed-profile failure messages: the failures worth diagnosing
/// there are root-election partitions, which are keyed on the random per-node
/// address ordering and so are invisible without the addresses that produced
/// them.
fn mesh_state(nodes: &[TestNode]) -> String {
nodes
.iter()
.enumerate()
.map(|(i, tn)| {
format!(
"node[{i}] addr={} root={} self_rooted={}",
tn.node.node_addr(),
tn.node.tree_state().root(),
tn.node.tree_state().is_root()
)
})
.collect::<Vec<_>>()
.join("; ")
}
// ============================================================================
// Unit tests: SessionEntry data structure
// ============================================================================
@@ -52,7 +75,7 @@ fn test_session_entry_new_initiating() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let handshake = HandshakeState::new_initiator(identity_a.keypair());
let entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -79,8 +102,7 @@ fn test_session_entry_rekey_jitter_in_range() {
for _ in 0..100 {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake =
HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let handshake = HandshakeState::new_initiator(identity_a.keypair());
let entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
identity_b.pubkey_full(),
@@ -111,8 +133,7 @@ fn test_session_entry_rekey_jitter_mean_near_zero() {
for _ in 0..n {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake =
HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let handshake = HandshakeState::new_initiator(identity_a.keypair());
let entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
identity_b.pubkey_full(),
@@ -138,7 +159,7 @@ fn test_session_entry_touch() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let handshake = HandshakeState::new_initiator(identity_a.keypair());
let mut entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -160,8 +181,7 @@ fn test_session_table_operations() {
let mut node = make_node();
let identity_b = Identity::generate();
let handshake =
HandshakeState::new_initiator(node.identity().keypair(), identity_b.pubkey_full());
let handshake = HandshakeState::new_initiator(node.identity().keypair());
let dest_addr = *identity_b.node_addr();
let entry = crate::node::session::SessionEntry::new(
@@ -221,7 +241,7 @@ async fn test_session_direct_peer_handshake() {
let count = process_available_packets(&mut nodes).await;
assert!(count > 0, "Expected SessionSetup packet to arrive");
// Node 1 should now have a session in AwaitingMsg3 state (XK: identity not yet known)
// Node 1 should now have a session in AwaitingMsg3 state (XX: identity not yet known)
assert_eq!(nodes[1].node.session_count(), 1);
assert!(
nodes[1]
@@ -277,7 +297,7 @@ async fn test_session_direct_peer_data_transfer() {
let node1_addr = *nodes[1].node.node_addr();
let node1_pubkey = nodes[1].node.identity().pubkey_full();
// Establish session (XK: 3 messages — Setup, Ack, Msg3)
// Establish session (XX: 3 messages — Setup, Ack, Msg3)
nodes[0]
.node
.initiate_session(node1_addr, node1_pubkey)
@@ -354,7 +374,7 @@ async fn test_session_3node_forwarded_handshake() {
tokio::time::sleep(Duration::from_millis(20)).await;
process_available_packets(&mut nodes).await;
// Node 2 should have an AwaitingMsg3 session (XK: identity not yet known)
// Node 2 should have an AwaitingMsg3 session (XX: identity not yet known)
assert!(
nodes[2].node.get_session(&node0_addr).is_some(),
"Node 2 should have a session entry for Node 0"
@@ -462,7 +482,7 @@ async fn test_session_3node_forwarded_data() {
process_available_packets(&mut nodes).await;
}
// Node 2 should be Established (transitioned during XK handshake msg3)
// Node 2 should be Established (transitioned during XX handshake msg3)
assert!(
nodes[2]
.node
@@ -572,6 +592,198 @@ async fn drain_to_quiescence(nodes: &mut [TestNode]) {
}
}
/// Mixed-profile lifecycle over loopback: a Full / Full / NonRouting / Leaf
/// mesh comes up, each node peers to the degree its role and topology
/// dictate, and end-to-end data reaches every reachable pair — including the
/// one multi-hop path, a Leaf node reaching a non-adjacent Full node through
/// the Full router between them. This is the in-process replacement for the
/// Docker `mixed-profile` suite: the node roles are the subject, and none of
/// it needs a real transport.
///
/// Topology (matching the retired Docker suite):
///
/// ```text
/// A (Full) ── B (Full)
/// │ \ │
/// │ \ │
/// D (Leaf) C (NonRouting)
/// ```
#[tokio::test]
async fn mixed_profile_nodes_converge_and_forward() {
use crate::proto::fmp::NodeProfile;
// 0=A Full, 1=B Full, 2=C NonRouting, 3=D Leaf.
let profiles = [
NodeProfile::Full,
NodeProfile::Full,
NodeProfile::NonRouting,
NodeProfile::Leaf,
];
// A-B, A-C, A-D, B-C.
let edges = [(0, 1), (0, 2), (0, 3), (1, 2)];
let mut nodes = run_tree_test_with_profiles(&profiles, &edges, false).await;
// Peer degree per role + topology: A=3 (B,C,D), B=2 (A,C), C=2 (A,B), D=1 (A).
let peer_counts: Vec<usize> = nodes.iter().map(|n| n.node.peers().count()).collect();
assert_eq!(
peer_counts,
vec![3, 2, 2, 1],
"mixed-profile peer degrees (A=3, B=2, C=2, D=1)"
);
populate_all_coord_caches(&mut nodes);
// TUN receiver on every node so delivered plaintext can be observed.
let mut tun_rx = Vec::with_capacity(nodes.len());
for tn in nodes.iter_mut() {
let (tx, rx) = std::sync::mpsc::channel();
tn.node.supervisor.tun_tx = Some(tx);
tun_rx.push(rx);
}
let info: Vec<(NodeAddr, secp256k1::PublicKey)> = nodes
.iter()
.map(|tn| (*tn.node.node_addr(), tn.node.identity().pubkey_full()))
.collect();
// Reachability the Docker suite asserted: F<->F, F<->N and F<->L between
// adjacent nodes, plus the Leaf<->Full multi-hop pair D<->B routed through
// the Full node A between them. Each is checked end to end.
//
// Unlike `leaf_smallest_addr_does_not_partition_multihop`, which pins the
// Leaf to the smallest NodeAddr to make one partition mode deterministic,
// this mesh draws its identities at random, so the multi-hop pair here is
// exercised against whatever root election the address ordering produces.
let reach: &[(usize, usize)] = &[
(0, 1), // A -> B Full <-> Full
(1, 0), // B -> A
(0, 2), // A -> C Full <-> NonRouting
(2, 0), // C -> A
(1, 2), // B -> C
(2, 1), // C -> B
(0, 3), // A -> D Full <-> Leaf
(3, 0), // D -> A
(3, 1), // D -> B Leaf -> Full, two hops via A
(1, 3), // B -> D Full -> Leaf, two hops via A
];
for &(src, dst) in reach {
let (dst_addr, dst_pubkey) = info[dst];
let src_addr = info[src].0;
let initiated = nodes[src].node.initiate_session(dst_addr, dst_pubkey).await;
if let Err(e) = initiated {
panic!(
"initiate_session {src}->{dst} failed: {e:?}; {}",
mesh_state(&nodes)
);
}
drain_to_quiescence(&mut nodes).await;
let payload = format!("mp-{src}-{dst}").into_bytes();
let src_fips = crate::FipsAddress::from_node_addr(&src_addr);
let dst_fips = crate::FipsAddress::from_node_addr(&dst_addr);
let ipv6 = build_ipv6_packet(&src_fips, &dst_fips, &payload);
let sent = nodes[src].node.send_ipv6_packet(&dst_addr, &ipv6).await;
if let Err(e) = sent {
panic!("send {src}->{dst} failed: {e:?}; {}", mesh_state(&nodes));
}
drain_to_quiescence(&mut nodes).await;
// TUN receives the decompressed IPv6 packet; match the upper-layer
// payload after the 40-byte header, as the other forwarding tests do.
let found = std::iter::from_fn(|| tun_rx[dst].try_recv().ok())
.any(|pkt| pkt.len() >= 40 && pkt[40..] == payload[..]);
if !found {
panic!(
"datagram {src}->{dst} must be delivered to node {dst}'s TUN; {}",
mesh_state(&nodes)
);
}
}
cleanup_nodes(&mut nodes).await;
}
/// Regression: a Leaf holding the smallest NodeAddr must not self-elect as root
/// and partition the mesh, so a multi-hop session from the Leaf to a
/// non-adjacent Full node still establishes and delivers.
///
/// Topology A(Full) — B(Full), with D(Leaf) hanging off A. D is pinned to the
/// strictly smallest NodeAddr — the condition that made D self-elect as a second
/// root, partitioning A/B's tree from D's and leaving B unable to route its
/// handshake reply back to D. The multi-hop pair D->B (routed through A) is the
/// one the Docker `mixed-profile` suite covered.
///
/// Only Full and Leaf profiles appear here so the elected root is always the
/// smaller of the two Full nodes: the separate global-min-NonRouting partition
/// (a distinct open problem for the leaf/non-routing tree-participation model) is
/// deliberately kept out so this test isolates the Leaf fix.
///
/// Without the leaf gate D self-roots every run and D->B fails; with it, D
/// attaches under A and the session establishes.
#[tokio::test]
async fn leaf_smallest_addr_does_not_partition_multihop() {
use crate::proto::fmp::NodeProfile;
// 0=A Full, 1=B Full, 2=D Leaf (pinned smallest).
let profiles = [NodeProfile::Full, NodeProfile::Full, NodeProfile::Leaf];
// A-B, A-D (D is a single-upstream leaf hanging off A).
let edges = [(0, 1), (0, 2)];
let mut nodes = run_tree_test_with_profiles_leaf_smallest(&profiles, 2, &edges).await;
// The mesh must be a single tree rooted at a Full node: D (the smallest
// addr) must NOT be its own root. A partition shows up as D self-rooted.
assert!(
!nodes[2].node.tree_state().is_root(),
"the Leaf (smallest addr) must not self-elect as root"
);
let roots: Vec<_> = nodes.iter().map(|n| *n.node.tree_state().root()).collect();
assert!(
roots.iter().all(|r| *r == roots[0]),
"all nodes must share one root (no partition); got {roots:?}"
);
populate_all_coord_caches(&mut nodes);
// TUN receiver on B so delivered plaintext can be observed.
let (tx, b_rx) = std::sync::mpsc::channel();
nodes[1].node.supervisor.tun_tx = Some(tx);
let d_addr = *nodes[2].node.node_addr();
let (b_addr, b_pubkey) = (
*nodes[1].node.node_addr(),
nodes[1].node.identity().pubkey_full(),
);
// D -> B: the multi-hop pair routed through A.
nodes[2]
.node
.initiate_session(b_addr, b_pubkey)
.await
.expect("D->B initiate_session must succeed (no partition)");
drain_to_quiescence(&mut nodes).await;
let payload = b"leaf-multihop".to_vec();
let d_fips = crate::FipsAddress::from_node_addr(&d_addr);
let b_fips = crate::FipsAddress::from_node_addr(&b_addr);
let ipv6 = build_ipv6_packet(&d_fips, &b_fips, &payload);
nodes[2]
.node
.send_ipv6_packet(&b_addr, &ipv6)
.await
.expect("D->B send must succeed");
drain_to_quiescence(&mut nodes).await;
let found = std::iter::from_fn(|| b_rx.try_recv().ok())
.any(|pkt| pkt.len() >= 40 && pkt[40..] == payload[..]);
assert!(
found,
"D->B multi-hop datagram must be delivered to B's TUN"
);
cleanup_nodes(&mut nodes).await;
}
#[tokio::test]
async fn test_session_100_nodes() {
let _guard = lock_large_network_test().await;
@@ -710,7 +922,7 @@ async fn test_session_100_nodes() {
drain_to_quiescence(&mut nodes).await;
// Reverse: responder → initiator
// (Responder should already be Established after XK msg3)
// (Responder should already be Established after XX msg3)
let rev_payload = format!("rev-{}", pair_idx).into_bytes();
let rev_ipv6 = build_ipv6_packet(&dst_fips, &src_fips, &rev_payload);
match nodes[dst].node.send_ipv6_packet(&src_addr, &rev_ipv6).await {
@@ -951,7 +1163,7 @@ async fn test_session_100_nodes() {
assert_eq!(send_forward_err, 0, "All forward sends should succeed");
assert_eq!(
send_reverse_err, 0,
"All reverse sends should succeed (responder Established after XK msg3)"
"All reverse sends should succeed (responder Established after XX msg3)"
);
assert_eq!(
fwd_delivered, send_forward_ok,
@@ -1048,7 +1260,7 @@ async fn test_tun_outbound_established_session() {
let src_fips = crate::FipsAddress::from_node_addr(&node0_addr);
let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr);
// Establish session (XK: 3 messages — Setup, Ack, Msg3)
// Establish session (XX: 3 messages — Setup, Ack, Msg3)
nodes[0]
.node
.initiate_session(node1_addr, node1_pubkey)
@@ -1103,12 +1315,19 @@ async fn test_tun_outbound_established_session() {
/// suites exercised. The rekey timing and choreography decision itself lives in
/// the sans-IO cores and is covered exhaustively there
/// (`proto/fsp/tests/core.rs`, `proto/fmp/tests/core.rs`); this drives a real
/// IK rekey handshake over the loopback transport so the AEAD continuity across
/// XX rekey handshake over the loopback transport so the AEAD continuity across
/// the cutover is asserted end to end.
///
/// Deterministic, no wall-clock wait: `rekey.after_messages = 1` makes the
/// first sent datagram cross the initiator's trigger, and both sessions are
/// backdated past the responder's 30s rekey-acceptance gate.
/// Deterministic, no wall-clock wait and no clock read on the responder:
/// `rekey.after_messages = 1` makes the first sent datagram cross the
/// initiator's trigger, and the responder classifies the arrival from the rekey
/// marker the initiator declares in its msg3 negotiation TLV.
///
/// The message-count arm is what carries this test's value. It is the arm the
/// retired age-based discriminator got wrong, and suppressing the marker so the
/// responder falls back to the cross-connection path reds the post-cutover
/// assertion, which is what shows the test discriminates the two arms rather
/// than merely observing a session index change.
#[tokio::test]
async fn rekey_cutover_preserves_data_plane() {
// node 0 rekeys on the message counter; time never triggers it.
@@ -1173,18 +1392,10 @@ async fn rekey_cutover_preserves_data_plane() {
let idx_before = nodes[0].node.get_peer(&node1_addr).unwrap().our_index();
// Age both sessions past the responder's 30s rekey-acceptance gate so the
// rekey msg1 is treated as a rekey rather than a fresh connection.
nodes[0]
.node
.get_peer_mut(&node1_addr)
.unwrap()
.test_backdate_session_established(Duration::from_secs(31));
nodes[1]
.node
.get_peer_mut(&node0_addr)
.unwrap()
.test_backdate_session_established(Duration::from_secs(31));
// No session backdating here, unlike the master-branch original: on this
// line the responder classifies the arrival from the sender's msg3
// declaration and `establish_inbound` reads no clock at all, so aging the
// session past a 30s acceptance gate would age it past nothing.
// Drive the real rekey handshake (msg1/msg2/msg3 over loopback) to cutover.
for _ in 0..6 {
@@ -1466,15 +1677,14 @@ async fn test_tun_outbound_pending_queue_flush() {
// Unit tests: Session idle timeout
// ============================================================================
/// Helper: complete a Noise IK handshake and return the initiator's NoiseSession.
/// Helper: complete a Noise XX handshake and return the initiator's NoiseSession.
fn make_noise_session(
our_identity: &Identity,
remote_identity: &Identity,
) -> crate::noise::NoiseSession {
use crate::noise::HandshakeState;
let mut initiator =
HandshakeState::new_initiator(our_identity.keypair(), remote_identity.pubkey_full());
let mut initiator = HandshakeState::new_initiator(our_identity.keypair());
let mut responder = HandshakeState::new_responder(remote_identity.keypair());
// Set epochs for both sides (required for handshake message encryption)
@@ -1489,6 +1699,8 @@ fn make_noise_session(
responder.read_message_1(&msg1).unwrap();
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
let msg3 = initiator.write_message_3().unwrap();
responder.read_message_3(&msg3).unwrap();
initiator.into_session().unwrap()
}
@@ -1558,7 +1770,7 @@ fn test_purge_idle_sessions_ignores_initiating() {
let remote = Identity::generate();
let remote_addr = *remote.node_addr();
let handshake = HandshakeState::new_initiator(node.identity().keypair(), remote.pubkey_full());
let handshake = HandshakeState::new_initiator(node.identity().keypair());
let entry = crate::node::session::SessionEntry::new(
remote_addr,
remote.pubkey_full(),
@@ -1689,7 +1901,7 @@ fn test_coords_warmup_counter_default_zero_on_new() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let handshake = HandshakeState::new_initiator(identity_a.keypair());
let entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -1846,7 +2058,7 @@ fn test_session_entry_handshake_payload_storage() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let handshake = HandshakeState::new_initiator(identity_a.keypair());
let mut entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -1878,7 +2090,7 @@ fn test_session_entry_resend_tracking() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let handshake = HandshakeState::new_initiator(identity_a.keypair());
let mut entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -1909,7 +2121,7 @@ fn test_session_entry_clear_handshake_payload() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let handshake = HandshakeState::new_initiator(identity_a.keypair());
let mut entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -1940,8 +2152,7 @@ async fn test_session_handshake_timeout() {
let mut node = make_node();
let identity_b = Identity::generate();
let handshake =
HandshakeState::new_initiator(node.identity().keypair(), identity_b.pubkey_full());
let handshake = HandshakeState::new_initiator(node.identity().keypair());
let dest_addr = *identity_b.node_addr();
@@ -1985,7 +2196,7 @@ async fn test_session_awaiting_msg3_timeout() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_xk_responder(identity_b.keypair());
let handshake = HandshakeState::new_responder(identity_b.keypair());
let src_addr = *identity_a.node_addr();
@@ -2029,7 +2240,7 @@ async fn test_tun_outbound_path_mtu_generates_ptb() {
let src_fips = crate::FipsAddress::from_node_addr(&node0_addr);
let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr);
// Establish session (XK: 3 messages — Setup, Ack, Msg3)
// Establish session (XX: 3 messages — Setup, Ack, Msg3)
nodes[0]
.node
.initiate_session(node1_addr, node1_pubkey)
+132 -1
View File
@@ -79,7 +79,59 @@ pub(super) async fn make_test_node() -> TestNode {
/// mirroring UDP, so heterogeneous-MTU / PMTUD tests still exercise the
/// forward-path bottleneck.
pub(super) async fn make_test_node_with_mtu(mtu: u16) -> TestNode {
let mut node = make_node();
make_test_node_inner(Config::new(), mtu).await
}
/// Create a test node with a specific routing profile. Profile is immutable
/// (lives in the shared context), so it is set via the `Config` flags that
/// `Config::node_profile()` reads rather than poked post-construction.
pub(super) async fn make_test_node_with_profile(
profile: crate::proto::fmp::NodeProfile,
) -> TestNode {
use crate::proto::fmp::NodeProfile;
let mut config = Config::new();
match profile {
NodeProfile::Leaf => config.node.leaf_only = true,
NodeProfile::NonRouting => config.node.disable_routing = true,
NodeProfile::Full => {}
}
make_test_node_inner(config, 1280).await
}
/// Create a test node with a specific profile AND a caller-chosen identity, so
/// a test can pin which node holds the smallest NodeAddr (e.g. force a Leaf to
/// be the numerically smallest node and reproduce the root-election partition
/// deterministically).
pub(super) async fn make_test_node_with_profile_and_identity(
profile: crate::proto::fmp::NodeProfile,
identity: Identity,
) -> TestNode {
use crate::proto::fmp::NodeProfile;
let mut config = Config::new();
match profile {
NodeProfile::Leaf => config.node.leaf_only = true,
NodeProfile::NonRouting => config.node.disable_routing = true,
NodeProfile::Full => {}
}
make_test_node_inner_with_identity(config, 1280, Some(identity)).await
}
/// Shared builder: a test node from an explicit `Config` and transport MTU.
async fn make_test_node_inner(config: Config, mtu: u16) -> TestNode {
make_test_node_inner_with_identity(config, mtu, None).await
}
/// Shared builder with an optional caller-chosen identity. `None` generates a
/// fresh random identity (the default); `Some` pins it via `Node::with_identity`.
async fn make_test_node_inner_with_identity(
config: Config,
mtu: u16,
identity: Option<Identity>,
) -> TestNode {
let mut node = match identity {
Some(id) => Node::with_identity(id, config).expect("build node with identity"),
None => make_node_with(config),
};
let transport_id = TransportId::new(1);
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ReceivedPacket>();
@@ -303,6 +355,7 @@ pub(super) fn print_tree_snapshot(label: &str, nodes: &[TestNode]) {
pub(super) async fn process_available_packets(nodes: &mut [TestNode]) -> usize {
use crate::proto::fmp::wire::{
COMMON_PREFIX_SIZE, CommonPrefix, FMP_VERSION, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2,
PHASE_MSG3,
};
// Snapshot the number of packets queued at every node at the start of the
@@ -330,6 +383,7 @@ pub(super) async fn process_available_packets(nodes: &mut [TestNode]) -> usize {
match prefix.phase {
PHASE_MSG1 => node.node.handle_msg1(packet).await,
PHASE_MSG2 => node.node.handle_msg2(packet).await,
PHASE_MSG3 => node.node.handle_msg3(packet).await,
PHASE_ESTABLISHED => node.node.handle_encrypted_frame(packet).await,
_ => {}
}
@@ -776,6 +830,83 @@ pub(super) async fn run_tree_test(
nodes
}
/// Like `run_tree_test` but with a per-node `NodeProfile`.
///
/// `profiles` must have one entry per node. Builds each node with the
/// matching profile (Full / NonRouting / Leaf) over loopback, wires the
/// edges, drains to convergence and asserts every edge established a
/// bidirectional peer. Used by mixed-profile lifecycle tests where the
/// node roles, not the topology shape, are the subject.
pub(super) async fn run_tree_test_with_profiles(
profiles: &[crate::proto::fmp::NodeProfile],
edges: &[(usize, usize)],
verbose: bool,
) -> Vec<TestNode> {
let mut nodes = Vec::with_capacity(profiles.len());
for &profile in profiles {
nodes.push(make_test_node_with_profile(profile).await);
}
for &(i, j) in edges {
initiate_handshake(&mut nodes, i, j).await;
}
let total = drain_all_packets(&mut nodes, verbose).await;
assert!(total > 0, "Should have processed at least some packets");
repair_missing_edge_handshakes(&mut nodes, edges, verbose).await;
for &(i, j) in edges {
let j_addr = *nodes[j].node.node_addr();
let i_addr = *nodes[i].node.node_addr();
assert!(
nodes[i].node.get_peer(&j_addr).is_some(),
"Node {i} should have peer {j_addr} (node {j})"
);
assert!(
nodes[j].node.get_peer(&i_addr).is_some(),
"Node {j} should have peer {i_addr} (node {i})"
);
}
nodes
}
/// Like `run_tree_test_with_profiles`, but pins node identities so the node at
/// `leaf_idx` holds the strictly smallest NodeAddr in the mesh.
///
/// This makes the root-election partition deterministic: a Leaf (or any non-Full
/// node) that holds the smallest NodeAddr is the one that would self-elect as a
/// second root. Without the leaf gate the resulting mesh partitions; with it the
/// Leaf attaches under its Full upstream and the tree stays connected.
pub(super) async fn run_tree_test_with_profiles_leaf_smallest(
profiles: &[crate::proto::fmp::NodeProfile],
leaf_idx: usize,
edges: &[(usize, usize)],
) -> Vec<TestNode> {
// One identity per node; move the smallest-addr identity into the leaf slot.
let mut ids: Vec<Identity> = (0..profiles.len()).map(|_| Identity::generate()).collect();
let smallest = (0..ids.len())
.min_by(|&a, &b| ids[a].node_addr().cmp(ids[b].node_addr()))
.expect("non-empty");
ids.swap(leaf_idx, smallest);
assert!(
(0..ids.len()).all(|i| i == leaf_idx || ids[leaf_idx].node_addr() < ids[i].node_addr()),
"leaf must hold the unique smallest NodeAddr"
);
let mut nodes = Vec::with_capacity(profiles.len());
for (i, &profile) in profiles.iter().enumerate() {
nodes.push(make_test_node_with_profile_and_identity(profile, ids[i].clone()).await);
}
for &(i, j) in edges {
initiate_handshake(&mut nodes, i, j).await;
}
let total = drain_all_packets(&mut nodes, false).await;
assert!(total > 0, "Should have processed at least some packets");
repair_missing_edge_handshakes(&mut nodes, edges, false).await;
nodes
}
/// Like `run_tree_test` but with per-node transport MTUs.
///
/// `mtus` must have one entry per node. Used for heterogeneous-MTU tests
+2 -2
View File
@@ -308,7 +308,7 @@ async fn test_tcp_oriented_connect_success_reaches_handshake() {
// Kick off the non-blocking oriented connect.
nodes[0]
.node
.initiate_connection(transport_id, target_addr, target_identity)
.initiate_connection(transport_id, target_addr, Some(target_identity))
.await
.expect("initiate_connection should queue a pending connect");
@@ -364,7 +364,7 @@ async fn test_tcp_oriented_connect_failure_tears_down_link() {
nodes[0]
.node
.initiate_connection(transport_id, target_addr, target_identity)
.initiate_connection(transport_id, target_addr, Some(target_identity))
.await
.expect("initiate_connection is non-blocking; it queues a pending connect");
+270 -220
View File
@@ -862,13 +862,17 @@ fn test_promote_cleans_up_pending_outbound_to_same_peer() {
let mut resp_epoch = [0u8; 8];
rand::Rng::fill_bytes(&mut rand::rng(), &mut resp_epoch);
let msg2 = resp_conn
.receive_handshake_init(peer_keypair, resp_epoch, &msg1, completing_time_ms)
.receive_handshake_init(peer_keypair, resp_epoch, &msg1, None, completing_time_ms)
.unwrap();
node.peer_machines
let (msg3, _neg) = node
.peer_machines
.get_mut(&completing_link_id)
.unwrap()
.complete_handshake(&msg2, completing_time_ms)
.complete_handshake(&msg2, None, completing_time_ms)
.unwrap();
resp_conn
.complete_handshake_msg3(&msg3, completing_time_ms)
.unwrap();
// Now 2 connections, 1 link (pending has link, completing doesn't yet need one for this test)
@@ -2157,225 +2161,249 @@ fn nostr_rendezvous_outbound_admission_atomic_roundtrip() {
);
}
/// Sender-side helper: build a wire-format Msg1 from a fresh peer
/// identity targeting `node_b`, *and* send it on the wire over `socket_a`
/// to `addr_b`. Returns the sender's NodeAddr so the test can assert on
/// identity-keyed maps.
/// Two-node UDP scaffold for driving a full Noise XX handshake
/// (Msg1 -> Msg2 -> Msg3) from `node_a` (initiator) into `node_b`
/// (responder, the unit under test). Mirrors `test_two_node_handshake_udp`
/// in `handshake.rs`, condensed for the cap-check tests.
///
/// Uses the same outbound-machine->Noise IK pattern as the
/// integration handshake tests, but inlined and unit-scoped.
async fn craft_and_send_msg1(
node_b: &Node,
sender_identity: &Identity,
socket_a: &tokio::net::UdpSocket,
/// `node_a` and `node_b` must already have a UDP transport under
/// `TransportId::new(1)`; the test passes in the bound local addrs and the
/// two receive channels. After this returns, `node_b` has processed the
/// initiator's Msg3 and the cap-enforcement decision has been made. Returns
/// the initiator's `NodeAddr`.
///
/// Critically for the silent-drop test: `node_a` is the only initiator —
/// `node_b` never dials `node_a`, so there is no pending-outbound connection
/// to bypass the cap. The inbound peer is therefore truly unsolicited.
async fn drive_xx_handshake(
node_a: &mut Node,
node_b: &mut Node,
transport_id: TransportId,
addr_b: std::net::SocketAddr,
timestamp_ms: u64,
packet_rx_a: &mut crate::transport::PacketRx,
packet_rx_b: &mut crate::transport::PacketRx,
) -> NodeAddr {
use crate::proto::fmp::wire::build_msg1;
use crate::utils::index::SessionIndex;
use tokio::time::{Duration, timeout};
let remote_addr_b = TransportAddr::from_string(&addr_b.to_string());
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full());
let sender_pubkey_id = PeerIdentity::from_pubkey_full(sender_identity.pubkey_full());
let sender_node_addr = *sender_pubkey_id.node_addr();
let link_id = LinkId::new(0xDEAD_BEEF);
let mut conn = outbound_leg(link_id, peer_b_identity, timestamp_ms);
// node_a initiates the outbound handshake.
let link_id_a = node_a.allocate_link_id();
let our_index_a = node_a.index_allocator.allocate().unwrap();
// Mirror the production dial path: the seam seeds the outbound leg's
// control machine, which owns the handshake crypto.
node_a
.seed_handshake_machine(
HandshakeSeed::outbound(link_id_a, peer_b_identity, 1000)
.with_our_index(our_index_a)
.with_transport_id(transport_id)
.with_source_addr(remote_addr_b.clone()),
)
.unwrap();
let our_keypair_a = node_a.identity().keypair();
let startup_epoch_a = node_a.startup_epoch();
let noise_msg1 = node_a
.peer_machines
.get_mut(&link_id_a)
.unwrap()
.start_handshake(our_keypair_a, startup_epoch_a, 1000)
.unwrap();
let wire_msg1 = build_msg1(our_index_a, &noise_msg1);
let sender_keypair = sender_identity.keypair();
let mut startup_epoch = [0u8; 8];
rand::Rng::fill_bytes(&mut rand::rng(), &mut startup_epoch);
let noise_msg1 = conn
.start_handshake(sender_keypair, startup_epoch, timestamp_ms)
.expect("start_handshake should produce noise msg1");
let link_a = Link::connectionless(
link_id_a,
transport_id,
remote_addr_b.clone(),
LinkDirection::Outbound,
Duration::from_millis(100),
);
node_a.links.insert(link_id_a, link_a);
node_a
.pending_outbound
.insert((transport_id, our_index_a.as_u32()), link_id_a);
let sender_index = SessionIndex::new(0x5151);
let wire_msg1 = build_msg1(sender_index, &noise_msg1);
socket_a
.send_to(&wire_msg1, addr_b)
let transport = node_a.transports.get(&transport_id).unwrap();
transport
.send(&remote_addr_b, &wire_msg1)
.await
.expect("sender_socket.send_to");
sender_node_addr
.expect("Failed to send msg1");
// node_b receives Msg1, replies Msg2 (XX: no promotion yet).
let packet_b = timeout(Duration::from_secs(1), packet_rx_b.recv())
.await
.expect("Timeout waiting for msg1")
.expect("Channel closed");
node_b.handle_msg1(packet_b).await;
// node_a receives Msg2, sends Msg3, promotes locally.
let packet_a = timeout(Duration::from_secs(1), packet_rx_a.recv())
.await
.expect("Timeout waiting for msg2")
.expect("Channel closed");
node_a.handle_msg2(packet_a).await;
// node_b receives Msg3 — the cap-enforcement decision happens here.
let packet_b_msg3 = timeout(Duration::from_secs(1), packet_rx_b.recv())
.await
.expect("Timeout waiting for msg3")
.expect("Channel closed");
node_b.handle_msg3(packet_b_msg3).await;
*PeerIdentity::from_pubkey_full(node_a.identity().pubkey_full()).node_addr()
}
/// Helper: deliver a packet from `node`'s registered UDP transport to
/// `node.handle_msg1`. Returns Ok(()) on success or Err if the packet
/// was not received within `timeout`.
async fn pump_one_msg1_into_node(
/// Register a UDP transport on `node` under `TransportId::new(1)`, returning
/// the bound local address and the packet receive channel.
async fn register_udp_transport(
node: &mut Node,
packet_rx: &mut crate::transport::PacketRx,
timeout_ms: u64,
) -> Result<(), &'static str> {
use tokio::time::{Duration, timeout};
let packet = timeout(Duration::from_millis(timeout_ms), packet_rx.recv())
.await
.map_err(|_| "timed out waiting for msg1 on packet_rx")?
.ok_or("packet channel closed")?;
node.handle_msg1(packet).await;
Ok(())
}
/// Verifies the early max_peers cap check in `handle_msg1` silent-drops
/// a Msg1 from a brand-new identity at saturation: no peer is admitted,
/// no Msg2 response goes back on the wire, and the msg1 rate-limiter
/// pending_count returns to baseline.
///
/// Wire-observable Msg2 absence is the load-bearing discriminator. With
/// the early cap gate removed (stash-verify), the late gate inside
/// `promote_connection` still rejects the new identity — but only
/// *after* `handle_msg1` has already built the Msg2 frame and
/// `transport.send(...wire_msg2)` has put it on the wire. The
/// post-call wire-side poll catches that Msg2 (FAIL pre-fix; the
/// silent timeout is the PASS post-fix).
#[tokio::test]
async fn handle_msg1_silent_drops_at_cap_for_new_peer() {
) -> (std::net::SocketAddr, crate::transport::PacketRx) {
use crate::config::UdpConfig;
use tokio::time::{Duration, timeout};
let mut node = make_node_with_max_peers(2);
inject_dummy_peers(&mut node, 2);
assert_eq!(node.peer_count(), 2, "precondition: at cap");
// === UDP transport setup for node_b (the unit under test) ===
let transport_id_b = TransportId::new(1);
let transport_id = TransportId::new(1);
let udp_config = UdpConfig {
bind_addr: Some("127.0.0.1:0".to_string()),
mtu: Some(1280),
..Default::default()
};
let (packet_tx_b, mut packet_rx_b) = packet_channel(64);
let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
transport_b.start_async().await.unwrap();
let addr_b = transport_b.local_addr().unwrap();
let (packet_tx, packet_rx) = packet_channel(64);
let mut transport = UdpTransport::new(transport_id, None, udp_config, packet_tx);
transport.start_async().await.unwrap();
let addr = transport.local_addr().unwrap();
node.transports
.insert(transport_id_b, TransportHandle::Udp(transport_b));
.insert(transport_id, TransportHandle::Udp(transport));
(addr, packet_rx)
}
// === Sender-side socket ===
let socket_a = tokio::net::UdpSocket::bind("127.0.0.1:0")
.await
.expect("bind sender socket");
// The inbound max_peers cap on the XX FMP handshake is enforced solely by
// the late `promote_connection` check (the early `handle_msg3` gate was
// removed: on XX, identity isn't known until Msg3, by which point all three
// messages have crossed the wire, so an early gate saved no bytes and
// duplicated the late check's peer set). These two tests assert the
// path-agnostic invariant — no promotion over cap, peer count unchanged, no
// index-allocator/connection leak — driven through the full XX three-message
// pump. They do NOT assert the IK "no Msg2 on the wire" discriminator
// (false on XX: Msg2 is the responder ephemeral, sent before identity is
// known), nor the IK rate-limiter rebalance (the msg3 cap-reject path does
// not touch `msg1_rate_limiter`).
#[tokio::test]
async fn handle_msg3_silent_drops_at_cap_for_new_peer() {
let transport_id = TransportId::new(1);
let before_peers = node.peer_count();
let before_pending = node.msg1_rate_limiter.pending_count();
// node_b is the cap'd unit under test: max_peers=1, already at cap.
let mut node_b = make_node_with_max_peers(1);
inject_dummy_peers(&mut node_b, 1);
assert_eq!(node_b.peer_count(), 1, "precondition: node_b at cap");
// Fresh sender identity — never seen by `node`.
let sender = Identity::generate();
let sender_node_addr = craft_and_send_msg1(&node, &sender, &socket_a, addr_b, 1000).await;
// node_a is a truly-unsolicited inbound initiator. node_b never dials
// node_a, so `is_pending_outbound` is false and the cap (not the
// cross-connection path) governs.
let mut node_a = make_node();
// Sanity: new identity is not currently a peer.
assert!(
!node.peers.contains_key(&sender_node_addr),
"precondition: new sender not yet a peer"
);
let (addr_b, mut packet_rx_b) = register_udp_transport(&mut node_b).await;
let (_addr_a, mut packet_rx_a) = register_udp_transport(&mut node_a).await;
// Pump the wire-arrived Msg1 into the node's handler.
pump_one_msg1_into_node(&mut node, &mut packet_rx_b, 1000)
.await
.expect("msg1 must reach packet_rx_b");
// Snapshot node_b resource state before the over-cap handshake.
let before_peers = node_b.peer_count();
let before_connections = node_b.connection_count();
let before_links = node_b.link_count();
let before_index_count = node_b.index_allocator.count();
// Post-call state checks.
let peer_a_node_addr = drive_xx_handshake(
&mut node_a,
&mut node_b,
transport_id,
addr_b,
&mut packet_rx_a,
&mut packet_rx_b,
)
.await;
// Path-agnostic invariant: no promotion over cap.
assert_eq!(
node.peer_count(),
node_b.peer_count(),
before_peers,
"early cap gate must not adopt a new peer at saturation"
"cap'd node must not promote a net-new peer over max_peers"
);
assert!(
!node.peers.contains_key(&sender_node_addr),
"new sender must not appear in peers map"
);
assert_eq!(
node.msg1_rate_limiter.pending_count(),
before_pending,
"rate limiter must rebalance: start_handshake() then \
complete_handshake() before silent-drop return"
!node_b.peers.contains_key(&peer_a_node_addr),
"denied peer must not appear in node_b's peers map"
);
// Wire-observable discriminator: with the early gate in place, no
// Msg2 should come back. With the gate removed, Msg2 IS sent
// before promote_connection rejects.
let mut buf = [0u8; 2048];
let recv = timeout(Duration::from_millis(300), socket_a.recv_from(&mut buf)).await;
let received_bytes = recv.ok().and_then(|inner| inner.ok()).map(|(n, _)| n);
assert!(
received_bytes.is_none(),
"Msg2 must NOT be sent in response when at max_peers cap; \
observed {received_bytes:?} wire bytes the fingerprint of \
the late-gate path replying with Msg2 before rejecting"
// Resource non-leak: the rejected handshake's connection, link, and
// allocated index must all be released, returning node_b to its
// pre-handshake bookkeeping baseline.
assert_eq!(
node_b.connection_count(),
before_connections,
"rejected handshake's pending connection must be cleaned up"
);
assert_eq!(
node_b.link_count(),
before_links,
"rejected handshake's link must be removed"
);
assert_eq!(
node_b.index_allocator.count(),
before_index_count,
"rejected handshake's allocated session index must be freed"
);
}
/// Verifies the bypass: at saturation, an inbound Msg1 from an
/// *existing* peer's identity is not silent-dropped by the early cap
/// check (the gate would otherwise wedge legitimate
/// reconnect/restart/rekey traffic against an at-cap node).
///
/// The cap-gate's `is_known_active = self.peers.contains_key(&peer_node_addr)`
/// branch admits this case; the downstream handling (restart-detect or
/// duplicate-msg1 resend) then runs per existing semantics. The
/// observable assertion here is the existing peer's continued
/// presence — the rate-limiter rebalance is the same in
/// bypass-admit and silent-drop, so this test isn't a discriminator
/// against the no-gate (stash) build; it's a regression check that the
/// gate doesn't accidentally evict known peers.
/// Regression guard: a full XX handshake from a KNOWN-identity peer at cap
/// must be admitted (reconnect/cross-connection bypass), not evicted. The
/// existing peer's key must remain present and the peer count must hold.
#[tokio::test]
async fn handle_msg1_admits_existing_peer_at_cap() {
use crate::config::UdpConfig;
async fn handle_msg3_admits_existing_peer_at_cap() {
let transport_id = TransportId::new(1);
let mut node = make_node_with_max_peers(2);
// node_a is the initiator whose identity we pre-insert as node_b's
// existing peer, so node_b is at cap WITH the incoming peer already known.
let mut node_a = make_node();
let mut node_b = make_node_with_max_peers(1);
inject_dummy_peers(&mut node, 1);
let existing_sender = Identity::generate();
let existing_pid = PeerIdentity::from_pubkey_full(existing_sender.pubkey_full());
let existing_pid = PeerIdentity::from_pubkey_full(node_a.identity().pubkey_full());
let existing_node_addr = *existing_pid.node_addr();
let existing_link_id = LinkId::new(7777);
{
use crate::peer::ActivePeer;
let peer = ActivePeer::new(existing_pid, existing_link_id, 0);
node.peers.insert(existing_node_addr, peer);
let peer = ActivePeer::new(existing_pid, LinkId::new(7777), 0);
node_b.peers.insert(existing_node_addr, peer);
}
assert_eq!(node.peer_count(), 2, "precondition: at cap");
let transport_id_b = TransportId::new(1);
let udp_config = UdpConfig {
bind_addr: Some("127.0.0.1:0".to_string()),
mtu: Some(1280),
..Default::default()
};
let (packet_tx_b, mut packet_rx_b) = packet_channel(64);
let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
transport_b.start_async().await.unwrap();
let addr_b = transport_b.local_addr().unwrap();
node.transports
.insert(transport_id_b, TransportHandle::Udp(transport_b));
let socket_a = tokio::net::UdpSocket::bind("127.0.0.1:0")
.await
.expect("bind sender socket");
let before_pending = node.msg1_rate_limiter.pending_count();
let sender_node_addr =
craft_and_send_msg1(&node, &existing_sender, &socket_a, addr_b, 2000).await;
assert_eq!(
sender_node_addr, existing_node_addr,
"sanity: crafted msg1 carries the existing peer's NodeAddr"
node_b.peer_count(),
1,
"precondition: node_b at cap with node_a already known"
);
pump_one_msg1_into_node(&mut node, &mut packet_rx_b, 1000)
.await
.expect("msg1 must reach packet_rx_b");
let (addr_b, mut packet_rx_b) = register_udp_transport(&mut node_b).await;
let (_addr_a, mut packet_rx_a) = register_udp_transport(&mut node_a).await;
// Bypass must not evict the existing peer or grow peer count.
assert_eq!(node.peer_count(), 2, "peer count unchanged");
let peer_a_node_addr = drive_xx_handshake(
&mut node_a,
&mut node_b,
transport_id,
addr_b,
&mut packet_rx_a,
&mut packet_rx_b,
)
.await;
assert_eq!(
peer_a_node_addr, existing_node_addr,
"sanity: initiator carries the existing peer's NodeAddr"
);
// The known peer is admitted (session replaced cleanly), not evicted,
// and the cap is not breached.
assert_eq!(
node_b.peer_count(),
1,
"peer count must hold at cap (known peer admitted, not double-counted)"
);
assert!(
node.peers.contains_key(&existing_node_addr),
"existing peer must still be present after bypass-admitted msg1"
);
assert_eq!(
node.msg1_rate_limiter.pending_count(),
before_pending,
"rate limiter must rebalance after the (bypass-admitted) handler returns"
node_b.peers.contains_key(&existing_node_addr),
"existing peer must still be present after a known-peer reconnect"
);
}
@@ -2620,61 +2648,83 @@ fn handshake_presence_tracks_the_carrier_not_the_noise_handles() {
);
}
/// The identity a responder discovers in msg1 must land on the surviving
/// carrier, not only on the pending leg. Everything that names an inbound
/// peer mid-handshake reads the carrier: the stale-connection sweep's
/// `retry_addr` decides whether a reaped leg is retried or torn down, and a
/// blank identity there silently changes that choreography.
/// The identity a handshake learns must land on the surviving carrier — the
/// machine's own `ConnectionState` — and not stay locked inside the Noise
/// handle. Every reader that names a peer mid-handshake goes to the carrier:
/// the promotion hand-off in `handle_msg2`/`handle_msg3` reads
/// `conn_expected_identity` to decide who is being admitted, and the
/// stale-connection sweep reads it to decide whether a reaped leg is retried
/// or torn down. A blank or stale value there silently changes both.
///
/// Noise XX carries no static key in msg1, so there is no identity to record
/// at that step. Each role learns its peer exactly once, at a different
/// message: the initiator learns the responder from msg2 (`complete_handshake`)
/// and the responder learns the initiator from msg3
/// (`complete_handshake_msg3`). Both writes are asserted here.
///
/// The wrong-on-purpose expectation the initiator starts from is the whole
/// point of this test and must not be "simplified" to the real responder.
/// The full-flow test alongside it checks the same values but seeds the
/// dialing side with the true responder identity, so dropping the msg2 write
/// leaves the correct value sitting there pre-seeded and that test still
/// passes. Only a starting value that is deliberately wrong can tell a real
/// write apart from a stale one.
#[test]
fn inbound_msg1_records_the_learned_identity_on_the_carrier() {
use crate::proto::fmp::LifecycleView;
fn xx_handshake_records_each_learned_identity_on_the_carrier() {
let initiator = Identity::generate();
let responder = Identity::generate();
let initiator_identity = PeerIdentity::from_pubkey_full(initiator.pubkey_full());
let responder_identity = PeerIdentity::from_pubkey_full(responder.pubkey_full());
let mut node = make_node();
let link_id = LinkId::new(77);
// The dial-time expectation is a third party: whoever actually answers
// overwrites it here. The dial intent is not lost with it — it lives on in
// the carrier's separate write-once `dialed_identity`, which `handle_msg2`
// holds the learned key up against before promoting. That comparison is the
// caller's; this crypto leaf performs the write regardless, which is what
// is asserted below.
let decoy = PeerIdentity::from_pubkey_full(Identity::generate().pubkey_full());
assert_ne!(decoy, responder_identity);
// A genuine IK msg1 addressed to this node, from a known sender.
let sender = Identity::generate();
let sender_identity = PeerIdentity::from_pubkey_full(sender.pubkey_full());
let node_identity = PeerIdentity::from_pubkey_full(node.identity().pubkey_full());
let initiator_link = LinkId::new(78);
let mut initiator =
crate::peer::machine::PeerMachine::new_outbound(initiator_link, node_identity, 1000);
initiator.set_leg(crate::peer::machine::HandshakeCrypto::new());
let noise_msg1 = initiator
.start_handshake(sender.keypair(), [9u8; 8], 1000)
.unwrap();
// Drive the responder half over an inbound leg that stays pending.
node.seed_handshake_machine(HandshakeSeed::inbound(link_id, 1000))
.unwrap();
let our_keypair = node.identity().keypair();
let startup_epoch = node.startup_epoch();
let machine = node.peer_machines.get_mut(&link_id).unwrap();
machine
.receive_handshake_init(our_keypair, startup_epoch, &noise_msg1, 1000)
let mut conn_i = outbound_leg(LinkId::new(1), decoy, 1000);
let mut conn_r = inbound_leg(LinkId::new(2), 1000);
let noise_msg1 = conn_i
.start_handshake(initiator.keypair(), [0x11; 8], 1000)
.unwrap();
let noise_msg2 = conn_r
.receive_handshake_init(responder.keypair(), [0x22; 8], &noise_msg1, None, 1100)
.unwrap();
// The responder is still anonymous: msg1 revealed nothing about who dialed.
assert_eq!(
machine.conn_expected_identity(),
Some(&sender_identity),
"msg1 identity learn must be recorded on the surviving carrier"
conn_r.conn_expected_identity(),
None,
"XX msg1 is ephemeral-only, so the responder learns no identity there"
);
// The send of the responder's msg2 fails: the leg is retained, empty, for
// the sweep to reclaim.
machine.mark_failed();
machine.mark_send_failed();
let stale = node.stale_connections(2000, 30_000);
let (noise_msg3, _) = conn_i.complete_handshake(&noise_msg2, None, 1200).unwrap();
assert_eq!(
stale.len(),
1,
"the failed inbound leg must reach the sweep"
conn_i.conn_expected_identity(),
Some(&responder_identity),
"the identity learned from msg2 must be recorded on the surviving carrier"
);
assert_eq!(
stale[0].retry_addr,
Some(*sender_identity.node_addr()),
"a failed inbound leg still names the peer it learned from msg1"
conn_i.conn_dialed_identity(),
Some(&decoy),
"the dial intent must survive the msg2 write, or there is nothing left \
to compare the learned identity against"
);
assert_eq!(
conn_r.conn_dialed_identity(),
None,
"an inbound leg dialed nobody"
);
conn_r.complete_handshake_msg3(&noise_msg3, 1300).unwrap();
assert_eq!(
conn_r.conn_expected_identity(),
Some(&initiator_identity),
"the identity learned from msg3 must be recorded on the surviving carrier"
);
}
@@ -2732,7 +2782,7 @@ async fn test_failed_msg1_preparation_unwinds_the_dial_machine() {
// Stand in for the dial: the machine exists before msg1 is prepared.
node.peer_machines.insert(
link_id,
PeerMachine::new_outbound(link_id, peer_identity, 1000),
PeerMachine::new_outbound(link_id, Some(peer_identity), 1000),
);
// Force the index allocation inside msg1 preparation to fail.
node.index_allocator = crate::utils::index::IndexAllocator::with_max_attempts(0);
@@ -2767,7 +2817,7 @@ async fn test_machine_carries_link_direction_and_address_on_dial_and_inbound() {
let peer_identity = make_peer_identity();
node.peer_machines.insert(
link_id,
PeerMachine::new_outbound(link_id, peer_identity, 1000),
PeerMachine::new_outbound(link_id, Some(peer_identity), 1000),
);
node.prepare_outbound_msg1(link_id, transport_id, &remote_addr, peer_identity)
.unwrap();
+8 -8
View File
@@ -3,7 +3,7 @@
//! Handles building, sending, and receiving TreeAnnounce messages,
//! including periodic root refresh and rate-limited propagation.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use secp256k1::XOnlyPublicKey;
use secp256k1::schnorr::Signature;
@@ -79,11 +79,15 @@ impl Node {
/// Send a TreeAnnounce to a specific peer, respecting rate limits.
///
/// If the peer is rate-limited, the announce is marked pending for
/// delivery on the next tick cycle.
/// delivery on the next tick cycle. Leaf nodes do not send tree
/// announces (they don't participate in the spanning tree).
pub(super) async fn send_tree_announce_to_peer(
&mut self,
peer_addr: &NodeAddr,
) -> Result<(), NodeError> {
if self.node_profile() == crate::proto::fmp::NodeProfile::Leaf {
return Ok(());
}
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
@@ -321,9 +325,7 @@ impl Node {
.filter(|(_, peer)| peer.has_srtt())
.map(|(addr, peer)| (*addr, peer.link_cost()))
.collect();
// No peers are excluded from parent candidacy on this branch; the
// non-full/leaf skip is a next-only shell refinement.
let skip: BTreeSet<NodeAddr> = BTreeSet::new();
let skip = self.non_full_peers();
// Monotonic ms for the flap-dampening / hold-down timers (distinct from
// the wall-clock `now_ms` above used for the peer's tree position). Read
@@ -583,9 +585,7 @@ impl Node {
.filter(|(_, peer)| peer.has_srtt())
.map(|(addr, peer)| (*addr, peer.link_cost()))
.collect();
// No peers are excluded from parent candidacy on this branch; the
// non-full/leaf skip is a next-only shell refinement.
let skip: BTreeSet<NodeAddr> = BTreeSet::new();
let skip = self.non_full_peers();
// Monotonic ms for the flap-dampening / hold-down timers, read once and
// threaded into classify + the state mutators.
+85 -376
View File
@@ -1,8 +1,7 @@
use super::{
CipherState, EPOCH_ENCRYPTED_SIZE, EPOCH_SIZE, HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE,
HandshakeProgress, HandshakeRole, NoiseError, NoisePattern, NoiseSession, PROTOCOL_NAME_IK,
PROTOCOL_NAME_XK, PUBKEY_SIZE, XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE,
XK_HANDSHAKE_MSG3_SIZE,
HANDSHAKE_MSG3_SIZE, HandshakeProgress, HandshakeRole, NoiseError, NoisePattern, NoiseSession,
PROTOCOL_NAME_XX, PUBKEY_SIZE,
};
use hkdf::Hkdf;
use rand::Rng;
@@ -102,7 +101,7 @@ impl SymmetricState {
}
}
/// Handshake state for Noise IK and XK patterns.
/// Handshake state for the Noise XX pattern.
pub struct HandshakeState {
/// Which Noise pattern is being used.
pattern: NoisePattern,
@@ -117,10 +116,8 @@ pub struct HandshakeState {
/// Our ephemeral keypair (generated at handshake start).
ephemeral_keypair: Option<Keypair>,
/// Remote static public key.
/// For IK initiator: known before handshake (from config).
/// For IK responder: learned from message 1.
/// For XK initiator: known before handshake (from config).
/// For XK responder: learned from message 3.
/// For XX initiator: learned from message 2.
/// For XX responder: learned from message 3.
remote_static: Option<PublicKey>,
/// Remote ephemeral public key (learned during handshake).
remote_ephemeral: Option<PublicKey>,
@@ -133,131 +130,46 @@ pub struct HandshakeState {
}
impl HandshakeState {
/// Normalize a compressed public key to even parity for pre-message hashing.
/// Create a new XX handshake as initiator.
///
/// Nostr npubs encode x-only keys (no parity). The Noise IK pre-message
/// mixes the responder's static key into the hash before any messages.
/// Both sides must mix identical bytes. Since the initiator may only have
/// the x-only key (from an npub), we normalize to even parity (0x02 prefix)
/// so the hash chain matches regardless of the key's actual parity.
///
/// This does NOT affect ECDH operations (which use x-coordinate-only output)
/// or the keys sent in handshake messages (which use actual parity).
fn normalize_for_premessage(pubkey: &PublicKey) -> [u8; PUBKEY_SIZE] {
let mut bytes = pubkey.serialize();
bytes[0] = 0x02; // Force even parity
bytes
}
/// Create a new IK handshake as initiator.
///
/// The initiator knows the responder's static key and will send first.
/// Used by FMP (link layer).
pub fn new_initiator(static_keypair: Keypair, remote_static: PublicKey) -> Self {
/// XX: neither side knows the other's static key. No pre-message.
pub fn new_initiator(static_keypair: Keypair) -> Self {
let secp = Secp256k1::new();
let mut state = Self {
pattern: NoisePattern::Ik,
Self {
pattern: NoisePattern::Xx,
role: HandshakeRole::Initiator,
progress: HandshakeProgress::Initial,
symmetric: SymmetricState::initialize(PROTOCOL_NAME_IK),
symmetric: SymmetricState::initialize(PROTOCOL_NAME_XX),
static_keypair,
ephemeral_keypair: None,
remote_static: Some(remote_static),
remote_static: None,
remote_ephemeral: None,
secp,
local_epoch: None,
remote_epoch: None,
};
// Mix in pre-message: <- s (responder's static is known)
// Normalize to even parity so initiator and responder hash chains match
// even when the initiator only has the x-only key (from npub).
let normalized = Self::normalize_for_premessage(&remote_static);
state.symmetric.mix_hash(&normalized);
state
}
// No pre-message: neither side's static is mixed into hash.
}
/// Create a new IK handshake as responder.
/// Create a new XX handshake as responder.
///
/// The responder does NOT know the initiator's static key - it will be
/// learned from message 1. Used by FMP (link layer).
/// XX: neither side knows the other's static key. No pre-message.
pub fn new_responder(static_keypair: Keypair) -> Self {
let secp = Secp256k1::new();
let mut state = Self {
pattern: NoisePattern::Ik,
Self {
pattern: NoisePattern::Xx,
role: HandshakeRole::Responder,
progress: HandshakeProgress::Initial,
symmetric: SymmetricState::initialize(PROTOCOL_NAME_IK),
symmetric: SymmetricState::initialize(PROTOCOL_NAME_XX),
static_keypair,
ephemeral_keypair: None,
remote_static: None, // Will learn from message 1
remote_static: None,
remote_ephemeral: None,
secp,
local_epoch: None,
remote_epoch: None,
};
// Mix in pre-message: <- s (our static, since we're responder)
// Normalize to even parity to match initiator's hash chain.
let normalized = Self::normalize_for_premessage(&state.static_keypair.public_key());
state.symmetric.mix_hash(&normalized);
state
}
/// Create a new XK handshake as initiator.
///
/// The initiator knows the responder's static key. XK defers the
/// initiator's static key reveal to msg3. Used by FSP (session layer).
pub fn new_xk_initiator(static_keypair: Keypair, remote_static: PublicKey) -> Self {
let secp = Secp256k1::new();
let mut state = Self {
pattern: NoisePattern::Xk,
role: HandshakeRole::Initiator,
progress: HandshakeProgress::Initial,
symmetric: SymmetricState::initialize(PROTOCOL_NAME_XK),
static_keypair,
ephemeral_keypair: None,
remote_static: Some(remote_static),
remote_ephemeral: None,
secp,
local_epoch: None,
remote_epoch: None,
};
// Mix in pre-message: <- s (responder's static is known)
let normalized = Self::normalize_for_premessage(&remote_static);
state.symmetric.mix_hash(&normalized);
state
}
/// Create a new XK handshake as responder.
///
/// The responder does NOT know the initiator's static key - it will be
/// learned from message 3. Used by FSP (session layer).
pub fn new_xk_responder(static_keypair: Keypair) -> Self {
let secp = Secp256k1::new();
let mut state = Self {
pattern: NoisePattern::Xk,
role: HandshakeRole::Responder,
progress: HandshakeProgress::Initial,
symmetric: SymmetricState::initialize(PROTOCOL_NAME_XK),
static_keypair,
ephemeral_keypair: None,
remote_static: None, // Will learn from message 3
remote_ephemeral: None,
secp,
local_epoch: None,
remote_epoch: None,
};
// Mix in pre-message: <- s (our static, since we're responder)
let normalized = Self::normalize_for_premessage(&state.static_keypair.public_key());
state.symmetric.mix_hash(&normalized);
state
}
// No pre-message: neither side's static is mixed into hash.
}
/// Get our role.
@@ -275,7 +187,7 @@ impl HandshakeState {
self.progress == HandshakeProgress::Complete
}
/// Get the remote static key (available after message 1 for responder).
/// Get the remote static key (available after message 2 for initiator, message 3 for responder).
pub fn remote_static(&self) -> Option<&PublicKey> {
self.remote_static.as_ref()
}
@@ -323,12 +235,11 @@ impl HandshakeState {
/// Write message 1 (initiator only).
///
/// Message 1 contains:
/// XX msg1: `-> e`
/// - e: ephemeral public key (33 bytes)
/// - encrypted s: our static public key encrypted (33 + 16 = 49 bytes)
/// - encrypted epoch: startup epoch for restart detection (8 + 16 = 24 bytes)
/// - No DH operations (responder's static is unknown)
///
/// Total: 106 bytes
/// Total: 33 bytes
pub fn write_message_1(&mut self) -> Result<Vec<u8>, NoiseError> {
if self.role != HandshakeRole::Initiator {
return Err(NoiseError::WrongState {
@@ -343,13 +254,6 @@ impl HandshakeState {
});
}
let remote_static = self
.remote_static
.expect("initiator must have remote static");
let epoch = self
.local_epoch
.expect("local epoch must be set before write_message_1");
// Generate ephemeral keypair
self.generate_ephemeral();
let ephemeral = self.ephemeral_keypair.as_ref().unwrap();
@@ -361,23 +265,7 @@ impl HandshakeState {
message.extend_from_slice(&e_pub);
self.symmetric.mix_hash(&e_pub);
// -> es: DH(e, rs), mix into key
let es = self.ecdh(&ephemeral.secret_key(), &remote_static);
self.symmetric.mix_key(&es);
// -> s: encrypt our static and send
let our_static = self.static_keypair.public_key().serialize();
let encrypted_static = self.symmetric.encrypt_and_hash(&our_static)?;
message.extend_from_slice(&encrypted_static);
// -> ss: DH(s, rs), mix into key
let ss = self.ecdh(&self.static_keypair.secret_key(), &remote_static);
self.symmetric.mix_key(&ss);
// -> epoch: encrypt startup epoch for restart detection
let encrypted_epoch = self.symmetric.encrypt_and_hash(&epoch)?;
debug_assert_eq!(encrypted_epoch.len(), EPOCH_ENCRYPTED_SIZE);
message.extend_from_slice(&encrypted_epoch);
// No DH here — responder's static is unknown in XX
self.progress = HandshakeProgress::Message1Done;
@@ -386,7 +274,7 @@ impl HandshakeState {
/// Read message 1 (responder only).
///
/// Processes the initiator's first message and learns their identity and epoch.
/// Parses the initiator's ephemeral key. No identity learned.
pub fn read_message_1(&mut self, message: &[u8]) -> Result<(), NoiseError> {
if self.role != HandshakeRole::Responder {
return Err(NoiseError::WrongState {
@@ -413,31 +301,7 @@ impl HandshakeState {
self.remote_ephemeral = Some(re);
self.symmetric.mix_hash(&message[..PUBKEY_SIZE]);
// -> es: DH(s, re), mix into key
// (responder uses their static with initiator's ephemeral)
let es = self.ecdh(&self.static_keypair.secret_key(), &re);
self.symmetric.mix_key(&es);
// -> s: decrypt initiator's static
let encrypted_static_end = PUBKEY_SIZE + PUBKEY_SIZE + super::TAG_SIZE;
let encrypted_static = &message[PUBKEY_SIZE..encrypted_static_end];
let decrypted_static = self.symmetric.decrypt_and_hash(encrypted_static)?;
let rs =
PublicKey::from_slice(&decrypted_static).map_err(|_| NoiseError::InvalidPublicKey)?;
self.remote_static = Some(rs);
// -> ss: DH(s, rs), mix into key
let ss = self.ecdh(&self.static_keypair.secret_key(), &rs);
self.symmetric.mix_key(&ss);
// -> epoch: decrypt initiator's startup epoch
let encrypted_epoch = &message[encrypted_static_end..];
debug_assert_eq!(encrypted_epoch.len(), EPOCH_ENCRYPTED_SIZE);
let decrypted_epoch = self.symmetric.decrypt_and_hash(encrypted_epoch)?;
debug_assert_eq!(decrypted_epoch.len(), EPOCH_SIZE);
let mut epoch = [0u8; EPOCH_SIZE];
epoch.copy_from_slice(&decrypted_epoch);
self.remote_epoch = Some(epoch);
// No DH here — responder's static is not in pre-message for XX
self.progress = HandshakeProgress::Message1Done;
@@ -446,11 +310,14 @@ impl HandshakeState {
/// Write message 2 (responder only).
///
/// Message 2 contains:
/// XX msg2: `<- e, ee, s, es` + encrypted epoch
/// - e: ephemeral public key (33 bytes)
/// - encrypted epoch: startup epoch for restart detection (8 + 16 = 24 bytes)
/// - ee: DH(e_priv, re_pub), mix_key
/// - s: encrypt_and_hash(s_pub) — encrypted static (49 bytes)
/// - es: DH(s_priv, re_pub), mix_key
/// - encrypted epoch (24 bytes)
///
/// Total: 57 bytes
/// Total: 106 bytes
pub fn write_message_2(&mut self) -> Result<Vec<u8>, NoiseError> {
if self.role != HandshakeRole::Responder {
return Err(NoiseError::WrongState {
@@ -485,23 +352,29 @@ impl HandshakeState {
let ee = self.ecdh(&ephemeral.secret_key(), &re);
self.symmetric.mix_key(&ee);
// <- se: DH(s, re), mix into key
let se = self.ecdh(&self.static_keypair.secret_key(), &re);
self.symmetric.mix_key(&se);
// <- s: encrypt our static and send
let our_static = self.static_keypair.public_key().serialize();
let encrypted_static = self.symmetric.encrypt_and_hash(&our_static)?;
message.extend_from_slice(&encrypted_static);
// <- es: DH(s, re), mix into key
let es = self.ecdh(&self.static_keypair.secret_key(), &re);
self.symmetric.mix_key(&es);
// <- epoch: encrypt startup epoch for restart detection
let encrypted_epoch = self.symmetric.encrypt_and_hash(&epoch)?;
debug_assert_eq!(encrypted_epoch.len(), EPOCH_ENCRYPTED_SIZE);
message.extend_from_slice(&encrypted_epoch);
self.progress = HandshakeProgress::Complete;
self.progress = HandshakeProgress::Message2Done;
Ok(message)
}
/// Read message 2 (initiator only).
///
/// Processes the responder's message and completes the handshake.
/// Processes the responder's ephemeral and encrypted static key.
/// After this, the initiator learns the responder's identity.
pub fn read_message_2(&mut self, message: &[u8]) -> Result<(), NoiseError> {
if self.role != HandshakeRole::Initiator {
return Err(NoiseError::WrongState {
@@ -533,204 +406,20 @@ impl HandshakeState {
let ee = self.ecdh(&ephemeral.secret_key(), &re);
self.symmetric.mix_key(&ee);
// <- se: DH(e, rs), mix into key
// (initiator uses their ephemeral with responder's static)
let rs = self.remote_static.expect("initiator has remote static");
let se = self.ecdh(&ephemeral.secret_key(), &rs);
self.symmetric.mix_key(&se);
// <- s: decrypt responder's static
let encrypted_static_end = PUBKEY_SIZE + PUBKEY_SIZE + super::TAG_SIZE;
let encrypted_static = &message[PUBKEY_SIZE..encrypted_static_end];
let decrypted_static = self.symmetric.decrypt_and_hash(encrypted_static)?;
let rs =
PublicKey::from_slice(&decrypted_static).map_err(|_| NoiseError::InvalidPublicKey)?;
self.remote_static = Some(rs);
// <- epoch: decrypt responder's startup epoch
let encrypted_epoch = &message[PUBKEY_SIZE..];
debug_assert_eq!(encrypted_epoch.len(), EPOCH_ENCRYPTED_SIZE);
let decrypted_epoch = self.symmetric.decrypt_and_hash(encrypted_epoch)?;
debug_assert_eq!(decrypted_epoch.len(), EPOCH_SIZE);
let mut epoch = [0u8; EPOCH_SIZE];
epoch.copy_from_slice(&decrypted_epoch);
self.remote_epoch = Some(epoch);
self.progress = HandshakeProgress::Complete;
Ok(())
}
// ========================================================================
// XK Pattern Methods (Session Layer)
// ========================================================================
/// Write XK message 1 (initiator only).
///
/// XK msg1: `-> e, es`
/// - e: ephemeral public key (33 bytes)
/// - es: DH(e_priv, rs_pub), mix_key
///
/// Total: 33 bytes (ephemeral only — no static, no epoch)
pub fn write_xk_message_1(&mut self) -> Result<Vec<u8>, NoiseError> {
if self.role != HandshakeRole::Initiator {
return Err(NoiseError::WrongState {
expected: "initiator".to_string(),
got: "responder".to_string(),
});
}
if self.progress != HandshakeProgress::Initial {
return Err(NoiseError::WrongState {
expected: HandshakeProgress::Initial.to_string(),
got: self.progress.to_string(),
});
}
let remote_static = self
.remote_static
.expect("initiator must have remote static");
// Generate ephemeral keypair
self.generate_ephemeral();
let ephemeral = self.ephemeral_keypair.as_ref().unwrap();
let e_pub = ephemeral.public_key().serialize();
let mut message = Vec::with_capacity(XK_HANDSHAKE_MSG1_SIZE);
// -> e: send ephemeral, mix into hash
message.extend_from_slice(&e_pub);
self.symmetric.mix_hash(&e_pub);
// -> es: DH(e, rs), mix into key
let es = self.ecdh(&ephemeral.secret_key(), &remote_static);
// <- es: DH(e, rs), mix into key
let es = self.ecdh(&ephemeral.secret_key(), &rs);
self.symmetric.mix_key(&es);
self.progress = HandshakeProgress::Message1Done;
Ok(message)
}
/// Read XK message 1 (responder only).
///
/// Processes the initiator's first message. Does NOT learn initiator's
/// identity (that comes in msg3).
pub fn read_xk_message_1(&mut self, message: &[u8]) -> Result<(), NoiseError> {
if self.role != HandshakeRole::Responder {
return Err(NoiseError::WrongState {
expected: "responder".to_string(),
got: "initiator".to_string(),
});
}
if self.progress != HandshakeProgress::Initial {
return Err(NoiseError::WrongState {
expected: HandshakeProgress::Initial.to_string(),
got: self.progress.to_string(),
});
}
if message.len() != XK_HANDSHAKE_MSG1_SIZE {
return Err(NoiseError::MessageTooShort {
expected: XK_HANDSHAKE_MSG1_SIZE,
got: message.len(),
});
}
// -> e: parse remote ephemeral, mix into hash
let re = PublicKey::from_slice(&message[..PUBKEY_SIZE])
.map_err(|_| NoiseError::InvalidPublicKey)?;
self.remote_ephemeral = Some(re);
self.symmetric.mix_hash(&message[..PUBKEY_SIZE]);
// -> es: DH(s, re), mix into key
// (responder uses their static with initiator's ephemeral)
let es = self.ecdh(&self.static_keypair.secret_key(), &re);
self.symmetric.mix_key(&es);
self.progress = HandshakeProgress::Message1Done;
Ok(())
}
/// Write XK message 2 (responder only).
///
/// XK msg2: `<- e, ee` + encrypted epoch
/// - e: ephemeral public key (33 bytes)
/// - ee: DH(e_priv, re_pub), mix_key
/// - encrypted epoch (24 bytes)
///
/// Total: 57 bytes
pub fn write_xk_message_2(&mut self) -> Result<Vec<u8>, NoiseError> {
if self.role != HandshakeRole::Responder {
return Err(NoiseError::WrongState {
expected: "responder".to_string(),
got: "initiator".to_string(),
});
}
if self.progress != HandshakeProgress::Message1Done {
return Err(NoiseError::WrongState {
expected: HandshakeProgress::Message1Done.to_string(),
got: self.progress.to_string(),
});
}
let re = self.remote_ephemeral.expect("should have remote ephemeral");
let epoch = self
.local_epoch
.expect("local epoch must be set before write_xk_message_2");
// Generate ephemeral keypair
self.generate_ephemeral();
let ephemeral = self.ephemeral_keypair.as_ref().unwrap();
let e_pub = ephemeral.public_key().serialize();
let mut message = Vec::with_capacity(XK_HANDSHAKE_MSG2_SIZE);
// <- e: send ephemeral, mix into hash
message.extend_from_slice(&e_pub);
self.symmetric.mix_hash(&e_pub);
// <- ee: DH(e, re), mix into key
let ee = self.ecdh(&ephemeral.secret_key(), &re);
self.symmetric.mix_key(&ee);
// <- epoch: encrypt startup epoch for restart detection
let encrypted_epoch = self.symmetric.encrypt_and_hash(&epoch)?;
debug_assert_eq!(encrypted_epoch.len(), EPOCH_ENCRYPTED_SIZE);
message.extend_from_slice(&encrypted_epoch);
self.progress = HandshakeProgress::Message2Done;
Ok(message)
}
/// Read XK message 2 (initiator only).
///
/// Processes the responder's message and extracts the responder's epoch.
/// Does NOT complete the handshake — msg3 still needed.
pub fn read_xk_message_2(&mut self, message: &[u8]) -> Result<(), NoiseError> {
if self.role != HandshakeRole::Initiator {
return Err(NoiseError::WrongState {
expected: "initiator".to_string(),
got: "responder".to_string(),
});
}
if self.progress != HandshakeProgress::Message1Done {
return Err(NoiseError::WrongState {
expected: HandshakeProgress::Message1Done.to_string(),
got: self.progress.to_string(),
});
}
if message.len() != XK_HANDSHAKE_MSG2_SIZE {
return Err(NoiseError::MessageTooShort {
expected: XK_HANDSHAKE_MSG2_SIZE,
got: message.len(),
});
}
// <- e: parse remote ephemeral, mix into hash
let e_pub = &message[..PUBKEY_SIZE];
let re = PublicKey::from_slice(e_pub).map_err(|_| NoiseError::InvalidPublicKey)?;
self.remote_ephemeral = Some(re);
self.symmetric.mix_hash(e_pub);
// <- ee: DH(e, re), mix into key
let ephemeral = self.ephemeral_keypair.as_ref().unwrap();
let ee = self.ecdh(&ephemeral.secret_key(), &re);
self.symmetric.mix_key(&ee);
// <- epoch: decrypt responder's startup epoch
let encrypted_epoch = &message[PUBKEY_SIZE..];
let encrypted_epoch = &message[encrypted_static_end..];
debug_assert_eq!(encrypted_epoch.len(), EPOCH_ENCRYPTED_SIZE);
let decrypted_epoch = self.symmetric.decrypt_and_hash(encrypted_epoch)?;
debug_assert_eq!(decrypted_epoch.len(), EPOCH_SIZE);
@@ -743,15 +432,15 @@ impl HandshakeState {
Ok(())
}
/// Write XK message 3 (initiator only).
/// Write message 3 (initiator only).
///
/// XK msg3: `-> s, se` + encrypted epoch
/// XX msg3: `-> s, se` + encrypted epoch
/// - s: encrypt_and_hash(s_pub) — encrypted static (49 bytes)
/// - se: DH(s_priv, re_pub), mix_key
/// - encrypted epoch (24 bytes)
///
/// Total: 73 bytes
pub fn write_xk_message_3(&mut self) -> Result<Vec<u8>, NoiseError> {
pub fn write_message_3(&mut self) -> Result<Vec<u8>, NoiseError> {
if self.role != HandshakeRole::Initiator {
return Err(NoiseError::WrongState {
expected: "initiator".to_string(),
@@ -770,9 +459,9 @@ impl HandshakeState {
.expect("should have remote ephemeral after msg2");
let epoch = self
.local_epoch
.expect("local epoch must be set before write_xk_message_3");
.expect("local epoch must be set before write_message_3");
let mut message = Vec::with_capacity(XK_HANDSHAKE_MSG3_SIZE);
let mut message = Vec::with_capacity(HANDSHAKE_MSG3_SIZE);
// -> s: encrypt our static and send
let our_static = self.static_keypair.public_key().serialize();
@@ -793,11 +482,11 @@ impl HandshakeState {
Ok(message)
}
/// Read XK message 3 (responder only).
/// Read message 3 (responder only).
///
/// Processes the initiator's encrypted static key and epoch.
/// After this, the responder learns the initiator's identity.
pub fn read_xk_message_3(&mut self, message: &[u8]) -> Result<(), NoiseError> {
pub fn read_message_3(&mut self, message: &[u8]) -> Result<(), NoiseError> {
if self.role != HandshakeRole::Responder {
return Err(NoiseError::WrongState {
expected: "responder".to_string(),
@@ -810,9 +499,9 @@ impl HandshakeState {
got: self.progress.to_string(),
});
}
if message.len() != XK_HANDSHAKE_MSG3_SIZE {
if message.len() != HANDSHAKE_MSG3_SIZE {
return Err(NoiseError::MessageTooShort {
expected: XK_HANDSHAKE_MSG3_SIZE,
expected: HANDSHAKE_MSG3_SIZE,
got: message.len(),
});
}
@@ -848,6 +537,26 @@ impl HandshakeState {
Ok(())
}
// ========================================================================
// Payload Encryption (for negotiation payload in msg2/msg3)
// ========================================================================
/// Encrypt additional payload and mix into the handshake hash.
///
/// Called after write_message_2/3 to append negotiation payload.
/// Must be called before `into_session()`.
pub fn encrypt_payload(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, NoiseError> {
self.symmetric.encrypt_and_hash(plaintext)
}
/// Decrypt additional payload and mix ciphertext into the handshake hash.
///
/// Called after read_message_2/3 to extract negotiation payload.
/// Must be called before `into_session()`.
pub fn decrypt_payload(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>, NoiseError> {
self.symmetric.decrypt_and_hash(ciphertext)
}
/// Complete the handshake and return a NoiseSession.
///
/// Must be called after the handshake is complete.
+41 -51
View File
@@ -1,39 +1,41 @@
//! Noise Protocol Implementations for FIPS
//! Noise Protocol Implementation for FIPS
//!
//! Implements Noise Protocol Framework patterns using secp256k1:
//! Implements the Noise XX pattern using secp256k1 for both link-layer and
//! session-layer encryption. Neither side knows the other's static key before
//! the handshake. Both identities are revealed during the handshake: responder
//! in msg2, initiator in msg3. Three-message handshake.
//!
//! - **IK pattern**: Used by FMP (link layer) for hop-by-hop peer authentication.
//! The initiator knows the responder's static key and sends its encrypted
//! static in msg1. Two-message handshake.
//!
//! - **XK pattern**: Used by FSP (session layer) for end-to-end sessions.
//! The initiator knows the responder's static key but defers revealing its
//! own identity until msg3, providing stronger identity hiding. Three-message
//! handshake.
//!
//! ## IK Handshake Pattern (Link Layer)
//! ## XX Handshake Pattern
//!
//! ```text
//! <- s (pre-message: responder's static known)
//! -> e, es, s, ss (msg1: ephemeral + encrypted static)
//! <- e, ee, se (msg2: ephemeral)
//! -> e (msg1: ephemeral only, no DH)
//! <- e, ee, s, es (msg2: ephemeral + encrypted static)
//! -> s, se (msg3: encrypted static)
//! ```
//!
//! ## XK Handshake Pattern (Session Layer)
//! The XX pattern handles both **link-layer peer authentication** (securing the
//! direct link between neighboring nodes) and **session-layer end-to-end
//! encryption** between arbitrary network addresses.
//!
//! ```text
//! <- s (pre-message: responder's static known)
//! -> e, es (msg1: ephemeral + DH with responder's static)
//! <- e, ee (msg2: ephemeral + DH)
//! -> s, se (msg3: encrypted static + DH)
//! ```
//! ## Identity Timing
//!
//! ## Separation of Concerns
//! Unlike IK (where the initiator's identity was in msg1), XX defers all
//! identity disclosure:
//!
//! The IK pattern handles **link-layer peer authentication** — securing the
//! direct link between neighboring nodes. The XK pattern handles **session-layer
//! end-to-end encryption** between arbitrary network addresses, with stronger
//! initiator identity protection.
//! - **msg1**: Ephemeral only. No identity, no DH with static keys.
//! - **msg2**: Responder reveals its static key to the initiator.
//! - **msg3**: Initiator reveals its static key to the responder.
//!
//! Consequence: all identity-based checks that previously ran during msg1
//! processing (restart detection, rekey detection, allow/deny lists,
//! cross-connection resolution) are now deferred:
//!
//! - **Initiator** performs identity checks in `handle_msg2` after
//! decrypting the responder's static key.
//! - **Responder** performs identity checks in `handle_msg3` after
//! decrypting the initiator's static key.
//! - **msg1 handler** can only do address-based duplicate detection (same
//! transport + address). Identity-dependent decisions happen later.
mod handshake;
mod replay;
@@ -47,13 +49,9 @@ pub use handshake::HandshakeState;
pub use replay::ReplayWindow;
pub use session::NoiseSession;
/// Protocol name for Noise IK with secp256k1 (link layer).
/// Format: Noise_IK_secp256k1_ChaChaPoly_SHA256
pub(crate) const PROTOCOL_NAME_IK: &[u8] = b"Noise_IK_secp256k1_ChaChaPoly_SHA256";
/// Protocol name for Noise XK with secp256k1 (session layer).
/// Format: Noise_XK_secp256k1_ChaChaPoly_SHA256
pub(crate) const PROTOCOL_NAME_XK: &[u8] = b"Noise_XK_secp256k1_ChaChaPoly_SHA256";
/// Protocol name for Noise XX with secp256k1.
/// Format: Noise_XX_secp256k1_ChaChaPoly_SHA256
pub(crate) const PROTOCOL_NAME_XX: &[u8] = b"Noise_XX_secp256k1_ChaChaPoly_SHA256";
/// Maximum message size for noise transport messages.
pub const MAX_MESSAGE_SIZE: usize = 65535;
@@ -70,20 +68,14 @@ pub const EPOCH_SIZE: usize = 8;
/// Size of encrypted epoch (epoch + AEAD tag).
pub const EPOCH_ENCRYPTED_SIZE: usize = EPOCH_SIZE + TAG_SIZE;
/// Size of IK handshake message 1: ephemeral (33) + encrypted static (33 + 16 tag) + encrypted epoch (8 + 16 tag).
pub const HANDSHAKE_MSG1_SIZE: usize = PUBKEY_SIZE + PUBKEY_SIZE + TAG_SIZE + EPOCH_ENCRYPTED_SIZE;
/// Handshake msg1: ephemeral only (33 bytes). No DH, no encryption.
pub const HANDSHAKE_MSG1_SIZE: usize = PUBKEY_SIZE;
/// Size of IK handshake message 2: ephemeral (33) + encrypted epoch (8 + 16 tag).
pub const HANDSHAKE_MSG2_SIZE: usize = PUBKEY_SIZE + EPOCH_ENCRYPTED_SIZE;
/// Handshake msg2: ephemeral (33) + encrypted static (33 + 16 tag) + encrypted epoch (8 + 16 tag) = 106 bytes.
pub const HANDSHAKE_MSG2_SIZE: usize = PUBKEY_SIZE + PUBKEY_SIZE + TAG_SIZE + EPOCH_ENCRYPTED_SIZE;
/// XK msg1: ephemeral only (33 bytes).
pub const XK_HANDSHAKE_MSG1_SIZE: usize = PUBKEY_SIZE;
/// XK msg2: ephemeral (33) + encrypted epoch (8 + 16 tag) = 57 bytes.
pub const XK_HANDSHAKE_MSG2_SIZE: usize = PUBKEY_SIZE + EPOCH_ENCRYPTED_SIZE;
/// XK msg3: encrypted static (33 + 16 tag) + encrypted epoch (8 + 16 tag) = 73 bytes.
pub const XK_HANDSHAKE_MSG3_SIZE: usize = PUBKEY_SIZE + TAG_SIZE + EPOCH_ENCRYPTED_SIZE;
/// Handshake msg3: encrypted static (33 + 16 tag) + encrypted epoch (8 + 16 tag) = 73 bytes.
pub const HANDSHAKE_MSG3_SIZE: usize = PUBKEY_SIZE + TAG_SIZE + EPOCH_ENCRYPTED_SIZE;
/// Replay window size in packets (matching WireGuard).
pub const REPLAY_WINDOW_SIZE: usize = 2048;
@@ -146,10 +138,8 @@ impl fmt::Display for HandshakeRole {
/// Which Noise pattern is being used for this handshake.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NoisePattern {
/// Noise IK: two-message handshake (link layer).
Ik,
/// Noise XK: three-message handshake (session layer).
Xk,
/// Noise XX: three-message handshake, no prior key knowledge.
Xx,
}
/// Handshake state machine states.
@@ -159,7 +149,7 @@ pub enum HandshakeProgress {
Initial,
/// Message 1 sent/received, ready for message 2.
Message1Done,
/// Message 2 sent/received, ready for message 3 (XK only).
/// Message 2 sent/received, ready for message 3.
Message2Done,
/// Handshake complete, ready for transport.
Complete,
+233 -490
View File
@@ -24,11 +24,8 @@ fn test_full_handshake() {
let initiator_epoch = generate_epoch();
let responder_epoch = generate_epoch();
let responder_pub = responder_keypair.public_key();
// Initiator knows responder's static key
// Responder does NOT know initiator's static key (IK pattern)
let mut initiator = HandshakeState::new_initiator(initiator_keypair, responder_pub);
// XX: neither side knows the other's static key
let mut initiator = HandshakeState::new_initiator(initiator_keypair);
initiator.set_local_epoch(initiator_epoch);
let mut responder = HandshakeState::new_responder(responder_keypair);
responder.set_local_epoch(responder_epoch);
@@ -36,38 +33,61 @@ fn test_full_handshake() {
assert_eq!(initiator.role(), HandshakeRole::Initiator);
assert_eq!(responder.role(), HandshakeRole::Responder);
// Initially, responder doesn't know initiator's identity
// Neither side knows the other's identity
assert!(initiator.remote_static().is_none());
assert!(responder.remote_static().is_none());
// Message 1: Initiator -> Responder
// Message 1: Initiator -> Responder (e only)
let msg1 = initiator.write_message_1().unwrap();
assert_eq!(msg1.len(), HANDSHAKE_MSG1_SIZE);
assert_eq!(msg1.len(), 33);
responder.read_message_1(&msg1).unwrap();
// Now responder knows initiator's identity!
// After msg1: still no identities known
assert!(initiator.remote_static().is_none());
assert!(responder.remote_static().is_none());
// Message 2: Responder -> Initiator (e, ee, s, es + epoch)
let msg2 = responder.write_message_2().unwrap();
assert_eq!(msg2.len(), HANDSHAKE_MSG2_SIZE);
assert_eq!(msg2.len(), 106);
initiator.read_message_2(&msg2).unwrap();
// After msg2: initiator knows responder's identity
assert!(initiator.remote_static().is_some());
assert_eq!(
initiator.remote_static().unwrap(),
&responder_keypair.public_key()
);
assert_eq!(initiator.remote_epoch(), Some(responder_epoch));
// Responder still doesn't know initiator
assert!(responder.remote_static().is_none());
// Neither side is complete yet
assert!(!initiator.is_complete());
assert!(!responder.is_complete());
// Message 3: Initiator -> Responder (s, se + epoch)
let msg3 = initiator.write_message_3().unwrap();
assert_eq!(msg3.len(), HANDSHAKE_MSG3_SIZE);
assert_eq!(msg3.len(), 73);
responder.read_message_3(&msg3).unwrap();
// Both should be complete now
assert!(initiator.is_complete());
assert!(responder.is_complete());
// After msg3: responder knows initiator's identity
assert!(responder.remote_static().is_some());
assert_eq!(
responder.remote_static().unwrap(),
&initiator_keypair.public_key()
);
// Responder learned initiator's epoch
assert_eq!(responder.remote_epoch(), Some(initiator_epoch));
// Message 2: Responder -> Initiator
let msg2 = responder.write_message_2().unwrap();
assert_eq!(msg2.len(), HANDSHAKE_MSG2_SIZE);
initiator.read_message_2(&msg2).unwrap();
// Both should be complete
assert!(initiator.is_complete());
assert!(responder.is_complete());
// Initiator learned responder's epoch
assert_eq!(initiator.remote_epoch(), Some(responder_epoch));
// Handshake hashes should match
assert_eq!(initiator.handshake_hash(), responder.handshake_hash());
@@ -75,79 +95,225 @@ fn test_full_handshake() {
let mut initiator_session = initiator.into_session().unwrap();
let mut responder_session = responder.into_session().unwrap();
// Test encryption/decryption
let plaintext = b"Hello, secure world!";
// Test bidirectional encryption
let plaintext = b"Hello via XX!";
let ciphertext = initiator_session.encrypt(plaintext).unwrap();
let decrypted = responder_session.decrypt(&ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
// Test reverse direction
let plaintext2 = b"Hello back!";
let plaintext2 = b"XX reply!";
let ciphertext2 = responder_session.encrypt(plaintext2).unwrap();
let decrypted2 = initiator_session.decrypt(&ciphertext2).unwrap();
assert_eq!(decrypted2, plaintext2);
}
#[test]
fn test_multiple_messages() {
fn test_message_sizes() {
assert_eq!(HANDSHAKE_MSG1_SIZE, 33); // ephemeral only
assert_eq!(HANDSHAKE_MSG2_SIZE, 33 + 33 + 16 + 24); // e + encrypted static + encrypted epoch
assert_eq!(HANDSHAKE_MSG3_SIZE, 33 + 16 + 24); // encrypted static + encrypted epoch
}
#[test]
fn test_identity_timing() {
// XX property: initiator learns responder in msg2, responder learns initiator in msg3
let initiator_keypair = generate_keypair();
let responder_keypair = generate_keypair();
let mut initiator =
HandshakeState::new_initiator(initiator_keypair, responder_keypair.public_key());
let mut initiator = HandshakeState::new_initiator(initiator_keypair);
initiator.set_local_epoch(generate_epoch());
let mut responder = HandshakeState::new_responder(responder_keypair);
responder.set_local_epoch(generate_epoch());
// Before any messages: neither side knows
assert!(initiator.remote_static().is_none());
assert!(responder.remote_static().is_none());
// After msg1
let msg1 = initiator.write_message_1().unwrap();
responder.read_message_1(&msg1).unwrap();
assert!(
initiator.remote_static().is_none(),
"XX: initiator should NOT know identity after msg1"
);
assert!(
responder.remote_static().is_none(),
"XX: responder should NOT know identity after msg1"
);
// After msg2: initiator learns responder
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
assert!(
initiator.remote_static().is_some(),
"XX: initiator should know responder after msg2"
);
assert_eq!(
initiator.remote_static().unwrap(),
&responder_keypair.public_key()
);
assert!(
responder.remote_static().is_none(),
"XX: responder should NOT know initiator after msg2"
);
let mut initiator_session = initiator.into_session().unwrap();
let mut responder_session = responder.into_session().unwrap();
// Send many messages to test nonce increment
for i in 0..100 {
let msg = format!("Message {}", i);
let ct = initiator_session.encrypt(msg.as_bytes()).unwrap();
let pt = responder_session.decrypt(&ct).unwrap();
assert_eq!(pt, msg.as_bytes());
}
assert_eq!(initiator_session.send_nonce(), 100);
assert_eq!(responder_session.recv_nonce(), 100);
// After msg3: responder learns initiator
let msg3 = initiator.write_message_3().unwrap();
responder.read_message_3(&msg3).unwrap();
assert!(
responder.remote_static().is_some(),
"XX: responder should know initiator after msg3"
);
assert_eq!(
responder.remote_static().unwrap(),
&initiator_keypair.public_key()
);
}
#[test]
fn test_wrong_role_errors() {
fn test_wrong_state_errors() {
let keypair1 = generate_keypair();
let keypair2 = generate_keypair();
let mut initiator = HandshakeState::new_initiator(keypair1, keypair2.public_key());
// Initiator can't read msg1
let mut initiator = HandshakeState::new_initiator(keypair1);
initiator.set_local_epoch(generate_epoch());
// Initiator can't read message 1
assert!(
initiator
.read_message_1(&[0u8; HANDSHAKE_MSG1_SIZE])
.is_err()
);
// Initiator can't write message 2 before message 1
// Initiator can't write msg2
assert!(initiator.write_message_2().is_err());
// Initiator can't write msg3 before msg2
assert!(initiator.write_message_3().is_err());
// Responder can't write msg1
let mut responder = HandshakeState::new_responder(keypair2);
responder.set_local_epoch(generate_epoch());
assert!(responder.write_message_1().is_err());
// Responder can't read msg3 before msg2
assert!(
responder
.read_message_3(&[0u8; HANDSHAKE_MSG3_SIZE])
.is_err()
);
}
#[test]
fn test_invalid_pubkey_in_msg1() {
let keypair = generate_keypair();
let mut responder = HandshakeState::new_responder(keypair);
fn test_multiple_messages_after_handshake() {
let keypair1 = generate_keypair();
let keypair2 = generate_keypair();
let mut initiator = HandshakeState::new_initiator(keypair1);
initiator.set_local_epoch(generate_epoch());
let mut responder = HandshakeState::new_responder(keypair2);
responder.set_local_epoch(generate_epoch());
// Invalid pubkey bytes (first 33 bytes are zero)
let invalid_msg = [0u8; HANDSHAKE_MSG1_SIZE];
assert!(responder.read_message_1(&invalid_msg).is_err());
let msg1 = initiator.write_message_1().unwrap();
responder.read_message_1(&msg1).unwrap();
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
let msg3 = initiator.write_message_3().unwrap();
responder.read_message_3(&msg3).unwrap();
let mut init_session = initiator.into_session().unwrap();
let mut resp_session = responder.into_session().unwrap();
// Send many messages
for i in 0..100 {
let msg = format!("XX message {}", i);
let ct = init_session.encrypt(msg.as_bytes()).unwrap();
let pt = resp_session.decrypt(&ct).unwrap();
assert_eq!(pt, msg.as_bytes());
}
assert_eq!(init_session.send_nonce(), 100);
assert_eq!(resp_session.recv_nonce(), 100);
}
#[test]
fn test_with_odd_parity() {
// XX: no pre-message normalization needed, but ECDH x-only hashing
// must still produce matching shared secrets regardless of parity.
let secp = secp256k1::Secp256k1::new();
// Node A (initiator) - even parity key
let sk_a = secp256k1::SecretKey::from_slice(
&hex::decode("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20").unwrap(),
)
.unwrap();
let kp_a = secp256k1::Keypair::from_secret_key(&secp, &sk_a);
// Node B (responder) - odd parity key
let sk_b = secp256k1::SecretKey::from_slice(
&hex::decode("b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0").unwrap(),
)
.unwrap();
let kp_b = secp256k1::Keypair::from_secret_key(&secp, &sk_b);
let (_, parity_b) = kp_b.public_key().x_only_public_key();
assert_eq!(
parity_b,
Parity::Odd,
"Test requires odd-parity responder key"
);
let mut initiator = HandshakeState::new_initiator(kp_a);
initiator.set_local_epoch(generate_epoch());
let mut responder = HandshakeState::new_responder(kp_b);
responder.set_local_epoch(generate_epoch());
let msg1 = initiator.write_message_1().unwrap();
responder.read_message_1(&msg1).unwrap();
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
let msg3 = initiator.write_message_3().unwrap();
responder.read_message_3(&msg3).unwrap();
assert!(initiator.is_complete());
assert!(responder.is_complete());
let mut sender = initiator.into_session().unwrap();
let mut receiver = responder.into_session().unwrap();
let counter = sender.current_send_counter();
let ciphertext = sender.encrypt(b"xx parity test").unwrap();
let plaintext = receiver
.decrypt_with_replay_check(&ciphertext, counter)
.unwrap();
assert_eq!(plaintext, b"xx parity test");
}
#[test]
fn test_invalid_msg_sizes() {
let keypair1 = generate_keypair();
let keypair2 = generate_keypair();
// Wrong size for msg1
let mut responder = HandshakeState::new_responder(keypair1);
responder.set_local_epoch(generate_epoch());
assert!(responder.read_message_1(&[0u8; 10]).is_err());
// Wrong size for msg3
let mut initiator = HandshakeState::new_initiator(keypair1);
initiator.set_local_epoch(generate_epoch());
let mut responder = HandshakeState::new_responder(keypair2);
responder.set_local_epoch(generate_epoch());
let msg1 = initiator.write_message_1().unwrap();
responder.read_message_1(&msg1).unwrap();
let _msg2 = responder.write_message_2().unwrap();
// Responder is now in Message2Done, try wrong-size msg3
assert!(responder.read_message_3(&[0u8; 10]).is_err());
assert!(
responder
.read_message_3(&[0u8; HANDSHAKE_MSG3_SIZE + 1])
.is_err()
);
}
#[test]
@@ -157,7 +323,7 @@ fn test_decryption_failure_wrong_key() {
let keypair3 = generate_keypair();
// Session between 1 and 2
let mut init1 = HandshakeState::new_initiator(keypair1, keypair2.public_key());
let mut init1 = HandshakeState::new_initiator(keypair1);
init1.set_local_epoch(generate_epoch());
let mut resp1 = HandshakeState::new_responder(keypair2);
resp1.set_local_epoch(generate_epoch());
@@ -166,11 +332,13 @@ fn test_decryption_failure_wrong_key() {
resp1.read_message_1(&msg1).unwrap();
let msg2 = resp1.write_message_2().unwrap();
init1.read_message_2(&msg2).unwrap();
let msg3 = init1.write_message_3().unwrap();
resp1.read_message_3(&msg3).unwrap();
let mut session1 = init1.into_session().unwrap();
// Session between 1 and 3
let mut init2 = HandshakeState::new_initiator(keypair1, keypair3.public_key());
let mut init2 = HandshakeState::new_initiator(keypair1);
init2.set_local_epoch(generate_epoch());
let mut resp2 = HandshakeState::new_responder(keypair3);
resp2.set_local_epoch(generate_epoch());
@@ -179,6 +347,8 @@ fn test_decryption_failure_wrong_key() {
resp2.read_message_1(&msg1).unwrap();
let msg2 = resp2.write_message_2().unwrap();
init2.read_message_2(&msg2).unwrap();
let msg3 = init2.write_message_3().unwrap();
resp2.read_message_3(&msg3).unwrap();
let mut session2 = resp2.into_session().unwrap();
@@ -206,7 +376,7 @@ fn test_session_remote_static() {
let keypair1 = generate_keypair();
let keypair2 = generate_keypair();
let mut init = HandshakeState::new_initiator(keypair1, keypair2.public_key());
let mut init = HandshakeState::new_initiator(keypair1);
init.set_local_epoch(generate_epoch());
let mut resp = HandshakeState::new_responder(keypair2);
resp.set_local_epoch(generate_epoch());
@@ -215,6 +385,8 @@ fn test_session_remote_static() {
resp.read_message_1(&msg1).unwrap();
let msg2 = resp.write_message_2().unwrap();
init.read_message_2(&msg2).unwrap();
let msg3 = init.write_message_3().unwrap();
resp.read_message_3(&msg3).unwrap();
let session1 = init.into_session().unwrap();
let session2 = resp.into_session().unwrap();
@@ -224,40 +396,6 @@ fn test_session_remote_static() {
assert_eq!(session2.remote_static(), &keypair1.public_key());
}
#[test]
fn test_message_sizes() {
// Verify our size constants are correct
assert_eq!(EPOCH_SIZE, 8);
assert_eq!(EPOCH_ENCRYPTED_SIZE, 8 + 16); // epoch + AEAD tag
assert_eq!(HANDSHAKE_MSG1_SIZE, 33 + 33 + 16 + 24); // e + encrypted_s + encrypted_epoch
assert_eq!(HANDSHAKE_MSG2_SIZE, 33 + 24); // e + encrypted_epoch
}
#[test]
fn test_responder_identity_discovery() {
// This test verifies the key IK property: responder learns initiator's identity
let initiator_keypair = generate_keypair();
let responder_keypair = generate_keypair();
let mut responder = HandshakeState::new_responder(responder_keypair);
responder.set_local_epoch(generate_epoch());
// Before message 1: responder has no idea who's connecting
assert!(responder.remote_static().is_none());
let mut initiator =
HandshakeState::new_initiator(initiator_keypair, responder_keypair.public_key());
initiator.set_local_epoch(generate_epoch());
let msg1 = initiator.write_message_1().unwrap();
// After processing message 1: responder knows initiator's identity
responder.read_message_1(&msg1).unwrap();
let discovered_initiator = responder.remote_static().unwrap();
assert_eq!(discovered_initiator, &initiator_keypair.public_key());
// The discovered key can be used to look up peer config, verify against allow-list, etc.
}
// ===== ReplayWindow Tests =====
#[test]
@@ -368,7 +506,7 @@ fn test_session_replay_protection() {
let keypair1 = generate_keypair();
let keypair2 = generate_keypair();
let mut init = HandshakeState::new_initiator(keypair1, keypair2.public_key());
let mut init = HandshakeState::new_initiator(keypair1);
init.set_local_epoch(generate_epoch());
let mut resp = HandshakeState::new_responder(keypair2);
resp.set_local_epoch(generate_epoch());
@@ -377,6 +515,8 @@ fn test_session_replay_protection() {
resp.read_message_1(&msg1).unwrap();
let msg2 = resp.write_message_2().unwrap();
init.read_message_2(&msg2).unwrap();
let msg3 = init.write_message_3().unwrap();
resp.read_message_3(&msg3).unwrap();
let mut sender = init.into_session().unwrap();
let mut receiver = resp.into_session().unwrap();
@@ -398,400 +538,3 @@ fn test_session_replay_protection() {
// Check method alone also detects replay
assert!(receiver.check_replay(counter).is_err());
}
#[test]
fn test_handshake_with_odd_parity_responder() {
// Node B's secret key produces an odd-parity public key (0x03 prefix).
// When the initiator only has the npub (x-only), PeerIdentity::pubkey_full()
// returns even parity (0x02). The pre-message mix_hash must normalize
// parity so both sides produce matching hash chains.
let secp = secp256k1::Secp256k1::new();
// Node B (responder) - odd parity key
let sk_b = secp256k1::SecretKey::from_slice(
&hex::decode("b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0").unwrap(),
)
.unwrap();
let kp_b = secp256k1::Keypair::from_secret_key(&secp, &sk_b);
let (xonly_b, parity_b) = kp_b.public_key().x_only_public_key();
assert_eq!(
parity_b,
Parity::Odd,
"Test requires odd-parity responder key"
);
// Node A (initiator) - even parity key
let sk_a = secp256k1::SecretKey::from_slice(
&hex::decode("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20").unwrap(),
)
.unwrap();
let kp_a = secp256k1::Keypair::from_secret_key(&secp, &sk_a);
// Simulate the production path: initiator gets responder's key via npub
// (x-only -> assumed even parity)
let assumed_even_b = xonly_b.public_key(Parity::Even);
assert_ne!(
assumed_even_b,
kp_b.public_key(),
"Even assumption should differ from actual odd key"
);
// Handshake using assumed-even key (as production code does)
let mut initiator = HandshakeState::new_initiator(kp_a, assumed_even_b);
initiator.set_local_epoch(generate_epoch());
let mut responder = HandshakeState::new_responder(kp_b);
responder.set_local_epoch(generate_epoch());
let msg1 = initiator.write_message_1().unwrap();
responder.read_message_1(&msg1).unwrap();
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
assert!(initiator.is_complete());
assert!(responder.is_complete());
// Verify sessions can communicate
let mut sender = initiator.into_session().unwrap();
let mut receiver = responder.into_session().unwrap();
let counter = sender.current_send_counter();
let ciphertext = sender.encrypt(b"parity test").unwrap();
let plaintext = receiver
.decrypt_with_replay_check(&ciphertext, counter)
.unwrap();
assert_eq!(plaintext, b"parity test");
}
// ===== XK Handshake Tests =====
#[test]
fn test_xk_full_handshake() {
let initiator_keypair = generate_keypair();
let responder_keypair = generate_keypair();
let initiator_epoch = generate_epoch();
let responder_epoch = generate_epoch();
let responder_pub = responder_keypair.public_key();
// XK: initiator knows responder's static, responder learns initiator's in msg3
let mut initiator = HandshakeState::new_xk_initiator(initiator_keypair, responder_pub);
initiator.set_local_epoch(initiator_epoch);
let mut responder = HandshakeState::new_xk_responder(responder_keypair);
responder.set_local_epoch(responder_epoch);
assert_eq!(initiator.role(), HandshakeRole::Initiator);
assert_eq!(responder.role(), HandshakeRole::Responder);
// Initially, responder doesn't know initiator's identity
assert!(responder.remote_static().is_none());
// Message 1: Initiator -> Responder (e, es)
let msg1 = initiator.write_xk_message_1().unwrap();
assert_eq!(msg1.len(), XK_HANDSHAKE_MSG1_SIZE);
assert_eq!(msg1.len(), 33); // ephemeral only
responder.read_xk_message_1(&msg1).unwrap();
// After msg1: responder still doesn't know initiator's identity (XK property)
assert!(responder.remote_static().is_none());
assert!(responder.remote_epoch().is_none());
// Message 2: Responder -> Initiator (e, ee + epoch)
let msg2 = responder.write_xk_message_2().unwrap();
assert_eq!(msg2.len(), XK_HANDSHAKE_MSG2_SIZE);
assert_eq!(msg2.len(), 57); // 33 ephemeral + 24 encrypted epoch
initiator.read_xk_message_2(&msg2).unwrap();
// After msg2: initiator learned responder's epoch
assert_eq!(initiator.remote_epoch(), Some(responder_epoch));
// Neither side is complete yet
assert!(!initiator.is_complete());
assert!(!responder.is_complete());
// Message 3: Initiator -> Responder (s, se + epoch)
let msg3 = initiator.write_xk_message_3().unwrap();
assert_eq!(msg3.len(), XK_HANDSHAKE_MSG3_SIZE);
assert_eq!(msg3.len(), 73); // 49 encrypted static + 24 encrypted epoch
responder.read_xk_message_3(&msg3).unwrap();
// Both should be complete now
assert!(initiator.is_complete());
assert!(responder.is_complete());
// After msg3: responder now knows initiator's identity
assert!(responder.remote_static().is_some());
assert_eq!(
responder.remote_static().unwrap(),
&initiator_keypair.public_key()
);
// Responder learned initiator's epoch from msg3
assert_eq!(responder.remote_epoch(), Some(initiator_epoch));
// Handshake hashes should match
assert_eq!(initiator.handshake_hash(), responder.handshake_hash());
// Convert to sessions
let mut initiator_session = initiator.into_session().unwrap();
let mut responder_session = responder.into_session().unwrap();
// Test bidirectional encryption
let plaintext = b"Hello via XK!";
let ciphertext = initiator_session.encrypt(plaintext).unwrap();
let decrypted = responder_session.decrypt(&ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
let plaintext2 = b"XK reply!";
let ciphertext2 = responder_session.encrypt(plaintext2).unwrap();
let decrypted2 = initiator_session.decrypt(&ciphertext2).unwrap();
assert_eq!(decrypted2, plaintext2);
}
#[test]
fn test_xk_message_sizes() {
assert_eq!(XK_HANDSHAKE_MSG1_SIZE, 33); // ephemeral only
assert_eq!(XK_HANDSHAKE_MSG2_SIZE, 33 + 24); // ephemeral + encrypted epoch
assert_eq!(XK_HANDSHAKE_MSG3_SIZE, 33 + 16 + 24); // encrypted static + encrypted epoch
}
#[test]
fn test_xk_identity_timing() {
// XK property: responder doesn't learn initiator identity until msg3
let initiator_keypair = generate_keypair();
let responder_keypair = generate_keypair();
let mut initiator =
HandshakeState::new_xk_initiator(initiator_keypair, responder_keypair.public_key());
initiator.set_local_epoch(generate_epoch());
let mut responder = HandshakeState::new_xk_responder(responder_keypair);
responder.set_local_epoch(generate_epoch());
// Before any messages
assert!(responder.remote_static().is_none());
// After msg1
let msg1 = initiator.write_xk_message_1().unwrap();
responder.read_xk_message_1(&msg1).unwrap();
assert!(
responder.remote_static().is_none(),
"XK: responder should NOT know identity after msg1"
);
// After msg2
let msg2 = responder.write_xk_message_2().unwrap();
initiator.read_xk_message_2(&msg2).unwrap();
assert!(
responder.remote_static().is_none(),
"XK: responder should NOT know identity after msg2"
);
// After msg3
let msg3 = initiator.write_xk_message_3().unwrap();
responder.read_xk_message_3(&msg3).unwrap();
assert!(
responder.remote_static().is_some(),
"XK: responder should know identity after msg3"
);
assert_eq!(
responder.remote_static().unwrap(),
&initiator_keypair.public_key()
);
}
#[test]
fn test_xk_wrong_state_errors() {
let keypair1 = generate_keypair();
let keypair2 = generate_keypair();
// Initiator can't read XK msg1
let mut initiator = HandshakeState::new_xk_initiator(keypair1, keypair2.public_key());
initiator.set_local_epoch(generate_epoch());
assert!(
initiator
.read_xk_message_1(&[0u8; XK_HANDSHAKE_MSG1_SIZE])
.is_err()
);
// Initiator can't write msg2
assert!(initiator.write_xk_message_2().is_err());
// Initiator can't write msg3 before msg2
assert!(initiator.write_xk_message_3().is_err());
// Responder can't write msg1
let mut responder = HandshakeState::new_xk_responder(keypair2);
responder.set_local_epoch(generate_epoch());
assert!(responder.write_xk_message_1().is_err());
// Responder can't read msg3 before msg2
assert!(
responder
.read_xk_message_3(&[0u8; XK_HANDSHAKE_MSG3_SIZE])
.is_err()
);
}
#[test]
fn test_xk_handshake_hash_differs_from_ik() {
// XK and IK should produce different handshake hashes (different protocol names)
let keypair1 = generate_keypair();
let keypair2 = generate_keypair();
let epoch1 = generate_epoch();
let epoch2 = generate_epoch();
// Complete an IK handshake
let mut ik_init = HandshakeState::new_initiator(keypair1, keypair2.public_key());
ik_init.set_local_epoch(epoch1);
let mut ik_resp = HandshakeState::new_responder(keypair2);
ik_resp.set_local_epoch(epoch2);
let msg1 = ik_init.write_message_1().unwrap();
ik_resp.read_message_1(&msg1).unwrap();
let msg2 = ik_resp.write_message_2().unwrap();
ik_init.read_message_2(&msg2).unwrap();
let ik_hash = ik_init.handshake_hash();
// Complete an XK handshake with the same keys
let mut xk_init = HandshakeState::new_xk_initiator(keypair1, keypair2.public_key());
xk_init.set_local_epoch(epoch1);
let mut xk_resp = HandshakeState::new_xk_responder(keypair2);
xk_resp.set_local_epoch(epoch2);
let msg1 = xk_init.write_xk_message_1().unwrap();
xk_resp.read_xk_message_1(&msg1).unwrap();
let msg2 = xk_resp.write_xk_message_2().unwrap();
xk_init.read_xk_message_2(&msg2).unwrap();
let msg3 = xk_init.write_xk_message_3().unwrap();
xk_resp.read_xk_message_3(&msg3).unwrap();
let xk_hash = xk_init.handshake_hash();
assert_ne!(
ik_hash, xk_hash,
"IK and XK should produce different handshake hashes"
);
}
#[test]
fn test_xk_multiple_messages_after_handshake() {
let keypair1 = generate_keypair();
let keypair2 = generate_keypair();
let mut initiator = HandshakeState::new_xk_initiator(keypair1, keypair2.public_key());
initiator.set_local_epoch(generate_epoch());
let mut responder = HandshakeState::new_xk_responder(keypair2);
responder.set_local_epoch(generate_epoch());
let msg1 = initiator.write_xk_message_1().unwrap();
responder.read_xk_message_1(&msg1).unwrap();
let msg2 = responder.write_xk_message_2().unwrap();
initiator.read_xk_message_2(&msg2).unwrap();
let msg3 = initiator.write_xk_message_3().unwrap();
responder.read_xk_message_3(&msg3).unwrap();
let mut init_session = initiator.into_session().unwrap();
let mut resp_session = responder.into_session().unwrap();
// Send many messages
for i in 0..100 {
let msg = format!("XK message {}", i);
let ct = init_session.encrypt(msg.as_bytes()).unwrap();
let pt = resp_session.decrypt(&ct).unwrap();
assert_eq!(pt, msg.as_bytes());
}
assert_eq!(init_session.send_nonce(), 100);
assert_eq!(resp_session.recv_nonce(), 100);
}
#[test]
fn test_xk_with_odd_parity_responder() {
let secp = secp256k1::Secp256k1::new();
// Node B (responder) - odd parity key
let sk_b = secp256k1::SecretKey::from_slice(
&hex::decode("b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0").unwrap(),
)
.unwrap();
let kp_b = secp256k1::Keypair::from_secret_key(&secp, &sk_b);
let (xonly_b, parity_b) = kp_b.public_key().x_only_public_key();
assert_eq!(
parity_b,
Parity::Odd,
"Test requires odd-parity responder key"
);
// Node A (initiator)
let sk_a = secp256k1::SecretKey::from_slice(
&hex::decode("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20").unwrap(),
)
.unwrap();
let kp_a = secp256k1::Keypair::from_secret_key(&secp, &sk_a);
// Simulate npub path: x-only → assumed even parity
let assumed_even_b = xonly_b.public_key(Parity::Even);
let mut initiator = HandshakeState::new_xk_initiator(kp_a, assumed_even_b);
initiator.set_local_epoch(generate_epoch());
let mut responder = HandshakeState::new_xk_responder(kp_b);
responder.set_local_epoch(generate_epoch());
let msg1 = initiator.write_xk_message_1().unwrap();
responder.read_xk_message_1(&msg1).unwrap();
let msg2 = responder.write_xk_message_2().unwrap();
initiator.read_xk_message_2(&msg2).unwrap();
let msg3 = initiator.write_xk_message_3().unwrap();
responder.read_xk_message_3(&msg3).unwrap();
assert!(initiator.is_complete());
assert!(responder.is_complete());
let mut sender = initiator.into_session().unwrap();
let mut receiver = responder.into_session().unwrap();
let counter = sender.current_send_counter();
let ciphertext = sender.encrypt(b"xk parity test").unwrap();
let plaintext = receiver
.decrypt_with_replay_check(&ciphertext, counter)
.unwrap();
assert_eq!(plaintext, b"xk parity test");
}
#[test]
fn test_xk_invalid_msg1_size() {
let keypair = generate_keypair();
let mut responder = HandshakeState::new_xk_responder(keypair);
responder.set_local_epoch(generate_epoch());
// Wrong size (IK msg1 size instead of XK)
assert!(
responder
.read_xk_message_1(&[0u8; HANDSHAKE_MSG1_SIZE])
.is_err()
);
// Too short
assert!(responder.read_xk_message_1(&[0u8; 10]).is_err());
}
#[test]
fn test_xk_invalid_msg3_size() {
let keypair1 = generate_keypair();
let keypair2 = generate_keypair();
let mut initiator = HandshakeState::new_xk_initiator(keypair1, keypair2.public_key());
initiator.set_local_epoch(generate_epoch());
let mut responder = HandshakeState::new_xk_responder(keypair2);
responder.set_local_epoch(generate_epoch());
let msg1 = initiator.write_xk_message_1().unwrap();
responder.read_xk_message_1(&msg1).unwrap();
let _msg2 = responder.write_xk_message_2().unwrap();
// Responder is now in Message2Done, try wrong-size msg3
assert!(responder.read_xk_message_3(&[0u8; 10]).is_err());
assert!(
responder
.read_xk_message_3(&[0u8; XK_HANDSHAKE_MSG3_SIZE + 1])
.is_err()
);
}
+1 -1
View File
@@ -43,7 +43,7 @@ fn can_reach(local_nat: NatType, remote_nat: NatType) -> bool {
fn signed_overlay_advert_event(created_at_secs: u64, expiration_secs: Option<u64>) -> nostr::Event {
let keys = nostr::Keys::generate();
let content = r#"{"identifier":"fips-overlay-v1","version":1,"endpoints":[{"transport":"tcp","addr":"8.8.8.8:443"}]}"#;
let content = r#"{"identifier":"fips-overlay-v1-next","version":1,"endpoints":[{"transport":"tcp","addr":"203.0.113.10:443"}]}"#;
let mut builder = EventBuilder::new(Kind::Custom(ADVERT_KIND), content)
.custom_created_at(Timestamp::from(created_at_secs));
if let Some(expiration_secs) = expiration_secs {
+8 -1
View File
@@ -3,7 +3,14 @@ use crate::config::PeerConfig;
use serde::{Deserialize, Serialize};
pub const ADVERT_KIND: u16 = 37195;
pub const ADVERT_IDENTIFIER: &str = "fips-overlay-v1";
/// Default Nostr-discovery advert namespace identifier on the `next`
/// branch. Distinct from `master`'s `fips-overlay-v1` so peers running
/// the FMP-v1 protocol on `next` are not discovered by `master` peers
/// (and vice versa) — the `next`-branch FMP wire format is incompatible
/// with `master`'s, so cross-branch discovery only ever produces failed
/// handshakes. Operators who genuinely want cross-branch reach can
/// override per-peer via `node.rendezvous.nostr.app` in config.
pub const ADVERT_IDENTIFIER: &str = "fips-overlay-v1-next";
pub const ADVERT_VERSION: u32 = 1;
pub const SIGNAL_KIND: u16 = 21059;
// Defined in the nostr `handoff` submodule; re-exported here so the
+339 -175
View File
@@ -7,6 +7,7 @@ use crate::config::MmpConfig;
use crate::node::REKEY_JITTER_SECS;
use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession};
use crate::proto::bloom::BloomFilter;
use crate::proto::fmp::{NegotiationPayload, NodeProfile};
use crate::proto::mmp::MmpPeerState;
use crate::proto::stp::{ParentDeclaration, TreeCoordinate};
use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId};
@@ -17,6 +18,18 @@ use secp256k1::XOnlyPublicKey;
use std::fmt;
use std::time::Instant;
/// Result of completing a rekey msg2 on the initiator (XX pattern):
/// the XX msg3 bytes to send, the completed Noise session, the remote
/// peer's startup epoch (for peer-restart detection), and the node address
/// derived from the static key the returned session is bound to.
///
/// XX transmits the responder's static in msg2 rather than pinning it a priori
/// (as IK did), so the learned identity is surfaced here for the caller's
/// static-continuity decision: a cryptographically valid handshake alone does
/// not establish that the rekey was answered by the peer already holding the
/// link.
type RekeyMsg2Completion = (Vec<u8>, NoiseSession, Option<[u8; 8]>, NodeAddr);
/// Draw a fresh per-session rekey jitter from `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`.
fn draw_rekey_jitter() -> i64 {
rand::rng().random_range(-REKEY_JITTER_SECS..=REKEY_JITTER_SECS)
@@ -248,6 +261,14 @@ pub struct ActivePeer {
/// Remote peer's startup epoch (from handshake). Used to detect restarts.
remote_epoch: Option<[u8; 8]>,
// === Negotiated Profile ===
/// Peer's node profile (Full, NonRouting, Leaf).
peer_profile: NodeProfile,
/// Whether to send sender reports to this peer (our provides_sr AND peer wants_sr).
send_sr: bool,
/// Whether to send receiver reports to this peer (our provides_rr AND peer wants_rr).
send_rr: bool,
// === Heartbeat ===
/// When we last sent a heartbeat to this peer.
last_heartbeat_sent: Option<Instant>,
@@ -281,6 +302,25 @@ pub struct ActivePeer {
/// In-progress rekey: number of msg1 retransmissions performed so far.
rekey_msg1_resend_count: u32,
// === Rekey Responder State (XX pattern) ===
/// In-progress rekey responder: Noise handshake state awaiting msg3.
rekey_responder_handshake: Option<NoiseHandshakeState>,
/// In-progress rekey responder: our new session index.
rekey_responder_our_index: Option<SessionIndex>,
// === Rekey msg3 retransmission (initiator liveness) ===
/// Retained wire-format rekey msg3, resent until the responder is
/// confirmed on the new epoch. Mirrors the FSP
/// `rekey_msg3_payload` mechanism (node/session/mod.rs). Liveness only:
/// overlapping-epoch decrypt covers cutover skew; retransmission
/// guarantees the responder eventually derives the new session even
/// if the first msg3 datagram is lost.
rekey_msg3_payload: Option<Vec<u8>>,
/// Next msg3 resend timestamp (Unix ms; 0 = none retained).
rekey_msg3_next_resend_ms: u64,
/// Number of msg3 retransmissions performed this rekey cycle.
rekey_msg3_resend_count: u32,
// === Published active-send-state (two-tier boundary) ===
/// The send-critical subset read (and, on roam/responder-cutover, written)
/// directly by the data plane. See `PeerSendState`.
@@ -310,6 +350,9 @@ impl ActivePeer {
pending_filter_update: true, // Send filter on new connection
authenticated_at,
remote_epoch: None,
peer_profile: NodeProfile::Full,
send_sr: true,
send_rr: true,
last_heartbeat_sent: None,
handshake_msg2: None,
session_established_at: now,
@@ -321,6 +364,11 @@ impl ActivePeer {
rekey_msg1: None,
rekey_msg1_next_resend: 0,
rekey_msg1_resend_count: 0,
rekey_responder_handshake: None,
rekey_responder_our_index: None,
rekey_msg3_payload: None,
rekey_msg3_next_resend_ms: 0,
rekey_msg3_resend_count: 0,
send: PeerSendState::new(link_id, now, authenticated_at),
}
}
@@ -355,10 +403,18 @@ impl ActivePeer {
transport_id: TransportId,
current_addr: TransportAddr,
link_stats: LinkStats,
is_initiator: bool,
_is_initiator: bool,
mmp_config: &MmpConfig,
remote_epoch: Option<[u8; 8]>,
our_profile: NodeProfile,
peer_profile: NodeProfile,
) -> Self {
// Compute MMP report gating: A sends to B iff A.provides AND B.wants
let our_neg = NegotiationPayload::fmp(0, 0, our_profile);
let their_neg = NegotiationPayload::fmp(0, 0, peer_profile);
let send_sr = our_neg.provides_sr() && their_neg.wants_sr();
let send_rr = our_neg.provides_rr() && their_neg.wants_rr();
let now = Instant::now();
let mut send = PeerSendState::new(link_id, now, authenticated_at);
send.noise_session = Some(noise_session);
@@ -371,7 +427,6 @@ impl ActivePeer {
mmp_config.mode,
mmp_config.log_interval_secs,
mmp_config.owd_window_size,
is_initiator,
));
Self {
npub: identity.npub(),
@@ -389,6 +444,9 @@ impl ActivePeer {
pending_filter_update: true,
authenticated_at,
remote_epoch,
peer_profile,
send_sr,
send_rr,
last_heartbeat_sent: None,
handshake_msg2: None,
session_established_at: now,
@@ -400,6 +458,11 @@ impl ActivePeer {
rekey_msg1: None,
rekey_msg1_next_resend: 0,
rekey_msg1_resend_count: 0,
rekey_responder_handshake: None,
rekey_responder_our_index: None,
rekey_msg3_payload: None,
rekey_msg3_next_resend_ms: 0,
rekey_msg3_resend_count: 0,
send,
}
}
@@ -661,6 +724,23 @@ impl ActivePeer {
self.remote_epoch = remote_epoch;
}
// === Negotiated Profile ===
/// Get peer's node profile.
pub fn peer_profile(&self) -> NodeProfile {
self.peer_profile
}
/// Whether to send sender reports to this peer.
pub fn send_sr(&self) -> bool {
self.send_sr
}
/// Whether to send receiver reports to this peer.
pub fn send_rr(&self) -> bool {
self.send_rr
}
// === Tree Accessors ===
/// Get the peer's tree coordinates, if known.
@@ -937,18 +1017,6 @@ impl ActivePeer {
self.session_established_at
}
/// Test-only seam: backdate the session-established instant so a test can
/// construct a session that reads as `age`-old. This only shifts the
/// private timestamp field; it changes no decision logic, no threshold, and
/// is compiled out of release builds.
#[cfg(test)]
pub(crate) fn test_backdate_session_established(&mut self, age: std::time::Duration) {
self.session_established_at = self
.session_established_at
.checked_sub(age)
.unwrap_or_else(Instant::now);
}
/// Test-only seam: install link-layer MMP state with a chosen operating
/// mode on a peer that was constructed without a Noise session (the bare
/// `new` constructor leaves `mmp` as `None`). This only attaches the same
@@ -964,10 +1032,29 @@ impl ActivePeer {
config.mode,
config.log_interval_secs,
config.owd_window_size,
true,
));
}
/// Shift `instant` back by `age`, panicking rather than falling back.
///
/// `Instant::checked_sub` returns `None` when the result would precede the
/// monotonic clock's epoch, which on a freshly booted machine means any
/// backdate larger than the uptime. The fallback these seams used to share
/// was `unwrap_or_else(Instant::now)`, which moved the timestamp *forward*
/// to now — the opposite of every caller's intent — so the caller's
/// assertion then failed for a reason its message did not describe. Failing
/// loudly keeps a machine-dependent limit from reading as a logic bug.
#[cfg(test)]
fn backdate(instant: Instant, age: std::time::Duration, what: &str) -> Instant {
instant.checked_sub(age).unwrap_or_else(|| {
panic!(
"cannot backdate {what} by {age:?}: the monotonic clock's epoch is more \
recent than that, so the result is unrepresentable. This machine has been \
up for less than the requested age use a smaller backdate."
)
})
}
/// Test-only seam: backdate the session-start instant so a test can make
/// `session_elapsed_ms()` read as `age`-old (needed to synthesize a
/// positive RTT sample from a crafted ReceiverReport). This only shifts the
@@ -975,11 +1062,35 @@ impl ActivePeer {
/// is compiled out of release builds.
#[cfg(test)]
pub(crate) fn test_backdate_session_start(&mut self, age: std::time::Duration) {
self.send.session_start = self
.send
.session_start
.checked_sub(age)
.unwrap_or_else(Instant::now);
self.send.session_start = Self::backdate(self.send.session_start, age, "session_start");
}
/// Test-only seam: backdate the session-established instant so a test can
/// make `session_established_at().elapsed()` read as `age`-old (needed to
/// fire the age half of the rekey trigger without waiting for it). This only
/// shifts the private timestamp field; it changes no decision logic, no
/// threshold, and is compiled out of release builds.
///
/// Backdate past the whole jitter band, not the nominal `after_secs`: the
/// effective trigger is `after_secs + jitter` with jitter drawn from
/// `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`, so a smaller margin makes the
/// test depend on the draw.
#[cfg(test)]
pub(crate) fn test_backdate_session_established(&mut self, age: std::time::Duration) {
self.session_established_at =
Self::backdate(self.session_established_at, age, "session_established_at");
}
/// Test-only seam: backdate the drain-start instant so a test can make
/// `drain_expired()` read true without waiting out the drain window. Only
/// shifts the private timestamp field, and is a no-op when no drain is in
/// progress, so it cannot manufacture a drain that did not happen. It changes
/// no decision logic, no threshold, and is compiled out of release builds.
#[cfg(test)]
pub(crate) fn test_backdate_drain_start(&mut self, age: std::time::Duration) {
if let Some(started) = self.send.drain_started {
self.send.drain_started = Some(Self::backdate(started, age, "drain_started"));
}
}
/// Per-session symmetric rekey-timer jitter offset (seconds).
@@ -1187,6 +1298,7 @@ impl ActivePeer {
self.rekey_msg1_next_resend = 0;
self.rekey_msg1_resend_count = 0;
self.rekey_in_progress = false;
self.clear_rekey_msg3_payload();
// Return whichever index needs freeing
self.rekey_our_index.take().or_else(|| {
self.send.pending_new_session = None;
@@ -1218,15 +1330,24 @@ impl ActivePeer {
self.rekey_our_index
}
/// Complete the rekey by processing msg2 (initiator side).
/// Complete the rekey by processing msg2 (initiator side, XX pattern).
///
/// Takes the stored handshake state, reads msg2, and returns the
/// completed NoiseSession. Clears the handshake-related fields but
/// leaves rekey_our_index for set_pending_session to use.
/// Takes the stored handshake state, reads XX msg2, generates XX msg3, and
/// returns (msg3_bytes, completed NoiseSession, remote startup epoch,
/// learned peer node address). Clears the handshake-related fields but
/// leaves rekey_our_index for set_pending_session to use. The remote epoch
/// is surfaced so the caller can detect a peer restart (changed epoch)
/// during recovery rekey; the learned node address is surfaced so the
/// caller can gate the install on static-key continuity.
///
/// Completing the handshake here is deliberately identity-agnostic: this
/// is the crypto leaf, and whether the learned identity may replace the
/// peer's session is a decision, taken by the caller against the FMP core.
pub fn complete_rekey_msg2(
&mut self,
msg2_bytes: &[u8],
) -> Result<(NoiseSession, Option<[u8; 8]>), NoiseError> {
our_profile: NodeProfile,
) -> Result<RekeyMsg2Completion, NoiseError> {
let mut hs = self
.rekey_handshake
.take()
@@ -1235,16 +1356,147 @@ impl ActivePeer {
got: "no handshake state".to_string(),
})?;
hs.read_message_2(msg2_bytes)?;
// Split msg2 into base XX part and any extra (negotiation payload)
let base_size = crate::noise::HANDSHAKE_MSG2_SIZE;
let (base_msg2, extra) = if msg2_bytes.len() > base_size {
(&msg2_bytes[..base_size], Some(&msg2_bytes[base_size..]))
} else {
(msg2_bytes, None)
};
hs.read_message_2(base_msg2)?;
// The remote static identity (and its startup epoch) is available once
// msg2 has been read; capture it for peer-restart detection.
let remote_epoch = hs.remote_epoch();
// Must decrypt negotiation payload (if present) to keep hash chain
// in sync, even though rekey doesn't use the negotiation result.
if let Some(encrypted_neg) = extra {
let _ = hs.decrypt_payload(encrypted_neg)?;
}
// Declare this handshake a rekey of the session already installed, naming
// the index the RESPONDER receives on (our `their_index`) so it can match
// the marker against its own `our_index`. Without it the responder cannot
// tell a rekey from a fresh dial — both arrive as a new msg1 on a new
// link — and every attempt to infer that from local state has a failure
// band. The pre-rekey session is still installed at this point; the
// pending one is not set until the caller drives it.
//
// An absent `their_index` would silently omit the marker and put this
// rekey back on the cross-connection path — the defect verbatim. It is
// unreachable today (the rekey cadence filters on `has_session`, and
// every production peer is built by `with_session`, which sets the
// index), so it is asserted rather than handled: a silent `if let` is
// the wrong shape for a value whose absence reintroduces the bug.
let mut msg3 = hs.write_message_3()?;
match self.their_index() {
Some(their_index) => {
let marker = NegotiationPayload::fmp(1, 1, our_profile)
.with_rekey_of(their_index)
.encode();
let encrypted = hs.encrypt_payload(&marker)?;
msg3.extend_from_slice(&encrypted);
}
None => {
debug_assert!(false, "rekeying peer has no their_index to declare");
tracing::warn!(
"Rekey msg3 built with no session index to declare; \
the peer will read it as a fresh dial"
);
}
}
let session = hs.into_session()?;
// Derive the learned identity from the session rather than the consumed
// handshake so the address returned is provably the one the session
// about to be installed is bound to.
let learned_peer = NodeAddr::from_pubkey(&session.remote_static_xonly());
// Clear msg1 resend state
self.rekey_msg1 = None;
self.rekey_msg1_next_resend = 0;
self.rekey_msg1_resend_count = 0;
Ok((session, remote_epoch))
Ok((msg3, session, remote_epoch, learned_peer))
}
/// Complete the rekey by processing msg3 (responder side, XX pattern).
///
/// Takes the stored responder handshake state, reads XX msg3, and returns
/// the completed NoiseSession.
pub fn complete_rekey_msg3(&mut self, msg3_bytes: &[u8]) -> Result<NoiseSession, NoiseError> {
let mut hs =
self.rekey_responder_handshake
.take()
.ok_or_else(|| NoiseError::WrongState {
expected: "rekey responder handshake awaiting msg3".to_string(),
got: "no responder handshake state".to_string(),
})?;
// Split msg3 into base XX part and any extra (negotiation payload)
let base_size = crate::noise::HANDSHAKE_MSG3_SIZE;
let (base_msg3, extra) = if msg3_bytes.len() > base_size {
(&msg3_bytes[..base_size], Some(&msg3_bytes[base_size..]))
} else {
(msg3_bytes, None)
};
hs.read_message_3(base_msg3)?;
// Must decrypt negotiation payload (if present) to keep hash chain
// in sync, even though rekey doesn't use the negotiation result.
if let Some(encrypted_neg) = extra {
let _ = hs.decrypt_payload(encrypted_neg)?;
}
let session = hs.into_session()?;
self.rekey_responder_our_index = None;
Ok(session)
}
// === Rekey msg3 retransmission (initiator liveness) ===
/// Retain the rekey msg3 wire payload for retransmission until the
/// responder is confirmed on the new epoch. Called by the initiator
/// right after the first successful msg3 send. Mirrors the FSP
/// `SessionEntry::set_rekey_msg3_payload`.
pub fn set_rekey_msg3_payload(&mut self, payload: Vec<u8>, next_resend_at_ms: u64) {
self.rekey_msg3_payload = Some(payload);
self.rekey_msg3_next_resend_ms = next_resend_at_ms;
self.rekey_msg3_resend_count = 0;
}
/// Get the retained rekey msg3 payload for retransmission.
pub fn rekey_msg3_payload(&self) -> Option<&[u8]> {
self.rekey_msg3_payload.as_deref()
}
/// Get the next msg3 resend timestamp (Unix ms; 0 = none retained).
pub fn rekey_msg3_next_resend_ms(&self) -> u64 {
self.rekey_msg3_next_resend_ms
}
/// Get the number of msg3 retransmissions this cycle.
pub fn rekey_msg3_resend_count(&self) -> u32 {
self.rekey_msg3_resend_count
}
/// Record a msg3 retransmission and schedule the next one.
pub fn record_rekey_msg3_resend(&mut self, next_resend_at_ms: u64) {
self.rekey_msg3_resend_count += 1;
self.rekey_msg3_next_resend_ms = next_resend_at_ms;
}
/// Clear the retained rekey msg3 payload (responder confirmed on the
/// new epoch, or the rekey cycle was abandoned).
pub fn clear_rekey_msg3_payload(&mut self) {
self.rekey_msg3_payload = None;
self.rekey_msg3_next_resend_ms = 0;
self.rekey_msg3_resend_count = 0;
}
/// Check if msg1 needs resending.
@@ -1272,18 +1524,74 @@ impl ActivePeer {
self.rekey_msg1_resend_count += 1;
self.rekey_msg1_next_resend = next_ms;
}
// === Rekey Responder State (XX pattern) ===
/// Whether this peer has a rekey responder handshake awaiting msg3.
pub fn has_rekey_responder_handshake(&self) -> bool {
self.rekey_responder_handshake.is_some()
}
/// Get the rekey responder our_index.
pub fn rekey_responder_our_index(&self) -> Option<SessionIndex> {
self.rekey_responder_our_index
}
/// Store rekey responder handshake state after sending msg2.
///
/// Called when processing a rekey msg1 from the peer. The handshake
/// state is held here until msg3 arrives to complete the rekey.
pub fn set_rekey_responder_state(
&mut self,
handshake: NoiseHandshakeState,
our_index: SessionIndex,
) {
self.rekey_responder_handshake = Some(handshake);
self.rekey_responder_our_index = Some(our_index);
}
/// Clear rekey responder state (on failure or abandonment).
pub fn clear_rekey_responder(&mut self) {
self.rekey_responder_handshake = None;
self.rekey_responder_our_index = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Identity;
use crate::noise::HandshakeState;
fn make_peer_identity() -> PeerIdentity {
let identity = Identity::generate();
PeerIdentity::from_pubkey(identity.pubkey())
}
/// A completed XX handshake, returning the initiator's session. Only the
/// session's existence matters here: this test is about `with_session`
/// populating the npub cache from the identity it is handed.
fn xx_session_pair() -> (NoiseSession, NoiseSession) {
let initiator_id = Identity::generate();
let responder_id = Identity::generate();
let mut initiator = HandshakeState::new_initiator(initiator_id.keypair());
initiator.set_local_epoch([0xA1, 0xB2, 0xC3, 0xD4, 0x11, 0x22, 0x33, 0x44]);
let mut responder = HandshakeState::new_responder(responder_id.keypair());
responder.set_local_epoch([0xD4, 0xC3, 0xB2, 0xA1, 0x44, 0x33, 0x22, 0x11]);
let msg1 = initiator.write_message_1().unwrap();
responder.read_message_1(&msg1).unwrap();
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
let msg3 = initiator.write_message_3().unwrap();
responder.read_message_3(&msg3).unwrap();
(
initiator.into_session().unwrap(),
responder.into_session().unwrap(),
)
}
fn make_node_addr(val: u8) -> NodeAddr {
let mut bytes = [0u8; 16];
bytes[0] = val;
@@ -1336,7 +1644,7 @@ mod tests {
// `with_session` builds its own struct literal, so it needs its
// own check that the cache is populated from the same identity.
let identity = make_peer_identity();
let (session, _peer_session) = ik_session_pair();
let (session, _peer_session) = xx_session_pair();
let peer = ActivePeer::with_session(
identity,
@@ -1351,6 +1659,8 @@ mod tests {
true,
&MmpConfig::default(),
None,
NodeProfile::Full,
NodeProfile::Full,
);
assert_eq!(peer.npub(), identity.npub());
@@ -1588,9 +1898,8 @@ mod tests {
/// Put a peer into a rekey-in-progress state with a real (initiator)
/// handshake so the msg1 resend budget can be exercised.
fn arm_rekey(peer: &mut ActivePeer) {
let remote = Identity::generate();
let local = Identity::generate();
let hs = NoiseHandshakeState::new_initiator(local.keypair(), remote.pubkey_full());
let hs = NoiseHandshakeState::new_initiator(local.keypair());
peer.set_rekey_state(hs, SessionIndex::new(7), vec![0xAB; 64], 0);
}
@@ -1632,149 +1941,4 @@ mod tests {
assert!(peer.rekey_msg1().is_none());
assert_eq!(peer.rekey_msg1_resend_count(), 0);
}
// === FMP rekey cutover: authenticate-before-promote ===
//
// IK-adapted analogue of the FSP trial-decrypt tests
// (node/session/mod.rs `trial_decrypt_picks_pending_and_promotes` /
// `trial_decrypt_failed_slot_leaves_replay_window_intact`). The FMP
// cutover is gated on an authenticated decrypt against `pending`, not
// the bare header K-bit. These tests exercise that primitive:
// `pending_new_session_mut()` trial-decrypt followed by
// `handle_peer_kbit_flip()` promotion.
/// Complete an IK handshake and return the (sender, receiver) session
/// pair. The receiver decrypts what the sender seals.
fn ik_session_pair() -> (NoiseSession, NoiseSession) {
let initiator_id = Identity::generate();
let responder_id = Identity::generate();
let mut initiator =
NoiseHandshakeState::new_initiator(initiator_id.keypair(), responder_id.pubkey_full());
initiator.set_local_epoch([0xA1, 0xB2, 0xC3, 0xD4, 0x11, 0x22, 0x33, 0x44]);
let mut responder = NoiseHandshakeState::new_responder(responder_id.keypair());
responder.set_local_epoch([0xD4, 0xC3, 0xB2, 0xA1, 0x44, 0x33, 0x22, 0x11]);
let msg1 = initiator.write_message_1().unwrap();
responder.read_message_1(&msg1).unwrap();
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
(
initiator.into_session().unwrap(),
responder.into_session().unwrap(),
)
}
/// Seal an FMP frame the way the send path does: returns
/// `(ciphertext, counter, header_bytes)` for the given K-bit.
fn seal_fmp(
sender: &mut NoiseSession,
receiver_idx: SessionIndex,
plaintext: &[u8],
k_bit: bool,
) -> (Vec<u8>, u64, [u8; 16]) {
use crate::proto::fmp::wire::{FLAG_KEY_EPOCH, build_established_header};
let counter = sender.current_send_counter();
let flags = if k_bit { FLAG_KEY_EPOCH } else { 0 };
let header = build_established_header(receiver_idx, counter, flags, plaintext.len() as u16);
let ciphertext = sender.encrypt_with_aad(plaintext, &header).unwrap();
(ciphertext, counter, header)
}
/// Build a peer whose `current` slot is `current_recv`.
fn peer_with_current(current_recv: NoiseSession) -> ActivePeer {
let identity = make_peer_identity();
ActivePeer::with_session(
identity,
LinkId::new(1),
1_000,
current_recv,
SessionIndex::new(1),
SessionIndex::new(2),
TransportId::new(1),
TransportAddr::from_string("hci0/AA:BB:CC:DD:EE:01"),
LinkStats::new(),
true,
&MmpConfig::default(),
None,
)
}
// A genuine new-epoch frame authenticates against `pending` and the
// peer promotes: pending -> current, K-bit flips, plaintext delivered.
#[test]
fn cutover_pending_authenticates_and_promotes() {
let (_cur_send, cur_recv) = ik_session_pair();
let (mut pend_send, pend_recv) = ik_session_pair();
let mut peer = peer_with_current(cur_recv);
let k_before = peer.current_k_bit();
peer.set_pending_session(pend_recv, SessionIndex::new(3), SessionIndex::new(4));
// Peer sealed in the new epoch with the flipped K-bit.
let (ct, counter, hdr) = seal_fmp(
&mut pend_send,
SessionIndex::new(3),
b"new-epoch",
!k_before,
);
// Trial-decrypt against pending succeeds (the cutover signal).
let plaintext = peer
.pending_new_session_mut()
.and_then(|p| p.decrypt_with_replay_check_and_aad(&ct, counter, &hdr).ok())
.expect("new-epoch frame must authenticate against pending");
assert_eq!(plaintext, b"new-epoch");
// Promotion moves pending -> current and flips the K-bit.
assert!(peer.handle_peer_kbit_flip().is_some());
assert!(peer.pending_new_session().is_none());
assert_eq!(peer.current_k_bit(), !k_before);
assert!(peer.previous_session().is_some());
}
// A stale/mismatched frame on a K-bit flip does NOT authenticate
// against `pending`: no promotion, `pending` preserved with its replay
// window intact, and the genuine current session still decrypts a
// subsequent steady-state frame.
#[test]
fn cutover_stale_frame_does_not_promote() {
let (mut cur_send, cur_recv) = ik_session_pair();
let (_pend_send, pend_recv) = ik_session_pair();
// A third, unrelated session whose ciphertext will NOT authenticate
// against `pending` (wrong keys) — simulates a flip belonging to a
// different rekey epoch.
let (mut stale_send, _stale_recv) = ik_session_pair();
let mut peer = peer_with_current(cur_recv);
let k_before = peer.current_k_bit();
peer.set_pending_session(pend_recv, SessionIndex::new(3), SessionIndex::new(4));
// Frame carries the flipped K-bit but is sealed in an unrelated
// session: it must fail to authenticate against `pending`.
let (ct, counter, hdr) =
seal_fmp(&mut stale_send, SessionIndex::new(3), b"stale", !k_before);
let result = peer
.pending_new_session_mut()
.and_then(|p| p.decrypt_with_replay_check_and_aad(&ct, counter, &hdr).ok());
assert!(
result.is_none(),
"stale frame must not authenticate against pending"
);
// No promotion happened: pending preserved, K-bit unchanged.
assert!(peer.pending_new_session().is_some());
assert_eq!(peer.current_k_bit(), k_before);
// The trial-decrypt left pending's replay window untouched, and the
// genuine current session still decrypts steady-state traffic — the
// fall-through path the handler takes on a non-authenticating flip.
let (ct2, counter2, hdr2) =
seal_fmp(&mut cur_send, SessionIndex::new(1), b"steady", k_before);
let cur_pt = peer.noise_session_mut().and_then(|s| {
s.decrypt_with_replay_check_and_aad(&ct2, counter2, &hdr2)
.ok()
});
assert_eq!(cur_pt.as_deref(), Some(&b"steady"[..]));
}
}
+1296 -708
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -57,6 +57,7 @@ impl<'a> Reader<'a> {
pub(crate) fn read_u16_le(&mut self) -> Result<u16, Error> {
Ok(u16::from_le_bytes(self.read_array::<2>()?))
}
#[allow(dead_code)]
pub(crate) fn read_u32_le(&mut self) -> Result<u32, Error> {
Ok(u32::from_le_bytes(self.read_array::<4>()?))
}
@@ -91,6 +92,7 @@ impl Writer {
pub(crate) fn write_u16_le(&mut self, v: u16) {
self.buf.extend_from_slice(&v.to_le_bytes());
}
#[allow(dead_code)]
pub(crate) fn write_u32_le(&mut self, v: u32) {
self.buf.extend_from_slice(&v.to_le_bytes());
}
+530 -171
View File
@@ -11,16 +11,19 @@
//!
//! The establish leaf's Noise wire construction and `promote_connection`
//! effects stay shell-side; handshake message bytes are carried as opaque blobs
//! only. The **inbound classification** decision, however, is modelled here:
//! only. The XX **inbound classification** decision, however, is modelled here:
//! [`Fmp::establish_inbound`] maps an [`EstablishSnapshot`] + [`WireOutcome`]
//! onto an [`InboundDecision`] the shell dispatches (E3). The outbound
//! (`handle_msg2`) classification and the born-on-next `handle_msg3` leaf remain
//! shell-side.
//! onto an [`InboundDecision`] the shell dispatches for `handle_msg3`. The
//! born-on-next Noise leaf (`handle_msg{1,2,3}` crypto) remains shell-side.
use super::state::Fmp;
use super::wire::{
FMP_FEAT_PROFILE_MASK, FMP_FEAT_PROVIDES_RR, FMP_FEAT_PROVIDES_SR, FMP_FEAT_WANTS_RR,
FMP_FEAT_WANTS_SR, NegotiationPayload, NodeProfile,
};
use crate::NodeAddr;
use crate::proto::Error;
use crate::transport::LinkId;
use crate::utils::index::SessionIndex;
use crate::{NodeAddr, PeerIdentity};
/// Determine winner of cross-connection tie-breaker.
///
@@ -154,6 +157,12 @@ pub(crate) struct PeerSnapshot {
/// Local rekey initiation is dampened after a recently received peer rekey
/// msg1 (pre-evaluated against the dampening timer).
pub is_dampened: bool,
/// The initiator already cut over on its own timer but is still
/// retransmitting this cycle's rekey msg3 to a responder not yet confirmed
/// on the new epoch. Suppresses starting a fresh rekey (which would
/// overwrite the retained payload) until the msg3 is delivered or its
/// budget is exhausted. Mirrors FSP `check_session_rekey`.
pub rekey_msg3_pending: bool,
/// Monotonic session age in seconds (`session_established_at().elapsed()`).
pub elapsed_secs: u64,
/// Current Noise send counter (0 when there is no session).
@@ -177,79 +186,105 @@ pub(crate) struct RekeyResendSnapshot {
pub msg1: Vec<u8>,
}
/// The rekey trigger thresholds, read shell-side from node config.
/// The rekey cadence config, read shell-side from node config.
pub(crate) struct RekeyCfg {
/// This node initiates rekeys (`node.rekey.enabled`).
///
/// It governs the initiating half of the cadence only: the trigger, and the
/// polled cutover that follows an initiation. Finishing the drain of a rekey
/// already accepted as responder is housekeeping rather than initiation, and
/// runs whatever this says — a node that never initiates still has drains to
/// complete, because a peer's rekey it accepted demoted a live session.
pub initiate: bool,
/// Rekey after this many seconds of session age (before jitter).
pub after_secs: u64,
/// Rekey after this many sent messages.
pub after_messages: u64,
}
/// The result of the shell-side Noise wire step (Phase B) for one inbound
/// handshake msg1, handed to the establish decision core.
/// The wire-learned facts about one inbound XX `msg3`, handed to the establish
/// decision core.
///
/// The Noise step (`receive_handshake_init`) runs on the control machine: it
/// reads **no** `Node` registry state — the essential invariant of this
/// decomposition — and yields the learned peer identity, the
/// remote startup epoch, the sender's session index, and the opaque msg2 noise
/// payload to frame and send. The core never parses or builds Noise bytes; the
/// payload is an opaque blob.
/// On XX the responder learns the initiator's identity and startup epoch only
/// once `msg3` completes the Noise handshake (unlike IK, which learns them at
/// `msg1`). The shell-side Noise step (`complete_handshake_msg3`) runs on the
/// control machine and reads **no** `Node` registry state — the essential
/// invariant of this decomposition — and yields these; the core reads them to
/// classify. The Noise bytes themselves never reach the core — the fresh
/// session extraction stays a shell effect. Only the two facts the
/// classification depends on travel here: the peer's node address (for the
/// smaller-NodeAddr tie-breaks) and the peer's captured startup epoch (for
/// restart detection).
pub(crate) struct WireOutcome {
/// Peer identity learned from the handshake (msg1 static key).
pub peer_identity: PeerIdentity,
/// The peer's startup epoch captured from msg1, if present.
/// The initiator's node address, learned from the `msg3` static key. Drives
/// both the cross-connection and the dual-init tie-breaks.
pub peer_node_addr: NodeAddr,
/// The initiator's startup epoch captured from the handshake, if present.
/// `None` when no epoch was carried (treated as same-epoch, never a
/// restart).
pub remote_epoch: Option<[u8; 8]>,
/// The sender's session index from the msg1 header (becomes our
/// `receiver_idx`/`their_index` in the msg2 response and the promotion).
pub their_index: SessionIndex,
/// The opaque Noise msg2 payload the responder produced (empty only if no
/// msg2 is to be sent).
pub msg2_payload: Vec<u8>,
}
/// A snapshot of the `Node` registry state the inbound establish decision reads
/// about the peer identified in a just-processed msg1, taken by the shell so the
/// A snapshot of the `Node` registry state the inbound XX establish decision
/// reads about the peer whose `msg3` just completed, taken by the shell so the
/// core decides without touching live `Node` state or reading a clock.
///
/// Produced by the [`EstablishView`] read-seam. Every clock read
/// (`existing_session_age_secs`) is resolved shell-side into a plain `u64`, the
/// same monotonic-ages asymmetry the rekey snapshot uses.
/// The decision reads no clock at all. It formerly carried a session age and a
/// config-derived age floor, both resolved shell-side; the sender's declaration
/// in [`rekey_claim`](EstablishSnapshot::rekey_claim) replaced them, so there is
/// no timing left for a clock step to perturb.
pub(crate) struct EstablishSnapshot {
/// The peer is already an active peer in the registry.
/// The peer identity is already an active peer in the registry. `false` here
/// is the net-new path (or the post-restart re-promote) — a plain promote.
pub has_existing_peer: bool,
/// The existing active peer's captured remote startup epoch, if any.
pub existing_peer_epoch: Option<[u8; 8]>,
/// Monotonic age in seconds of the existing peer's session
/// (`session_established_at().elapsed()`), resolved shell-side. `0` when
/// there is no existing peer.
pub existing_session_age_secs: u64,
/// The existing peer has an established Noise session.
pub has_session: bool,
/// The existing peer's session is healthy.
pub is_healthy: bool,
/// The existing peer already holds a pending post-rekey session awaiting
/// K-bit cutover.
/// K-bit cutover. On XX this is one of the two dual-init tie-break states
/// (the widened window IK never reached) — NOT an unconditional reject.
pub pending_new_session: bool,
/// The existing peer has a rekey handshake in flight.
/// The existing peer has a rekey handshake in flight (the other dual-init
/// tie-break state).
pub rekey_in_progress: bool,
/// The existing peer's stored msg2 wire bytes (an opaque blob), resent on a
/// same-epoch duplicate msg1. `None` when there is no existing peer or it
/// has no stored msg2.
/// The existing peer's stored `msg2` wire bytes (an opaque blob), resent on
/// a same-epoch duplicate `msg3`. `None` when there is no existing peer or
/// it has no stored `msg2`.
pub existing_msg2: Option<Vec<u8>>,
/// Admitting this peer as a net-new identity would exceed `max_peers`
/// (pre-evaluated `max_peers > 0 && peers.len() >= max_peers`).
pub at_max_peers: bool,
/// A pending outbound connection to this same peer identity already exists
/// (a cross-connection in progress); bypasses the max-peers cap.
pub has_pending_outbound_to_peer: bool,
/// Whether the local rekey trigger is enabled in config (gates treating a
/// same-epoch msg1 from an established peer as a rekey rather than a
/// duplicate).
pub rekey_enabled: bool,
/// This node's own address, for the dual-initiation tie-break.
/// The `msg3` arrived on a *different* link than the existing peer's active
/// link (`existing_peer.link_id() != link_id`). Required for the inline
/// cross-connection branch: a `msg3` on the same link is never a
/// cross-connection.
pub different_link: bool,
/// What the sender's `msg3` declares about replacing an existing session,
/// resolved shell-side against the peer at `wire.peer_node_addr`.
pub rekey_claim: RekeyClaim,
/// This node's own address, for both tie-breaks (the smaller NodeAddr wins).
pub our_node_addr: NodeAddr,
}
/// What an inbound `msg3` declares about replacing a session we already hold.
///
/// The sender knows whether it is rekeying; the responder cannot infer it, since
/// a rekey and a fresh dial both arrive as a new `msg1` on a new link. The msg3
/// negotiation TLV carries the sender's answer and the shell resolves it into
/// this three-valued input — resolved shell-side because matching the declared
/// index against the session we hold is a registry read, and because it must be
/// matched only against the peer the handshake authenticated as, never resolved
/// through a global index map.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RekeyClaim {
/// No marker: the sender says this handshake is not a rekey.
None,
/// The marker names the session we hold for this peer.
Matches,
/// The marker names a session that is not the one we hold.
Mismatch,
}
/// A snapshot of the registry state the *outbound* establish decision reads
/// about the peer whose msg2 just completed our handshake, taken by the shell.
///
@@ -265,12 +300,54 @@ pub(crate) struct OutboundSnapshot {
pub our_outbound_wins: bool,
}
/// A snapshot of the two identities the *initiator's rekey* `msg2` completion
/// decision compares, taken by the shell once the Noise step has run.
///
/// A rekey `msg2` is dispatched to its peer by the session index the initiator
/// itself allocated, which travels in the cleartext rekey-`msg1` header and is
/// therefore observable on path. Under XX the responder's static is *learned*
/// from `msg2` rather than pinned a priori (as it was under IK), so a
/// cryptographically valid `msg2` on its own proves only that *someone*
/// answered — not that the peer we are already bonded to answered. This
/// snapshot carries the two facts that settle it; no Noise material reaches the
/// core.
pub(crate) struct RekeyMsg2Snapshot {
/// The node address of the established peer whose rekey this `msg2` claims
/// to complete — the `Node::peers` key the rekey index resolved to.
pub established_peer: NodeAddr,
/// The node address derived from the static key learned in this `msg2`,
/// i.e. the identity the completed Noise session would actually be bound to.
pub learned_peer: NodeAddr,
}
/// A snapshot of the two identities the *initial outbound* `msg2` completion
/// decision compares, taken by the shell once the Noise step has run.
///
/// The sibling of [`RekeyMsg2Snapshot`] one rung earlier in the lifecycle: the
/// rekey gate protects a link that is already established, this one protects the
/// link being formed. Same root cause — XX *learns* the responder's static from
/// `msg2` instead of pinning it a priori as IK did, so a cryptographically valid
/// `msg2` proves only that *someone* answered the dial, never that the peer we
/// meant to reach answered it. No Noise material reaches the core.
pub(crate) struct DialMsg2Snapshot {
/// The node address this leg was dialed at, or `None` if the dial named no
/// peer.
///
/// `None` is an *anonymous* leg — a shared-media beacon dial, whose beacon
/// asserts no identity for anyone to substitute — and it is decided locally
/// when the leg is built, never from anything on the wire, so no peer can
/// steer an identified dial into the `None` branch.
pub dialed_peer: Option<NodeAddr>,
/// The node address derived from the static key learned in this `msg2`,
/// i.e. the identity this leg would actually be promoted under.
pub learned_peer: NodeAddr,
}
/// A registry/transport effect the async shell performs on the core's behalf.
///
/// The maintain/teardown subset (`Teardown`..`ResendRekeyMsg1`) covers the
/// tick-poll half of the lifecycle. The establish-machine subset
/// (`PromoteToActive`..) is the master-side IK handshake decision; the shell
/// executes each, resolving the ambient identity/time/wire payload it needs.
/// The scaffold subset covers the maintain/teardown half of the lifecycle. The
/// establish-leaf variants (`SendMsg2`, `PromoteToActive`) are deferred to the
/// born-on-next handshake component and are intentionally absent.
pub(crate) enum ConnAction {
/// Tear down and free the handshake connection on `link`
/// (`cleanup_stale_connection`): frees the session index, removes the
@@ -341,59 +418,66 @@ pub(crate) trait LifecycleView {
fn rekey_resend_candidates(&self, now_ms: u64) -> Vec<RekeyResendSnapshot>;
}
/// The classification outcome for one inbound handshake msg1, decided purely
/// from the [`EstablishSnapshot`] and [`WireOutcome`]. The shell matches on this
/// and drives the effects; the core consumes nothing and touches no live state.
/// The classification outcome for one inbound XX `msg3`, decided purely from the
/// [`EstablishSnapshot`] and [`WireOutcome`]. The shell matches on this and
/// drives the effects; the core consumes nothing and touches no live state.
///
/// The variants map one-to-one onto the pre-refactor inline branches of
/// `handle_msg1`'s post-crypto classification. There is deliberately **no**
/// inbound cross-connection won/lost variant: an existing same-identity peer is
/// always intercepted here first (restart / rekey / duplicate), and a net-new
/// [`Promote`](InboundDecision::Promote) reaches `promote_connection` with no
/// existing peer — so on the inbound path the tie-break never fires. The real
/// cross-connection resolution lives in `handle_msg2` (outbound completion).
/// This is a **superset** of the IK inbound decision. Because XX learns the
/// initiator's identity only at `msg3`, the same-epoch cross-connection
/// tie-break resolves *here* (on `msg3`) rather than on the outbound `msg2`
/// completion — hence the [`CrossConnect`](InboundDecision::CrossConnect)
/// variant that the IK inbound decision deliberately lacked. The dual-init
/// tie-break is also widened: it covers both the `rekey_in_progress` and the
/// `pending_new_session` states (see [`EstablishSnapshot::pending_new_session`]),
/// where IK only caught the former.
#[derive(Debug)]
pub(crate) enum InboundDecision {
/// No existing peer for this identity: authorize, allocate our index, send
/// msg2, and promote. Everything the shell needs (verified identity, their
/// index, opaque msg2 payload) is in the `WireOutcome` it still holds, so
/// the variant carries nothing.
/// No existing peer for this identity: promote the completed connection via
/// `promote_connection` (whose late max-peers cap and cross-connection
/// won/lost handling stay shell-side). Everything the shell needs is in the
/// live connection it still holds, so the variant carries nothing.
Promote,
/// Existing peer at a *different* startup epoch — a peer restart. The shell
/// tears down the stale peer and schedules its reconnect, then runs the same
/// authorize → … → promote sequence as [`Promote`](InboundDecision::Promote).
/// `peer` is the teardown / reconnect target.
/// removes the stale active peer and schedules its reconnect, then runs the
/// same promote sequence as [`Promote`](InboundDecision::Promote). `peer` is
/// the teardown / reconnect target.
RestartThenPromote { peer: NodeAddr },
/// Same-epoch rekey msg1 on an aged, healthy session: respond as the rekey
/// responder. The shell extracts the fresh Noise session from the live
/// connection, allocates a new index, sends the rekey msg2, and stores the
/// session as the peer's pending (post-rekey) session. `abandon_first` is set
/// only on the dual-initiation *loser* path, where we first abandon our own
/// in-flight rekey. `peer` is the rekey target.
/// Same-epoch cross-connection resolved inline on `msg3`: a healthy session
/// with no rekey of ours in flight received a concurrent `msg3` on a
/// different link that declares no rekey — so it is a fresh dial crossing
/// ours. `our_inbound_wins` (the larger-NodeAddr side) selects
/// swap-to-inbound vs keep-outbound; the shell frees the loser index and
/// tears down the temporary link either way. `peer` is the tie-break target.
CrossConnect {
peer: NodeAddr,
our_inbound_wins: bool,
},
/// Same-epoch `msg3` on a healthy session, declared by its sender to replace
/// that very session: respond as the rekey responder. The shell extracts the
/// fresh Noise session from the live
/// connection, allocates a new index, and stores it as the peer's pending
/// (post-rekey) session awaiting K-bit cutover. `abandon_first` is set only
/// on the dual-initiation *loser* path (larger NodeAddr), where we first
/// abandon our own in-flight rekey/pending state. `peer` is the rekey target.
RekeyRespond { peer: NodeAddr, abandon_first: bool },
/// Same-epoch duplicate msg1 (not a rekey): resend the existing peer's stored
/// msg2. `msg2` is the opaque stored bytes (`None` → nothing to resend, the
/// silent no-op preserved from the pre-refactor path).
/// Same-epoch duplicate `msg3` (not a cross-connection, not a rekey): resend
/// the existing peer's stored `msg2`. `msg2` is the opaque stored bytes
/// (`None` → nothing to resend, the silent no-op preserved from the
/// pre-refactor path). The active peer is left untouched.
ResendMsg2 { msg2: Option<Vec<u8>> },
/// Drop this msg1 with a handshake reject (`HandshakeReject::BadState`) and
/// no promotion. `reason` selects only the diagnostic log line — every reject
/// records the same stat and completes the rate-limiter identically.
/// Drop this `msg3` with a handshake reject (`HandshakeReject::BadState`) and
/// no promotion. `reason` selects only the diagnostic log line.
Reject { reason: InboundReject },
}
/// Why an inbound msg1 was rejected. Distinguishes only the diagnostic log
/// message; all three reject identically (BadState stat, rate-limiter complete,
/// the local not-yet-registered connection dropped).
/// Why an inbound XX `msg3` was dropped by the core classification. XX reaches
/// the core with a single reject cause; the negotiation reject and the late ACL
/// reject are shell steps that run *before* the decision, and the max-peers cap
/// is a late gate inside `promote_connection` — none of them reach here.
#[derive(Debug)]
pub(crate) enum InboundReject {
/// At `max_peers` and this is a net-new identity with no pending outbound to
/// bypass the cap: silent-drop before any msg2 build/send.
AtMaxPeers,
/// The peer already holds a pending post-rekey session awaiting K-bit
/// cutover; a second rekey msg1 must not overwrite it.
PendingSession,
/// Dual rekey initiation and we are the tie-break *winner* (smaller
/// NodeAddr): drop the peer's msg1 and keep driving our own rekey.
/// NodeAddr): drop the peer's `msg3` and keep driving our own rekey.
DualRekeyWon,
}
@@ -421,32 +505,69 @@ pub(crate) enum OutboundDecision {
CrossConnectionKeep,
}
/// Minimum session age (seconds) before a same-epoch msg1 from an established
/// peer is treated as a rekey rather than a duplicate. Guards against
/// misreading a simultaneous cross-connection msg1 as a rekey (both sides
/// promote within a tick, so a genuine rekey cannot fire that fast). Unchanged
/// from the pre-refactor literal.
const REKEY_MIN_SESSION_AGE_SECS: u64 = 30;
/// Read-only view of the `Node` registry state the inbound establish decision
/// needs about a peer whose msg1 has just been processed.
/// The classification outcome for one initiator-side rekey `msg2` completion,
/// decided purely from the [`RekeyMsg2Snapshot`]. The shell matches on this and
/// drives the effects; the core consumes nothing and touches no live state.
///
/// The core defines this interface; the async shell (`node`) implements it over
/// the live `peers` map, resolving every clock read into a plain `u64` before
/// the [`EstablishSnapshot`] reaches the core. Keeping it a trait
/// keeps `proto` free of a `node` dependency and lets the establish decision be
/// unit-tested against hand-built snapshots.
pub(crate) trait EstablishView {
/// Snapshot the registry state relevant to classifying an inbound msg1 from
/// `peer_addr`: the existing peer's epoch/session/rekey state (with the
/// session age resolved shell-side), the max-peers cap, and this node's own
/// address for the tie-break.
fn establish_snapshot(&self, peer_addr: &NodeAddr) -> EstablishSnapshot;
/// This is the initiator-side counterpart of the continuity the responder gets
/// structurally: an inbound rekey completes keyed by the node address derived
/// from the authenticated static, so a non-matching static resolves to a
/// different peer and can never displace an established session. The initiator
/// dispatches by its own session index instead, so the same guarantee has to be
/// stated as an explicit decision.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RekeyMsg2Decision {
/// The rekey was answered by the peer we are bonded to: install the fresh
/// session as the peer's pending (post-rekey) session awaiting K-bit
/// cutover, the unchanged pre-existing behaviour.
Install,
/// The rekey was answered by some other identity: drop this `msg2` with a
/// handshake reject, abandon the rekey cycle, and leave the established
/// session completely undisturbed. `reason` selects only the diagnostic
/// log line.
Reject { reason: RekeyMsg2Reject },
}
/// Snapshot the registry state relevant to classifying an outbound msg2
/// completion for `peer_addr`: whether the identity is already an active
/// peer, and the pre-evaluated cross-connection tie-break.
fn outbound_snapshot(&self, peer_addr: &NodeAddr) -> OutboundSnapshot;
/// Why an initiator-side rekey `msg2` was dropped by the core classification.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RekeyMsg2Reject {
/// The static learned in `msg2` derives to a different node address than
/// the established peer's — an on-path party raced a valid XX `msg2` of its
/// own against the observable rekey `msg1`, or the peer's identity changed
/// underneath the link. Either way the established session is kept.
StaticMismatch,
}
/// The classification outcome for one initiator-side *initial* `msg2`
/// completion, decided purely from the [`DialMsg2Snapshot`]. The shell matches
/// on this and drives the effects; the core consumes nothing and touches no
/// live state.
///
/// The responder gets the equivalent guarantee structurally — an inbound leg is
/// promoted under the node address derived from the authenticated static, so
/// there is no prior expectation for a foreign static to contradict. Only the
/// initiator holds an intent that the wire can disagree with, so only here does
/// it have to be stated as an explicit decision.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum DialMsg2Decision {
/// Either the dial named no peer (an anonymous leg, where any answer is a
/// legitimate first contact) or it named the peer that answered. Carry on
/// into the ACL gate and promotion, the unchanged pre-existing behaviour.
Accept,
/// The dial named a peer and someone else answered: drop this `msg2` with a
/// handshake reject and promote nothing. `reason` selects only the
/// diagnostic log line.
Reject { reason: DialMsg2Reject },
}
/// Why an initiator-side initial `msg2` was dropped by the core classification.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum DialMsg2Reject {
/// The static learned in `msg2` derives to a different node address than the
/// one dialed — an on-path party observed the outbound `msg1` and raced a
/// valid XX `msg2` under its own static, or the address we dialed is no
/// longer the peer we recorded there. Either way this leg never promotes.
StaticMismatch,
}
impl Fmp {
@@ -515,22 +636,36 @@ impl Fmp {
/// Actions are returned phase-grouped (all cutovers, then all drains, then
/// all rekey initiations) to preserve the pre-refactor global execution
/// order across peers, which the shared `index_allocator` observes.
///
/// `cfg.initiate` splits the cadence in two. The trigger and the polled
/// cutover belong to initiation and are suppressed when it is off; the drain
/// arm is housekeeping for a demoted session and is not. A node that only
/// ever responds to rekeys still accumulates drains, and nothing else
/// completes one.
pub(crate) fn poll_rekey(&self, peers: Vec<PeerSnapshot>, cfg: &RekeyCfg) -> Vec<ConnAction> {
let mut cutovers = Vec::new();
let mut drains = Vec::new();
let mut rekeys = Vec::new();
for p in peers {
// 1. Initiator-side cutover.
if p.has_pending && !p.rekey_in_progress {
// 1. Polled cutover.
//
// Gated on `initiate`, and the gate is doing real work: the snapshot
// carries no rekey role, so this arm fires for a pending session we
// stored as RESPONDER just as readily as for one we initiated. On a
// node that does not initiate, the responder's cutover is the peer's
// K-bit flip on the data path, and polling it here would move that
// cutover earlier — a behaviour change this arm must not make.
if cfg.initiate && p.has_pending && !p.rekey_in_progress {
cutovers.push(ConnAction::Cutover { peer: p.addr });
continue;
}
// 2. Drain window expiry (does not preclude a trigger below).
// Deliberately ungated: see `RekeyCfg::initiate`.
if p.is_draining && p.drain_expired {
drains.push(ConnAction::Drain { peer: p.addr });
}
// 3. Rekey trigger.
if p.rekey_in_progress || p.is_dampened {
if !cfg.initiate || p.rekey_in_progress || p.is_dampened || p.rekey_msg3_pending {
continue;
}
let effective_after = cfg.after_secs.saturating_add_signed(p.jitter_secs);
@@ -584,79 +719,155 @@ impl Fmp {
abandons
}
/// Classify one inbound handshake msg1 from the establish snapshot and the
/// Noise wire outcome. Pure: reads only `snap` and `wire`, mutates nothing,
/// Classify one inbound XX `msg3` from the establish snapshot and the Noise
/// wire outcome. Pure: reads only `snap` and `wire`, mutates nothing,
/// consumes nothing. The returned [`InboundDecision`] tells the shell which
/// effect sequence to drive.
///
/// Mirrors the pre-refactor `handle_msg1` post-crypto branch order exactly:
/// the early max-peers cap gate, then — for an existing same-identity peer —
/// the epoch-restart / rekey / duplicate classification, else a net-new
/// promote. The pre-refactor `possible_restart` flag is folded away: it was
/// forced true whenever `has_existing_peer` held, so gating the block on
/// `has_existing_peer` alone is behavior-identical.
/// Classification order. What separates a rekey from a fresh dial is the
/// sender's own declaration ([`RekeyClaim`]), never session age: a
/// message-count-triggered rekey fires on a session of any age, so an
/// age-based split misread real rekeys as cross-connections and left the two
/// ends of a link on different sessions.
///
/// 1. No existing peer → net-new [`Promote`](InboundDecision::Promote).
/// 2. Existing peer, different epoch → [`RestartThenPromote`].
/// 3. Same epoch, marker naming a session we do not hold
/// ([`Mismatch`](RekeyClaim::Mismatch)) → [`ResendMsg2`], not a reject:
/// the sender has already committed to its pending session and the reject
/// path sends nothing back.
/// 4. Same epoch, no marker, different link, healthy session → inline
/// [`CrossConnect`] (the XX widening: IK resolves this on `msg2`).
/// `our_inbound_wins` is the larger-NodeAddr side, matching
/// `cross_connection_winner(our, peer, /*outbound=*/ false)`. Taken
/// regardless of any rekey of ours in flight — the peer's outbound half of
/// this tie-break cannot see that state, so declining here would diverge
/// the pair; the executor abandons the displaced rekey instead.
/// 5. Same epoch, marker naming the session we hold
/// ([`Matches`](RekeyClaim::Matches)), healthy session → [`RekeyRespond`],
/// whatever the local `rekey.enabled` says: that flag governs whether we
/// initiate rekeys, never whether we accept one, or an asymmetric setting
/// would diverge the two ends of the link.
/// The widened dual-init tie-break fires when the peer
/// is in *either* the `rekey_in_progress` or the `pending_new_session`
/// state: the smaller NodeAddr wins ([`Reject`] the peer's `msg3`), the
/// larger loses (`abandon_first`, then respond).
/// 6. Otherwise same epoch → duplicate [`ResendMsg2`].
///
/// [`CrossConnect`]: InboundDecision::CrossConnect
/// [`RestartThenPromote`]: InboundDecision::RestartThenPromote
/// [`RekeyRespond`]: InboundDecision::RekeyRespond
/// [`Reject`]: InboundDecision::Reject
/// [`ResendMsg2`]: InboundDecision::ResendMsg2
pub(crate) fn establish_inbound(
&self,
snap: &EstablishSnapshot,
wire: &WireOutcome,
) -> InboundDecision {
// Early cap gate: at capacity and a net-new identity (no existing peer,
// no pending outbound to bypass) → silent-drop before any msg2.
if snap.at_max_peers && !snap.has_existing_peer && !snap.has_pending_outbound_to_peer {
return InboundDecision::Reject {
reason: InboundReject::AtMaxPeers,
};
if !snap.has_existing_peer {
// No existing peer for this identity → net-new promote.
return InboundDecision::Promote;
}
if snap.has_existing_peer {
let peer_addr = *wire.peer_identity.node_addr();
match (snap.existing_peer_epoch, wire.remote_epoch) {
(Some(existing), Some(new)) if existing != new => {
// Epoch mismatch → peer restart.
InboundDecision::RestartThenPromote { peer: peer_addr }
let peer = wire.peer_node_addr;
match (snap.existing_peer_epoch, wire.remote_epoch) {
(Some(existing), Some(new)) if existing != new => {
// Epoch mismatch → peer restart.
InboundDecision::RestartThenPromote { peer }
}
_ => {
// Same epoch (or no epoch captured on either side).
// A rekey and a fresh dial both arrive as a new msg1 on a new
// link, so the sender declares which it is: the msg3 negotiation
// TLV names the session it replaces and the shell resolves that
// against the session we hold. Session age used to stand in for
// this and could not — a message-count-triggered rekey fires on a
// young session and was misread as a cross-connection, which is
// the defect this replaces.
if snap.rekey_claim == RekeyClaim::Mismatch {
// The sender is replacing a session that is not the one we
// hold. Resend rather than reject: the initiator installed its
// pending session at msg2 completion and will cut over on its
// own timer, and the reject path sends nothing back, so
// tearing down here strands the link in exactly the way this
// defect does. Resending leaves both current sessions intact
// and lets the initiator's resend/abandon machinery converge.
return InboundDecision::ResendMsg2 {
msg2: snap.existing_msg2.clone(),
};
}
_ => {
// Same epoch (or no epoch captured on either side).
let is_rekey = snap.rekey_enabled
&& snap.has_session
&& snap.is_healthy
&& snap.existing_session_age_secs >= REKEY_MIN_SESSION_AGE_SECS;
if !is_rekey {
// Duplicate msg1 — resend the stored msg2.
return InboundDecision::ResendMsg2 {
msg2: snap.existing_msg2.clone(),
};
}
if snap.pending_new_session {
// A completed rekey is already pending cutover.
return InboundDecision::Reject {
reason: InboundReject::PendingSession,
};
}
if snap.rekey_in_progress {
// Dual initiation — smaller NodeAddr wins as initiator.
// Our own rekey is the outbound/initiator side, so reuse
// the shared tie-break with `this_is_outbound = true`.
if cross_connection_winner(&snap.our_node_addr, &peer_addr, true) {
// The sender declares no rekey, so a msg3 on a different link is
// a fresh dial crossing ours.
//
// A rekey of ours in flight does NOT suppress the swap, and must
// not: this is one half of a two-sided tie-break whose other half
// is `establish_outbound`, which sees only `has_existing_peer` and
// the address ordering. Declining here while the peer's outbound
// half swaps anyway leaves the two ends on different sessions with
// neither holding the other's — measured, and strictly worse than
// the behaviour this replaced.
//
// The hazard that suppression was reaching for is real but belongs
// to the executor: `replace_session` rewrites the session and both
// indices and touches nothing else, orphaning an in-flight rekey.
// The `SwapToInboundSession` arm therefore abandons that rekey as
// part of the swap, the same way the rekey-responder arm does when
// it loses the dual-rekey tie-break.
if snap.rekey_claim == RekeyClaim::None
&& snap.different_link
&& snap.has_session
&& snap.is_healthy
{
// `cross_connection_winner(our, peer, this_is_outbound=false)`:
// the smaller node prefers its outbound, so our *inbound*
// wins iff we are the larger node (the exact negation of the
// smaller-NodeAddr `<` test the dual-init tie-break uses).
let our_inbound_wins = snap.our_node_addr >= peer;
return InboundDecision::CrossConnect {
peer,
our_inbound_wins,
};
}
// Rekey responder gate: the sender named the session we hold.
//
// Nothing here consults the local rekey config. `rekey.enabled`
// means "initiate rekeys" and nothing else: reading it here made
// the responder decline a rekey the sender had already committed
// to, so an asymmetric setting left the two ends on different
// sessions with the link carrying nothing until the link-dead
// timer. The sender's declaration matching the session we hold is
// the authoritative signal, and it is a property of the wire, not
// of our config.
if snap.rekey_claim == RekeyClaim::Matches && snap.has_session && snap.is_healthy {
// Widened dual-init tie-break: both the still-in-progress and
// the already-pending states resolve by the smaller NodeAddr.
if snap.rekey_in_progress || snap.pending_new_session {
if snap.our_node_addr < peer {
// We win — keep our session, drop their msg3.
return InboundDecision::Reject {
reason: InboundReject::DualRekeyWon,
};
}
// We lose abandon ours, then respond as responder.
// We lose abandon ours, then respond as responder.
return InboundDecision::RekeyRespond {
peer: peer_addr,
peer,
abandon_first: true,
};
}
InboundDecision::RekeyRespond {
peer: peer_addr,
return InboundDecision::RekeyRespond {
peer,
abandon_first: false,
}
};
}
// Not a cross-connection, not a rekey → duplicate handshake.
InboundDecision::ResendMsg2 {
msg2: snap.existing_msg2.clone(),
}
}
} else {
// No existing peer for this identity → net-new promote.
InboundDecision::Promote
}
}
@@ -676,6 +887,50 @@ impl Fmp {
OutboundDecision::CrossConnectionKeep
}
}
/// Classify one initiator-side rekey `msg2` completion: the static-key
/// continuity gate. Pure: reads only `snap`, mutates nothing.
///
/// A rekey may only replace the session of the peer that already holds the
/// link, so the identity learned from `msg2` must derive to the established
/// peer's node address. Anything else is
/// [`Reject`](RekeyMsg2Decision::Reject) — the shell abandons the rekey and
/// keeps the current session. A match is
/// [`Install`](RekeyMsg2Decision::Install), the unchanged legitimate path.
pub(crate) fn rekey_outbound(&self, snap: &RekeyMsg2Snapshot) -> RekeyMsg2Decision {
if snap.learned_peer != snap.established_peer {
return RekeyMsg2Decision::Reject {
reason: RekeyMsg2Reject::StaticMismatch,
};
}
RekeyMsg2Decision::Install
}
/// Classify one initiator-side *initial* `msg2` completion: the dial-time
/// identity gate. Pure: reads only `snap`, mutates nothing.
///
/// A dial that named a peer may only be completed by that peer, so the
/// identity learned from `msg2` must derive to the dialed node address.
/// Anything else is [`Reject`](DialMsg2Decision::Reject) and the shell
/// promotes nothing.
///
/// A dial that named nobody has nothing to contradict: an anonymous
/// shared-media beacon asserts no identity, so answering one and being
/// admitted is peering rather than substitution, and the ACL — which runs
/// against the learned identity either way — stays the correct and only gate
/// there. That carve-out cannot be reached as an exemption, because whether
/// `dialed_peer` is `Some` is settled when the leg is built and never by the
/// wire.
pub(crate) fn dial_outbound(&self, snap: &DialMsg2Snapshot) -> DialMsg2Decision {
if let Some(dialed) = snap.dialed_peer
&& dialed != snap.learned_peer
{
return DialMsg2Decision::Reject {
reason: DialMsg2Reject::StaticMismatch,
};
}
DialMsg2Decision::Accept
}
}
/// Exponential-backoff schedule for the next handshake/rekey msg1 resend:
@@ -685,3 +940,107 @@ fn next_resend_at_ms(now_ms: u64, interval_ms: u64, backoff: f64, prior_count: u
let count = prior_count + 1;
now_ms + (interval_ms as f64 * crate::proto::math::powi(backoff, count)) as u64
}
// ============================================================================
// FMP negotiation decision logic
// ============================================================================
//
// The version-agreement, profile-extraction, and profile-pairing decisions
// relocated from `protocol::negotiation` (the payload *codec* stays in
// `wire.rs`). These are pure decisions over an already-decoded
// `NegotiationPayload`, so they belong beside the other FMP core decisions.
impl NegotiationPayload {
/// Agree on a protocol version with a peer's negotiation payload.
///
/// Returns `min(our_max, their_max)`, rejecting if the agreed version
/// is below either side's minimum.
pub fn agree_version(&self, other: &Self) -> Result<u8, Error> {
let agreed = self.version_max.min(other.version_max);
if agreed < self.version_min || agreed < other.version_min {
return Err(Error::Malformed("version mismatch"));
}
Ok(agreed)
}
/// Build an FMP negotiation payload for the given node profile.
///
/// Sets the profile bits and MMP wants/provides defaults for the profile.
pub fn fmp(version_min: u8, version_max: u8, profile: NodeProfile) -> Self {
let (provides_sr, provides_rr, wants_sr, wants_rr) = match profile {
NodeProfile::Full => (true, true, true, true),
NodeProfile::NonRouting => (true, true, false, true),
NodeProfile::Leaf => (false, true, false, false),
};
let mut features = (profile as u8 as u64) & FMP_FEAT_PROFILE_MASK;
if provides_sr {
features |= FMP_FEAT_PROVIDES_SR;
}
if provides_rr {
features |= FMP_FEAT_PROVIDES_RR;
}
if wants_sr {
features |= FMP_FEAT_WANTS_SR;
}
if wants_rr {
features |= FMP_FEAT_WANTS_RR;
}
Self::new(version_min, version_max, features)
}
/// Extract the node profile from the FMP feature bitfield.
pub fn node_profile(&self) -> Result<NodeProfile, Error> {
let raw = (self.features & FMP_FEAT_PROFILE_MASK) as u8;
NodeProfile::try_from(raw)
}
/// Whether this peer can provide MMP sender reports.
pub fn provides_sr(&self) -> bool {
self.features & FMP_FEAT_PROVIDES_SR != 0
}
/// Whether this peer can provide MMP receiver reports.
pub fn provides_rr(&self) -> bool {
self.features & FMP_FEAT_PROVIDES_RR != 0
}
/// Whether this peer wants MMP sender reports.
pub fn wants_sr(&self) -> bool {
self.features & FMP_FEAT_WANTS_SR != 0
}
/// Whether this peer wants MMP receiver reports.
pub fn wants_rr(&self) -> bool {
self.features & FMP_FEAT_WANTS_RR != 0
}
/// Validate that two profiles form a valid link pairing.
///
/// At least one side must be `Full` or the link is rejected.
pub fn validate_profiles(ours: NodeProfile, theirs: NodeProfile) -> Result<(), Error> {
if ours != NodeProfile::Full && theirs != NodeProfile::Full {
return Err(Error::Malformed(
"invalid profile pairing (at least one must be full)",
));
}
Ok(())
}
}
/// Decode, validate, and extract the peer's node profile from an FMP
/// negotiation payload.
///
/// The pure decode -> validate -> profile decision lifted out of the async
/// `process_fmp_negotiation` shell adapter. The shell applies the returned
/// profile to the connection (`set_negotiation_results`) and logs.
pub(crate) fn decide_fmp_negotiation(
our_profile: NodeProfile,
neg_bytes: &[u8],
) -> Result<NodeProfile, Error> {
let their_payload = NegotiationPayload::decode(neg_bytes)?;
let their_profile = their_payload.node_profile()?;
NegotiationPayload::validate_profiles(our_profile, their_profile)?;
Ok(their_profile)
}
+22 -15
View File
@@ -6,25 +6,31 @@
//!
//! This covers the four tick-poll maintain/teardown drivers (handshake
//! timeout/teardown, msg1 resend, rekey cutover/drain/trigger, rekey-msg1
//! resend) plus the inbound-msg1 classification decision
//! ([`Fmp::establish_inbound`]). Handshake message bytes are opaque blobs
//! throughout — the Noise wire construction and `promote_connection` effects,
//! the outbound `handle_msg2` classification, and the born-on-next
//! `handle_msg3` leaf stay in `node/`.
//! resend) plus the XX inbound `msg3` establish classification
//! ([`InboundDecision`], decided by [`Fmp::establish_inbound`]). Handshake
//! message bytes are opaque blobs throughout — the establish leaf's Noise wire
//! construction and `promote_connection` effects are born-on-next and stay in
//! `node/`; only the classification decision lives here.
//!
//! - `core.rs` — the [`LifecycleView`] read-seam trait, the [`ConnAction`]
//! effect vocabulary, the snapshot types, the pure `poll_*` decisions, and
//! the [`cross_connection_winner`] tie-break helper.
//! effect vocabulary, the snapshot types, the [`InboundDecision`] establish
//! classification, the [`RekeyMsg2Decision`] initiator-side rekey `msg2`
//! static-continuity classification, the [`DialMsg2Decision`]
//! initiator-side initial `msg2` dial-identity classification, the pure
//! `poll_*`/`establish_inbound`/`rekey_outbound`/`dial_outbound` decisions, the
//! [`cross_connection_winner`] tie-break helper, and the FMP negotiation
//! decision logic (version agreement, profile validation).
//! - `limits.rs` — the pure connection-retry backoff math.
//! - `state.rs` — [`ConnectionState`], the pure handshake-phase connection
//! bookkeeping (owned by the per-peer control machine beside its Noise
//! crypto carrier), plus [`Fmp`], the (stateless) lifecycle anchor owned by
//! `Node`.
//! The handshake phase itself lives on the per-peer control machine.
//! - `wire.rs` — the FMP link-framing codec: handshake message types,
//! disconnect reasons, and the orderly disconnect message. Also carries the
//! FMP link wire framing relocated from `node/wire.rs` — the common prefix,
//! encrypted/msg1/msg2 headers, and the `build_*`/inner-header codec fns.
//! - `wire.rs` — the FMP wire codec: XX handshake message types, disconnect
//! reasons, the orderly disconnect message, and the negotiation payload.
//! Also carries the relocated FMP link wire framing (moved from
//! `node/wire.rs`): the common prefix, encrypted/established headers, and
//! the msg1/msg2/msg3 handshake framing.
mod core;
mod limits;
@@ -35,12 +41,13 @@ pub(crate) mod wire;
mod tests;
pub(crate) use core::{
ConnAction, ConnSnapshot, EstablishSnapshot, EstablishView, InboundDecision, InboundReject,
LifecycleView, OutboundDecision, OutboundSnapshot, PeerSnapshot, RekeyCfg, RekeyResendSnapshot,
WireOutcome,
ConnAction, ConnSnapshot, DialMsg2Decision, DialMsg2Reject, DialMsg2Snapshot,
EstablishSnapshot, InboundDecision, InboundReject, LifecycleView, OutboundDecision,
OutboundSnapshot, PeerSnapshot, RekeyCfg, RekeyClaim, RekeyMsg2Decision, RekeyMsg2Reject,
RekeyMsg2Snapshot, RekeyResendSnapshot, WireOutcome, decide_fmp_negotiation,
};
pub use core::{PromotionResult, cross_connection_winner};
pub(crate) use limits::backoff_ms;
pub(crate) use state::{ConnectionState, Fmp};
pub use wire::HandshakeMessageType;
pub(crate) use wire::{Disconnect, DisconnectReason};
pub use wire::{HandshakeMessageType, NegotiationPayload, NodeProfile, TlvEntry};
+77
View File
@@ -28,6 +28,7 @@
//! [`Fmp`] is the separate, stateless lifecycle anchor owned by
//! [`Node`](crate::node::Node); see its doc below.
use super::wire::NodeProfile;
use crate::PeerIdentity;
use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId};
use crate::utils::index::SessionIndex;
@@ -51,6 +52,23 @@ pub struct ConnectionState {
/// Updated after receiving their static key in the handshake.
expected_identity: Option<PeerIdentity>,
/// The identity this leg was *dialed at*, fixed at construction and never
/// written again — `Some` only for an identified outbound dial (a
/// configured peer, an mDNS/rendezvous npub, or discovery that carried a
/// `pubkey_hint`), `None` for an anonymous shared-media dial and for every
/// inbound leg.
///
/// This is deliberately *not* [`expected_identity`](Self::expected_identity).
/// That field carries a different fact — *who answered* — and under XX the
/// answer arrives late (msg2 for the initiator, msg3 for the responder) and
/// must overwrite whatever was there, because on an anonymous leg it is the
/// only way the carrier ever acquires an identity at all. Holding both facts
/// in one field meant the second silently destroyed the first, leaving
/// nothing for the initiator to check the answer against. Keeping the dial
/// intent in its own write-once field is what makes the check possible; see
/// [`Fmp::dial_outbound`](super::core::Fmp::dial_outbound).
dialed_identity: Option<PeerIdentity>,
// === Timing ===
/// When the connection attempt started (Unix milliseconds).
started_at: u64,
@@ -83,6 +101,10 @@ pub struct ConnectionState {
/// Remote peer's startup epoch (learned from handshake).
remote_epoch: Option<[u8; 8]>,
// === Negotiation Results ===
/// Peer's node profile (learned from negotiation payload).
peer_profile: Option<NodeProfile>,
// === Handshake Resend ===
/// Wire-format msg1 bytes for resend (initiator only).
handshake_msg1: Option<Vec<u8>>,
@@ -107,6 +129,7 @@ impl ConnectionState {
link_id,
direction: LinkDirection::Outbound,
expected_identity: Some(expected_identity),
dialed_identity: Some(expected_identity),
started_at: current_time_ms,
last_activity: current_time_ms,
link_stats: LinkStats::new(),
@@ -115,6 +138,34 @@ impl ConnectionState {
transport_id: None,
source_addr: None,
remote_epoch: None,
peer_profile: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
}
}
/// Create the pure state for a new outbound connection without a pre-known
/// identity.
///
/// Used for anonymous discovery on shared-media transports (Ethernet, BLE)
/// where the beacon doesn't carry identity. The peer's identity is learned
/// from XX msg2 during the handshake.
pub fn outbound_anonymous(link_id: LinkId, current_time_ms: u64) -> Self {
Self {
link_id,
direction: LinkDirection::Outbound,
expected_identity: None,
dialed_identity: None,
started_at: current_time_ms,
last_activity: current_time_ms,
link_stats: LinkStats::new(),
our_index: None,
their_index: None,
transport_id: None,
source_addr: None,
remote_epoch: None,
peer_profile: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
@@ -130,6 +181,7 @@ impl ConnectionState {
link_id,
direction: LinkDirection::Inbound,
expected_identity: None,
dialed_identity: None,
started_at: current_time_ms,
last_activity: current_time_ms,
link_stats: LinkStats::new(),
@@ -138,6 +190,7 @@ impl ConnectionState {
transport_id: None,
source_addr: None,
remote_epoch: None,
peer_profile: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
@@ -158,6 +211,7 @@ impl ConnectionState {
link_id,
direction: LinkDirection::Inbound,
expected_identity: None,
dialed_identity: None,
started_at: current_time_ms,
last_activity: current_time_ms,
link_stats: LinkStats::new(),
@@ -166,6 +220,7 @@ impl ConnectionState {
transport_id: Some(transport_id),
source_addr: Some(source_addr),
remote_epoch: None,
peer_profile: None,
handshake_msg1: None,
handshake_msg2: None,
resend_count: 0,
@@ -189,6 +244,16 @@ impl ConnectionState {
self.expected_identity.as_ref()
}
/// Get the identity this leg was dialed at, if it was an identified dial.
///
/// Decided locally at construction and never from the wire, so no peer can
/// steer an identified dial into reporting `None` here. There is no setter
/// by design — the whole point of the field is that the handshake cannot
/// move it.
pub fn dialed_identity(&self) -> Option<&PeerIdentity> {
self.dialed_identity.as_ref()
}
/// Check if this is an outbound connection.
pub fn is_outbound(&self) -> bool {
self.direction == LinkDirection::Outbound
@@ -293,6 +358,18 @@ impl ConnectionState {
self.expected_identity = Some(identity);
}
// === Negotiation Results ===
/// Get the peer's negotiated node profile, if learned.
pub fn peer_profile(&self) -> Option<NodeProfile> {
self.peer_profile
}
/// Record the peer's node profile learned during FMP negotiation.
pub fn set_negotiation_results(&mut self, peer_profile: NodeProfile) {
self.peer_profile = Some(peer_profile);
}
// === Handshake Resend ===
/// Store the wire-format msg1 bytes for resend and reset the resend counter.
+491 -185
View File
@@ -4,23 +4,36 @@ use super::util::{
establish_snapshot, peer_snapshot, rekey_resend_snapshot, resend_snapshot, stale_snapshot,
wire_outcome,
};
use crate::NodeAddr;
use crate::proto::fmp::{
ConnAction, Fmp, InboundDecision, InboundReject, OutboundDecision, OutboundSnapshot, RekeyCfg,
cross_connection_winner,
ConnAction, DialMsg2Decision, DialMsg2Reject, DialMsg2Snapshot, Fmp, InboundDecision,
InboundReject, NegotiationPayload, NodeProfile, OutboundDecision, OutboundSnapshot, RekeyCfg,
RekeyClaim, RekeyMsg2Decision, RekeyMsg2Reject, RekeyMsg2Snapshot, cross_connection_winner,
};
use crate::testutil::make_node_addr;
use crate::transport::LinkId;
/// Threshold config used across the rekey decision tests: rekey at 100s of
/// session age or 1000 sent messages.
/// Matching-epoch default used by `establish_snapshot`.
const SAME_EPOCH: [u8; 8] = [0x01; 8];
/// Threshold config used across the rekey decision tests: an initiating node
/// that rekeys at 100s of session age or 1000 sent messages.
fn cfg() -> RekeyCfg {
RekeyCfg {
initiate: true,
after_secs: 100,
after_messages: 1_000,
}
}
/// The same thresholds on a node that does not initiate rekeys
/// (`node.rekey.enabled = false`).
fn cfg_no_initiate() -> RekeyCfg {
RekeyCfg {
initiate: false,
..cfg()
}
}
#[test]
fn empty_stale_set_yields_no_actions() {
let fmp = Fmp::new();
@@ -158,6 +171,50 @@ fn rekey_expired_drain_and_trigger_both_fire() {
);
}
/// A node that does not initiate rekeys still completes an expired drain, and
/// still does nothing else.
///
/// The two halves are one property. A node with `rekey.enabled = false` accepts
/// its peers' rekeys — the declaration on the wire decides that, not local
/// config — and every acceptance demotes a live session into the drain slot.
/// Nothing but this arm releases that session and its index, so suppressing the
/// whole poll strands both for the life of the peer.
///
/// The other half is why the suppression cannot simply be dropped: this snapshot
/// carries no rekey role, so `has_pending` is set by a responder-stored session
/// exactly as by an initiated one. Letting the cutover arm run here would move a
/// non-initiating node's cutover off the peer's K-bit flip and onto the poll.
#[test]
fn rekey_without_initiation_drains_but_does_not_cut_over_or_trigger() {
let fmp = Fmp::new();
let mut draining = peer_snapshot(0x14);
draining.is_draining = true;
draining.drain_expired = true;
// Also over both trigger thresholds, and holding a pending session as a
// responder would: neither may produce an action.
draining.has_pending = true;
draining.elapsed_secs = 10_000;
draining.counter = 10_000;
let actions = fmp.poll_rekey(vec![draining], &cfg_no_initiate());
assert_eq!(
actions.len(),
1,
"a non-initiating node must emit the drain and nothing else"
);
assert!(matches!(actions[0], ConnAction::Drain { peer } if peer == make_node_addr(0x14)));
// An unexpired drain window is still not due, so the arm is not simply
// firing on every poll.
let mut unexpired = peer_snapshot(0x14);
unexpired.is_draining = true;
assert!(
fmp.poll_rekey(vec![unexpired], &cfg_no_initiate())
.is_empty(),
"an unexpired drain window must not complete"
);
}
/// The send-counter arm of the rekey trigger, pinned at its boundary through
/// the real `poll_rekey` rather than a reimplementation of its predicate.
///
@@ -221,6 +278,18 @@ fn rekey_dampening_suppresses_trigger() {
assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty());
}
#[test]
fn rekey_msg3_pending_suppresses_trigger() {
let fmp = Fmp::new();
// The initiator already cut over and is still retransmitting this cycle's
// rekey msg3; a fresh rekey must not start (it would overwrite the retained
// payload). Mirrors the dampening-suppression case.
let mut p = peer_snapshot(0x17);
p.elapsed_secs = 10_000;
p.rekey_msg3_pending = true;
assert!(fmp.poll_rekey(vec![p], &cfg()).is_empty());
}
#[test]
fn rekey_actions_are_phase_grouped_across_peers() {
let fmp = Fmp::new();
@@ -320,198 +389,130 @@ fn rekey_resend_abandons_precede_retransmits() {
}
// ===========================================================================
// establish_inbound — inbound msg1 classification (E3)
// XX inbound establish classification (`establish_inbound`).
//
// The 11 framed characterization tests in `node::tests::establish_xx_chartests`
// are the behavior oracle; these exercise the pure decision directly over
// hand-built snapshots, one per branch, with deterministic session ages.
// ===========================================================================
/// All-0xFF NodeAddr: strictly greater than any pubkey-derived peer addr, so a
/// tie-break with `our_node_addr` set to this makes us the larger side.
fn max_node_addr() -> NodeAddr {
NodeAddr::from_bytes([0xFF; 16])
}
/// No existing peer for this identity -> a net-new promote.
#[test]
fn establish_inbound_net_new_promotes() {
fn establish_net_new_promotes() {
let fmp = Fmp::new();
let snap = establish_snapshot();
let wire = wire_outcome(Some([1u8; 8]));
let mut snap = establish_snapshot(0x05);
snap.has_existing_peer = false;
let wire = wire_outcome(0x02, SAME_EPOCH);
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::Promote
));
}
/// An existing peer at a different startup epoch -> restart then promote.
#[test]
fn establish_inbound_at_cap_net_new_rejects() {
fn establish_epoch_mismatch_restarts() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.at_max_peers = true;
let wire = wire_outcome(Some([1u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::Reject {
reason: InboundReject::AtMaxPeers
}
));
}
#[test]
fn establish_inbound_at_cap_with_pending_outbound_bypasses_and_promotes() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.at_max_peers = true;
snap.has_pending_outbound_to_peer = true;
let wire = wire_outcome(Some([1u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::Promote
));
}
#[test]
fn establish_inbound_at_cap_existing_peer_not_capped() {
// At cap but the identity is already a peer → the cap gate is bypassed and
// the same-epoch classification runs (here: a duplicate resend).
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.at_max_peers = true;
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([9u8; 8]);
snap.existing_msg2 = Some(vec![0xAB, 0xCD]);
let wire = wire_outcome(Some([9u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::ResendMsg2 { .. }
));
}
#[test]
fn establish_inbound_epoch_mismatch_restarts() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([1u8; 8]);
let wire = wire_outcome(Some([2u8; 8]));
let peer = *wire.peer_identity.node_addr();
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::RestartThenPromote { peer: p } if p == peer
));
}
#[test]
fn establish_inbound_same_epoch_young_session_resends() {
// Same epoch but session younger than the rekey gate → duplicate resend,
// carrying the stored msg2 bytes verbatim.
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 5;
snap.existing_msg2 = Some(vec![0x01, 0x02, 0x03]);
let wire = wire_outcome(Some([7u8; 8]));
let snap = establish_snapshot(0x05); // existing epoch [0x01; 8]
let wire = wire_outcome(0x02, [0x02; 8]); // new epoch differs
match fmp.establish_inbound(&snap, &wire) {
InboundDecision::ResendMsg2 { msg2 } => {
assert_eq!(msg2.as_deref(), Some(&[0x01, 0x02, 0x03][..]));
InboundDecision::RestartThenPromote { peer } => {
assert_eq!(peer, make_node_addr(0x02));
}
other => panic!("expected ResendMsg2, got a different variant: {other:?}"),
other => panic!("expected RestartThenPromote, got {other:?}"),
}
}
/// Same-epoch, different link, still-fresh session where we are the LARGER node:
/// our inbound wins the inline cross-connection (swap to inbound).
#[test]
fn establish_inbound_aged_session_rekey_responds() {
fn establish_cross_connection_larger_node_inbound_wins() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 31;
let wire = wire_outcome(Some([7u8; 8]));
let peer = *wire.peer_identity.node_addr();
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::RekeyRespond { peer: p, abandon_first: false } if p == peer
));
}
#[test]
fn establish_inbound_rekey_gate_requires_enabled() {
// Aged healthy session but rekey disabled → same-epoch msg1 is a duplicate,
// not a rekey.
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 31;
snap.rekey_enabled = false;
let wire = wire_outcome(Some([7u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::ResendMsg2 { .. }
));
}
#[test]
fn establish_inbound_rekey_gate_boundary_at_30s() {
// Exactly 30s satisfies `>= 30` → rekey; 29s does not → duplicate.
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
let wire = wire_outcome(Some([7u8; 8]));
snap.existing_session_age_secs = 30;
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::RekeyRespond { .. }
));
snap.existing_session_age_secs = 29;
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::ResendMsg2 { .. }
));
}
#[test]
fn establish_inbound_pending_session_rejects() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 31;
snap.pending_new_session = true;
let wire = wire_outcome(Some([7u8; 8]));
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::Reject {
reason: InboundReject::PendingSession
let mut snap = establish_snapshot(0x09); // our addr 0x09
snap.different_link = true;
snap.rekey_claim = RekeyClaim::None; // sender declares no rekey
let wire = wire_outcome(0x02, SAME_EPOCH); // peer 0x02 < our 0x09
match fmp.establish_inbound(&snap, &wire) {
InboundDecision::CrossConnect {
peer,
our_inbound_wins,
} => {
assert_eq!(peer, make_node_addr(0x02));
assert!(our_inbound_wins, "larger node's inbound wins");
}
));
other => panic!("expected CrossConnect, got {other:?}"),
}
}
/// Same-epoch, different link, still-fresh session where we are the SMALLER
/// node: our inbound loses (keep the existing outbound session).
#[test]
fn establish_inbound_dual_init_we_win_rejects() {
// rekey in progress + our addr < peer addr (our = 0x10, peer = pubkey-derived
// non-zero) → we win, drop theirs.
fn establish_cross_connection_smaller_node_inbound_loses() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 31;
let mut snap = establish_snapshot(0x02); // our addr 0x02
snap.different_link = true;
snap.rekey_claim = RekeyClaim::None;
let wire = wire_outcome(0x09, SAME_EPOCH); // peer 0x09 > our 0x02
match fmp.establish_inbound(&snap, &wire) {
InboundDecision::CrossConnect {
our_inbound_wins, ..
} => assert!(!our_inbound_wins, "smaller node's inbound loses"),
other => panic!("expected CrossConnect, got {other:?}"),
}
}
/// Same link (never a cross-connection) with a fresh session and rekey enabled:
/// neither cross-connection (same link) nor rekey (age < floor) -> duplicate.
#[test]
fn establish_same_link_fresh_is_duplicate() {
let fmp = Fmp::new();
let mut snap = establish_snapshot(0x05);
snap.different_link = false;
snap.rekey_claim = RekeyClaim::None;
snap.existing_msg2 = Some(vec![0xaa, 0xbb]);
let wire = wire_outcome(0x02, SAME_EPOCH);
match fmp.establish_inbound(&snap, &wire) {
InboundDecision::ResendMsg2 { msg2 } => assert_eq!(msg2, Some(vec![0xaa, 0xbb])),
other => panic!("expected ResendMsg2, got {other:?}"),
}
}
/// Aged, healthy, same-epoch session with no dual-init in flight -> rekey
/// responder, no prior abandon.
///
/// The snapshot carries no local rekey-trigger config, and deliberately: the
/// sender's declaration is the whole input. A responder that consulted its own
/// `rekey.enabled` here would decline a rekey the sender has already committed
/// to, leaving the two ends on different sessions whenever the setting is
/// asymmetric. The two-ended proof of that lives in `node/tests/handshake.rs`;
/// this pins the decision itself.
#[test]
fn establish_aged_rekey_responds_without_abandon() {
let fmp = Fmp::new();
let mut snap = establish_snapshot(0x05);
snap.rekey_claim = RekeyClaim::Matches;
let wire = wire_outcome(0x02, SAME_EPOCH);
match fmp.establish_inbound(&snap, &wire) {
InboundDecision::RekeyRespond {
peer,
abandon_first,
} => {
assert_eq!(peer, make_node_addr(0x02));
assert!(!abandon_first);
}
other => panic!("expected RekeyRespond, got {other:?}"),
}
}
/// Dual-init in the `rekey_in_progress` state, we are the SMALLER node ->
/// tie-break win, drop their msg3.
#[test]
fn establish_dual_init_in_progress_smaller_wins() {
let fmp = Fmp::new();
let mut snap = establish_snapshot(0x02); // our addr 0x02 (smaller)
snap.rekey_claim = RekeyClaim::Matches;
snap.rekey_in_progress = true;
snap.our_node_addr = make_node_addr(0x00); // minimal → strictly smaller
let wire = wire_outcome(Some([7u8; 8]));
let wire = wire_outcome(0x09, SAME_EPOCH); // peer 0x09
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::Reject {
@@ -520,24 +521,147 @@ fn establish_inbound_dual_init_we_win_rejects() {
));
}
/// Dual-init in the `rekey_in_progress` state, we are the LARGER node ->
/// tie-break loss, abandon ours then respond.
#[test]
fn establish_inbound_dual_init_we_lose_responds_with_abandon() {
// rekey in progress + our addr > peer addr → we lose, abandon ours and
// respond as responder.
fn establish_dual_init_in_progress_larger_loses() {
let fmp = Fmp::new();
let mut snap = establish_snapshot();
snap.has_existing_peer = true;
snap.existing_peer_epoch = Some([7u8; 8]);
snap.has_session = true;
snap.is_healthy = true;
snap.existing_session_age_secs = 31;
let mut snap = establish_snapshot(0x09); // our addr 0x09 (larger)
snap.rekey_claim = RekeyClaim::Matches;
snap.rekey_in_progress = true;
snap.our_node_addr = max_node_addr(); // strictly larger than any peer addr
let wire = wire_outcome(Some([7u8; 8]));
let peer = *wire.peer_identity.node_addr();
let wire = wire_outcome(0x02, SAME_EPOCH); // peer 0x02
match fmp.establish_inbound(&snap, &wire) {
InboundDecision::RekeyRespond { abandon_first, .. } => assert!(abandon_first),
other => panic!("expected RekeyRespond{{abandon_first:true}}, got {other:?}"),
}
}
/// XX-widened dual-init: the `pending_new_session` state (which IK never
/// reached). As the LARGER node we lose -> abandon ours then respond,
/// re-installing pending.
#[test]
fn establish_dual_init_pending_state_larger_loses() {
let fmp = Fmp::new();
let mut snap = establish_snapshot(0x09); // our addr 0x09 (larger)
snap.rekey_claim = RekeyClaim::Matches;
snap.rekey_in_progress = false;
snap.pending_new_session = true; // the widened window
let wire = wire_outcome(0x02, SAME_EPOCH);
match fmp.establish_inbound(&snap, &wire) {
InboundDecision::RekeyRespond { abandon_first, .. } => assert!(abandon_first),
other => panic!("expected RekeyRespond{{abandon_first:true}}, got {other:?}"),
}
}
/// XX-widened dual-init `pending_new_session` state as the SMALLER node ->
/// tie-break win, drop their msg3.
#[test]
fn establish_dual_init_pending_state_smaller_wins() {
let fmp = Fmp::new();
let mut snap = establish_snapshot(0x02); // our addr 0x02 (smaller)
snap.rekey_claim = RekeyClaim::Matches;
snap.pending_new_session = true;
let wire = wire_outcome(0x09, SAME_EPOCH);
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::RekeyRespond { peer: p, abandon_first: true } if p == peer
InboundDecision::Reject {
reason: InboundReject::DualRekeyWon
}
));
}
/// The sender's declaration partitions cross-connection from rekey, and it does
/// so independently of session age. This is the property the age floor could not
/// provide: a message-count-triggered rekey fires on a young session, so an
/// age-based partition classified a real rekey as a cross-connection and left the
/// two ends on different session indices.
#[test]
fn establish_declaration_partitions_cross_connection_and_rekey() {
let fmp = Fmp::new();
let wire = wire_outcome(0x02, SAME_EPOCH);
let mut undeclared = establish_snapshot(0x09);
undeclared.different_link = true;
undeclared.rekey_claim = RekeyClaim::None;
assert!(matches!(
fmp.establish_inbound(&undeclared, &wire),
InboundDecision::CrossConnect { .. }
));
let mut declared = establish_snapshot(0x09);
declared.different_link = true;
declared.rekey_claim = RekeyClaim::Matches;
assert!(matches!(
fmp.establish_inbound(&declared, &wire),
InboundDecision::RekeyRespond { .. }
));
}
/// A marker naming a session we do not hold resends msg2 rather than rejecting.
/// The initiator has already installed its pending session and will cut over on
/// its own timer; the reject path sends nothing back, so tearing down here would
/// strand the link in exactly the way this defect does.
#[test]
fn establish_mismatched_marker_resends_rather_than_rejecting() {
let fmp = Fmp::new();
let wire = wire_outcome(0x02, SAME_EPOCH);
let mut snap = establish_snapshot(0x09);
snap.different_link = true;
snap.rekey_claim = RekeyClaim::Mismatch;
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::ResendMsg2 { .. }
));
}
/// A fresh dial crossing our own in-flight rekey still resolves by the
/// cross-connection tie-break, unchanged by the rekey.
///
/// This decision is one half of a two-sided tie-break: the peer's outbound half
/// (`establish_outbound`) reads only whether it has an existing peer and the
/// address ordering, and cannot see our rekey. Declining the swap here on
/// account of our own state therefore desynchronizes the pair — the peer swaps,
/// we do not, and neither end holds the other's session. Measured on both trees:
/// declining leaves all four indices distinct with no pending session anywhere.
///
/// The orphaned-rekey hazard is real and is handled where the swap happens, by
/// abandoning the rekey as part of it, rather than by refusing to swap.
#[test]
fn establish_undeclared_during_our_rekey_still_cross_connects() {
let fmp = Fmp::new();
let wire = wire_outcome(0x02, SAME_EPOCH);
for (in_progress, pending) in [(true, false), (false, true), (false, false)] {
let mut snap = establish_snapshot(0x09); // our 0x09 > peer 0x02
snap.different_link = true;
snap.rekey_claim = RekeyClaim::None;
snap.rekey_in_progress = in_progress;
snap.pending_new_session = pending;
match fmp.establish_inbound(&snap, &wire) {
InboundDecision::CrossConnect {
our_inbound_wins, ..
} => assert!(
our_inbound_wins,
"the larger node's inbound wins regardless of our rekey state \
(in_progress={in_progress} pending={pending})"
),
other => panic!("in_progress={in_progress} pending={pending}: got {other:?}"),
}
}
}
/// An unhealthy or session-less aged peer is not a rekey candidate -> duplicate.
#[test]
fn establish_aged_unhealthy_is_duplicate() {
let fmp = Fmp::new();
let mut snap = establish_snapshot(0x05);
snap.rekey_claim = RekeyClaim::Matches;
snap.is_healthy = false;
let wire = wire_outcome(0x02, SAME_EPOCH);
assert!(matches!(
fmp.establish_inbound(&snap, &wire),
InboundDecision::ResendMsg2 { .. }
));
}
@@ -582,6 +706,83 @@ fn establish_outbound_cross_connection_loss_keeps() {
);
}
// ===========================================================================
// rekey_outbound — initiator rekey msg2 static-continuity classification
// ===========================================================================
#[test]
fn rekey_outbound_matching_static_installs() {
let fmp = Fmp::new();
let peer = make_node_addr(1);
let snap = RekeyMsg2Snapshot {
established_peer: peer,
learned_peer: peer,
};
assert_eq!(fmp.rekey_outbound(&snap), RekeyMsg2Decision::Install);
}
#[test]
fn rekey_outbound_foreign_static_rejects() {
let fmp = Fmp::new();
let snap = RekeyMsg2Snapshot {
established_peer: make_node_addr(1),
learned_peer: make_node_addr(2),
};
assert_eq!(
fmp.rekey_outbound(&snap),
RekeyMsg2Decision::Reject {
reason: RekeyMsg2Reject::StaticMismatch
}
);
}
// ===========================================================================
// dial_outbound — initiator initial msg2 dial-identity classification
// ===========================================================================
#[test]
fn dial_outbound_matching_static_accepts() {
let fmp = Fmp::new();
let peer = make_node_addr(1);
let snap = DialMsg2Snapshot {
dialed_peer: Some(peer),
learned_peer: peer,
};
assert_eq!(fmp.dial_outbound(&snap), DialMsg2Decision::Accept);
}
#[test]
fn dial_outbound_foreign_static_rejects() {
let fmp = Fmp::new();
let snap = DialMsg2Snapshot {
dialed_peer: Some(make_node_addr(1)),
learned_peer: make_node_addr(2),
};
assert_eq!(
fmp.dial_outbound(&snap),
DialMsg2Decision::Reject {
reason: DialMsg2Reject::StaticMismatch
}
);
}
/// The anonymous carve-out: a dial that named nobody has no intent for the
/// answer to contradict, so any static is accepted and the ACL remains the only
/// gate. Breaking this stops shared-media discovery promoting anything.
#[test]
fn dial_outbound_anonymous_accepts_any_static() {
let fmp = Fmp::new();
for learned in [make_node_addr(1), make_node_addr(2), make_node_addr(200)] {
let snap = DialMsg2Snapshot {
dialed_peer: None,
learned_peer: learned,
};
assert_eq!(fmp.dial_outbound(&snap), DialMsg2Decision::Accept);
}
}
// ===== cross_connection_winner tie-break tests =====
#[test]
fn test_cross_connection_smaller_node_wins_outbound() {
let node_a = make_node_addr(1); // smaller
@@ -614,3 +815,108 @@ fn test_cross_connection_symmetric() {
// Exactly one survives
assert!(a_outbound_wins != a_inbound_wins);
}
// ===== Negotiation decision tests (relocated from protocol::negotiation) =====
#[test]
fn test_version_agreement_basic() {
let ours = NegotiationPayload::new(1, 3, 0);
let theirs = NegotiationPayload::new(1, 2, 0);
assert_eq!(ours.agree_version(&theirs).unwrap(), 2);
}
#[test]
fn test_version_agreement_mismatch() {
let ours = NegotiationPayload::new(3, 5, 0);
let theirs = NegotiationPayload::new(1, 2, 0);
assert!(ours.agree_version(&theirs).is_err());
}
#[test]
fn test_version_agreement_asymmetric() {
let ours = NegotiationPayload::new(2, 5, 0);
let theirs = NegotiationPayload::new(1, 4, 0);
assert_eq!(ours.agree_version(&theirs).unwrap(), 4);
assert_eq!(theirs.agree_version(&ours).unwrap(), 4);
}
#[test]
fn test_fmp_payload_full_profile() {
let p = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
assert_eq!(p.node_profile().unwrap(), NodeProfile::Full);
assert!(p.provides_sr());
assert!(p.provides_rr());
assert!(p.wants_sr());
assert!(p.wants_rr());
}
#[test]
fn test_fmp_payload_nonrouting_profile() {
let p = NegotiationPayload::fmp(1, 1, NodeProfile::NonRouting);
assert_eq!(p.node_profile().unwrap(), NodeProfile::NonRouting);
assert!(p.provides_sr());
assert!(p.provides_rr());
assert!(!p.wants_sr());
assert!(p.wants_rr());
}
#[test]
fn test_fmp_payload_leaf_profile() {
let p = NegotiationPayload::fmp(1, 1, NodeProfile::Leaf);
assert_eq!(p.node_profile().unwrap(), NodeProfile::Leaf);
assert!(!p.provides_sr());
assert!(p.provides_rr());
assert!(!p.wants_sr());
assert!(!p.wants_rr());
}
#[test]
fn test_fmp_payload_roundtrip() {
for profile in [
NodeProfile::Full,
NodeProfile::NonRouting,
NodeProfile::Leaf,
] {
let original = NegotiationPayload::fmp(1, 1, profile);
let encoded = original.encode();
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded, original);
assert_eq!(decoded.node_profile().unwrap(), profile);
}
}
#[test]
fn test_zero_features_is_full() {
let p = NegotiationPayload::new(1, 1, 0);
assert_eq!(p.node_profile().unwrap(), NodeProfile::Full);
assert!(!p.provides_sr());
assert!(!p.wants_sr());
}
#[test]
fn test_validate_profiles_valid() {
assert!(NegotiationPayload::validate_profiles(NodeProfile::Full, NodeProfile::Full).is_ok());
assert!(
NegotiationPayload::validate_profiles(NodeProfile::Full, NodeProfile::NonRouting).is_ok()
);
assert!(
NegotiationPayload::validate_profiles(NodeProfile::NonRouting, NodeProfile::Full).is_ok()
);
assert!(NegotiationPayload::validate_profiles(NodeProfile::Full, NodeProfile::Leaf).is_ok());
assert!(NegotiationPayload::validate_profiles(NodeProfile::Leaf, NodeProfile::Full).is_ok());
}
#[test]
fn test_validate_profiles_invalid() {
assert!(
NegotiationPayload::validate_profiles(NodeProfile::NonRouting, NodeProfile::NonRouting)
.is_err()
);
assert!(
NegotiationPayload::validate_profiles(NodeProfile::NonRouting, NodeProfile::Leaf).is_err()
);
assert!(
NegotiationPayload::validate_profiles(NodeProfile::Leaf, NodeProfile::NonRouting).is_err()
);
assert!(NegotiationPayload::validate_profiles(NodeProfile::Leaf, NodeProfile::Leaf).is_err());
}
+20 -1
View File
@@ -3,7 +3,7 @@
//! crypto-driving transition behavior is covered by the shell `peer::connection`
//! suite, and the handshake phase itself lives on the control machine.
use crate::proto::fmp::ConnectionState;
use crate::proto::fmp::{ConnectionState, NodeProfile};
use crate::transport::{LinkId, TransportAddr, TransportId};
use crate::utils::index::SessionIndex;
use crate::{Identity, PeerIdentity};
@@ -132,3 +132,22 @@ fn timing_and_touch() {
assert_eq!(state.duration(2500), 1500);
assert!(!state.is_timed_out(2500, 1000));
}
#[test]
fn outbound_anonymous_initializes_pure_fields() {
let state = ConnectionState::outbound_anonymous(LinkId::new(5), 4000);
assert!(state.is_outbound());
assert!(!state.is_inbound());
assert!(state.expected_identity().is_none());
assert_eq!(state.link_id(), LinkId::new(5));
assert_eq!(state.started_at(), 4000);
assert!(state.peer_profile().is_none());
}
#[test]
fn negotiation_results_round_trip() {
let mut state = ConnectionState::inbound(LinkId::new(1), 0);
assert!(state.peer_profile().is_none());
state.set_negotiation_results(NodeProfile::Full);
assert_eq!(state.peer_profile(), Some(NodeProfile::Full));
}
+22 -25
View File
@@ -1,12 +1,10 @@
//! Shared test helpers for the FMP connection-lifecycle unit tests.
use crate::proto::fmp::{
ConnSnapshot, EstablishSnapshot, PeerSnapshot, RekeyResendSnapshot, WireOutcome,
ConnSnapshot, EstablishSnapshot, PeerSnapshot, RekeyClaim, RekeyResendSnapshot, WireOutcome,
};
use crate::testutil::make_node_addr;
use crate::transport::LinkId;
use crate::utils::index::SessionIndex;
use crate::{Identity, PeerIdentity};
/// Build a `RekeyResendSnapshot` for the given peer byte, prior retransmission
/// count, due-flag, and opaque msg1 bytes.
@@ -35,6 +33,7 @@ pub(super) fn peer_snapshot(addr_byte: u8) -> PeerSnapshot {
is_draining: false,
drain_expired: false,
is_dampened: false,
rekey_msg3_pending: false,
elapsed_secs: 0,
counter: 0,
jitter_secs: 0,
@@ -71,35 +70,33 @@ pub(super) fn resend_snapshot(link: LinkId, resend_count: u32, msg1: Vec<u8>) ->
}
}
/// Build a quiescent `EstablishSnapshot` for a net-new inbound msg1: no existing
/// peer, not at cap, rekey enabled, our node addr fixed. Tests override only the
/// fields the case exercises.
pub(super) fn establish_snapshot() -> EstablishSnapshot {
/// Build an `EstablishSnapshot` describing an existing, healthy, same-epoch
/// peer owned by node `our_byte`.
/// The default is a quiescent session on the same link with no
/// in-flight rekey / pending and no rekey declared by the sender. Tests override
/// only the fields their branch exercises. `existing_peer_epoch` defaults to
/// `[0x01; 8]`.
pub(super) fn establish_snapshot(our_byte: u8) -> EstablishSnapshot {
EstablishSnapshot {
has_existing_peer: false,
existing_peer_epoch: None,
existing_session_age_secs: 0,
has_session: false,
is_healthy: false,
has_existing_peer: true,
existing_peer_epoch: Some([0x01; 8]),
has_session: true,
is_healthy: true,
pending_new_session: false,
rekey_in_progress: false,
existing_msg2: None,
at_max_peers: false,
has_pending_outbound_to_peer: false,
rekey_enabled: true,
our_node_addr: make_node_addr(0x10),
different_link: false,
rekey_claim: RekeyClaim::None,
our_node_addr: make_node_addr(our_byte),
}
}
/// Build a `WireOutcome` carrying a freshly generated peer identity and the
/// given remote epoch (empty msg2 payload, fixed sender index). Callers read the
/// peer's NodeAddr back via `wire.peer_identity.node_addr()` when they need it
/// for the tie-break.
pub(super) fn wire_outcome(remote_epoch: Option<[u8; 8]>) -> WireOutcome {
/// Build a `WireOutcome` naming the initiator `peer_byte` and its captured
/// startup epoch. `[0x01; 8]` matches the `establish_snapshot` default (a
/// same-epoch handshake); a different epoch models a restart.
pub(super) fn wire_outcome(peer_byte: u8, epoch: [u8; 8]) -> WireOutcome {
WireOutcome {
peer_identity: PeerIdentity::from_pubkey_full(Identity::generate().pubkey_full()),
remote_epoch,
their_index: SessionIndex::new(0x1234),
msg2_payload: Vec::new(),
peer_node_addr: make_node_addr(peer_byte),
remote_epoch: Some(epoch),
}
}
+183 -5
View File
@@ -1,14 +1,20 @@
//! Tests for the FMP link-framing wire codec.
//! Tests for the FMP wire codec: XX handshake framing, orderly disconnect,
//! and the negotiation payload.
use crate::proto::fmp::{Disconnect, DisconnectReason, HandshakeMessageType};
use super::super::wire::{NEGOTIATION_HEADER_SIZE, TLV_REKEY_OF};
use crate::proto::fmp::{
Disconnect, DisconnectReason, HandshakeMessageType, NegotiationPayload, NodeProfile,
};
use crate::utils::index::SessionIndex;
// ===== HandshakeMessageType Tests =====
#[test]
fn test_handshake_message_type_roundtrip() {
let types = [
HandshakeMessageType::NoiseIKMsg1,
HandshakeMessageType::NoiseIKMsg2,
HandshakeMessageType::Msg1,
HandshakeMessageType::Msg2,
HandshakeMessageType::Msg3,
];
for ty in types {
@@ -21,7 +27,7 @@ fn test_handshake_message_type_roundtrip() {
#[test]
fn test_handshake_message_type_invalid() {
assert!(HandshakeMessageType::from_byte(0x00).is_none());
assert!(HandshakeMessageType::from_byte(0x03).is_none());
assert!(HandshakeMessageType::from_byte(0x04).is_none());
assert!(HandshakeMessageType::from_byte(0x10).is_none());
}
@@ -29,7 +35,9 @@ fn test_handshake_message_type_invalid() {
fn test_handshake_message_type_is_handshake() {
assert!(HandshakeMessageType::is_handshake(0x01));
assert!(HandshakeMessageType::is_handshake(0x02));
assert!(HandshakeMessageType::is_handshake(0x03));
assert!(!HandshakeMessageType::is_handshake(0x00));
assert!(!HandshakeMessageType::is_handshake(0x04));
assert!(!HandshakeMessageType::is_handshake(0x10));
}
@@ -107,3 +115,173 @@ fn test_disconnect_decode_unknown_reason() {
let decoded = Disconnect::decode(&[0x80]).unwrap();
assert_eq!(decoded.reason, DisconnectReason::Other);
}
// ===== Negotiation payload codec Tests =====
#[test]
fn test_encode_decode_roundtrip() {
let payload = NegotiationPayload::new(1, 3, 0x00000000_0000002A);
let encoded = payload.encode();
assert_eq!(encoded.len(), NEGOTIATION_HEADER_SIZE);
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded, payload);
}
#[test]
fn test_encode_decode_with_tlv() {
let payload = NegotiationPayload::new(0, 1, 0)
.with_tlv(1, vec![0xAA, 0xBB])
.with_tlv(256, vec![0x01, 0x02, 0x03, 0x04]);
let encoded = payload.encode();
// 10 header + (2+2+2) + (2+2+4) = 10 + 6 + 8 = 24
assert_eq!(encoded.len(), 24);
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded, payload);
assert_eq!(decoded.tlv_entries.len(), 2);
assert_eq!(decoded.tlv_entries[0].field_num, 1);
assert_eq!(decoded.tlv_entries[0].value, vec![0xAA, 0xBB]);
assert_eq!(decoded.tlv_entries[1].field_num, 256);
assert_eq!(decoded.tlv_entries[1].value, vec![0x01, 0x02, 0x03, 0x04]);
}
#[test]
fn test_unknown_format_rejected() {
let mut data = NegotiationPayload::new(0, 0, 0).encode();
data[0] = 1; // Set format to 1
assert!(NegotiationPayload::decode(&data).is_err());
}
#[test]
fn test_invalid_version_range() {
let mut data = NegotiationPayload::new(0, 0, 0).encode();
// Set version_min=5, version_max=3 (invalid: min > max)
data[1] = (5 << 4) | 3;
assert!(NegotiationPayload::decode(&data).is_err());
}
#[test]
fn test_unknown_tlv_forward_compat() {
// Unknown field_nums should be preserved through encode/decode
let payload = NegotiationPayload::new(0, 1, 0).with_tlv(9999, vec![0xFF, 0xFE, 0xFD]);
let encoded = payload.encode();
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded.tlv_entries.len(), 1);
assert_eq!(decoded.tlv_entries[0].field_num, 9999);
assert_eq!(decoded.tlv_entries[0].value, vec![0xFF, 0xFE, 0xFD]);
}
#[test]
fn test_empty_payload() {
let payload = NegotiationPayload::new(0, 0, 0);
let encoded = payload.encode();
assert_eq!(encoded.len(), NEGOTIATION_HEADER_SIZE);
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded.version_min, 0);
assert_eq!(decoded.version_max, 0);
assert_eq!(decoded.features, 0);
assert!(decoded.tlv_entries.is_empty());
}
#[test]
fn test_truncated_payload() {
// Less than header size
assert!(NegotiationPayload::decode(&[0u8; 5]).is_err());
assert!(NegotiationPayload::decode(&[]).is_err());
}
#[test]
fn test_truncated_tlv() {
let payload = NegotiationPayload::new(0, 1, 0).with_tlv(1, vec![0xAA, 0xBB, 0xCC]);
let mut encoded = payload.encode();
// Truncate the TLV value (remove last byte)
encoded.pop();
assert!(NegotiationPayload::decode(&encoded).is_err());
// Truncate to just partial TLV header (only 2 of 4 header bytes)
let mut partial = NegotiationPayload::new(0, 1, 0).encode();
partial.extend_from_slice(&[0x01, 0x00]); // Only field_num, no length
assert!(NegotiationPayload::decode(&partial).is_err());
}
// ===== Rekey marker Tests =====
#[test]
fn test_rekey_marker_roundtrip() {
let index = SessionIndex::new(0xDEADBEEF);
let payload = NegotiationPayload::fmp(1, 1, NodeProfile::Full).with_rekey_of(index);
let decoded = NegotiationPayload::decode(&payload.encode()).unwrap();
assert_eq!(decoded.rekey_of().unwrap(), Some(index));
}
#[test]
fn test_rekey_marker_absent_is_none() {
let payload = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
let decoded = NegotiationPayload::decode(&payload.encode()).unwrap();
assert_eq!(decoded.rekey_of().unwrap(), None);
}
/// The forward-compatibility property the marker's rolling upgrade rests on: a
/// peer that adds TLV fields we do not know must not disturb our reading of the
/// one we do. Both directions matter — an unknown field alone still reads as
/// "no rekey declared", and an unknown field alongside the marker still yields
/// the marker.
#[test]
fn test_rekey_marker_unaffected_by_unknown_tlv() {
let unknown_only = NegotiationPayload::fmp(1, 1, NodeProfile::Full)
.with_tlv(9999, vec![0xFF, 0xFE, 0xFD])
.encode();
let decoded = NegotiationPayload::decode(&unknown_only).unwrap();
assert_eq!(decoded.rekey_of().unwrap(), None);
let index = SessionIndex::new(7);
let with_both = NegotiationPayload::fmp(1, 1, NodeProfile::Full)
.with_tlv(9999, vec![0xFF, 0xFE, 0xFD])
.with_rekey_of(index)
.with_tlv(4242, vec![0x01])
.encode();
let decoded = NegotiationPayload::decode(&with_both).unwrap();
assert_eq!(decoded.rekey_of().unwrap(), Some(index));
}
/// A marker whose value is the wrong length is an error, never an absence.
///
/// This is the case the container codec cannot catch and the accessor exists
/// for: the TLV is well formed — correct field number, length matching its own
/// value — so `decode` accepts it, and only the accessor can tell that four
/// bytes were expected. Reading it as "no rekey declared" would put a real rekey
/// back on the cross-connection path, which is the defect the marker removes.
#[test]
fn test_rekey_marker_wrong_length_is_error() {
for value in [
vec![],
vec![0x01, 0x02, 0x03],
vec![0x01, 0x02, 0x03, 0x04, 0x05],
] {
let len = value.len();
let encoded = NegotiationPayload::fmp(1, 1, NodeProfile::Full)
.with_tlv(TLV_REKEY_OF, value)
.encode();
let decoded = NegotiationPayload::decode(&encoded)
.unwrap_or_else(|e| panic!("{len}-byte marker should decode as a TLV: {e}"));
assert!(
decoded.rekey_of().is_err(),
"{len}-byte marker must be an error, not an absence"
);
}
}
#[test]
fn test_node_profile_try_from() {
assert_eq!(NodeProfile::try_from(0).unwrap(), NodeProfile::Full);
assert_eq!(NodeProfile::try_from(1).unwrap(), NodeProfile::NonRouting);
assert_eq!(NodeProfile::try_from(2).unwrap(), NodeProfile::Leaf);
assert!(NodeProfile::try_from(3).is_err());
assert!(NodeProfile::try_from(7).is_err());
}
+486 -75
View File
@@ -1,18 +1,16 @@
//! FMP link-framing messages and link-layer wire codec.
//! FMP wire codec: XX handshake framing, orderly disconnect, and the
//! protocol-negotiation payload.
//!
//! The Noise IK handshake message-type discriminants and the orderly
//! disconnect codec, per the wire-migrates-with-subsystem policy.
//! `Disconnect::encode` reads the shared `LinkMessageType::Disconnect` catalog
//! variant (a downward `proto -> proto` dependency); the catalog itself lives
//! in `crate::proto::link`.
//!
//! This module also carries the FMP mesh-layer packet-dispatch wire format
//! (the common prefix, encrypted/msg1/msg2 headers, and the `build_*`/
//! inner-header codec functions), relocated from `node/wire.rs` so all FMP
//! wire lives with its subsystem. See the "FMP link wire framing" section
//! banner below for the packet-type layout.
//! The Noise XX handshake message-type discriminants and the orderly
//! disconnect codec, relocated from `protocol::link`, plus the
//! negotiation-payload codec relocated from `protocol::negotiation`, per the
//! wire-migrates-with-subsystem policy. `Disconnect::encode` reads the shared
//! `LinkMessageType::Disconnect` catalog variant (a downward `proto ->
//! protocol` dependency); the catalog itself stays in `protocol::link`. The
//! negotiation *decision* logic (version agreement, profile validation, FMP
//! feature helpers) lives in `core.rs`; only the payload codec is here.
use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, TAG_SIZE};
use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, HANDSHAKE_MSG3_SIZE, TAG_SIZE};
use crate::proto::Error;
use crate::proto::codec::Reader;
use crate::proto::link::LinkMessageType;
@@ -21,27 +19,32 @@ use ::core::fmt;
/// Handshake message type identifiers.
///
/// These messages are exchanged during Noise IK handshake before link
/// These messages are exchanged during Noise XX handshake before link
/// encryption is established. They use the same TLV framing as link
/// messages but payloads are not encrypted (except Noise-internal encryption).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum HandshakeMessageType {
/// Noise IK message 1: initiator sends ephemeral + encrypted static.
/// Payload: 82 bytes (33 ephemeral + 33 static + 16 tag).
NoiseIKMsg1 = 0x01,
/// Noise XX message 1: initiator sends ephemeral key.
/// Payload: 33 bytes (ephemeral pubkey).
Msg1 = 0x01,
/// Noise IK message 2: responder sends ephemeral.
/// Payload: 33 bytes (ephemeral pubkey only).
NoiseIKMsg2 = 0x02,
/// Noise XX message 2: responder sends ephemeral + encrypted static + epoch.
/// Payload: 106+ bytes (33 ephemeral + 49 encrypted static + 24 encrypted epoch + negotiation).
Msg2 = 0x02,
/// Noise XX message 3: initiator sends encrypted static + epoch.
/// Payload: 73+ bytes (49 encrypted static + 24 encrypted epoch + negotiation).
Msg3 = 0x03,
}
impl HandshakeMessageType {
/// Try to convert from a byte.
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(HandshakeMessageType::NoiseIKMsg1),
0x02 => Some(HandshakeMessageType::NoiseIKMsg2),
0x01 => Some(HandshakeMessageType::Msg1),
0x02 => Some(HandshakeMessageType::Msg2),
0x03 => Some(HandshakeMessageType::Msg3),
_ => None,
}
}
@@ -53,15 +56,16 @@ impl HandshakeMessageType {
/// Check if a byte represents a handshake message type.
pub fn is_handshake(b: u8) -> bool {
matches!(b, 0x01 | 0x02)
matches!(b, 0x01..=0x03)
}
}
impl fmt::Display for HandshakeMessageType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
HandshakeMessageType::NoiseIKMsg1 => "NoiseIKMsg1",
HandshakeMessageType::NoiseIKMsg2 => "NoiseIKMsg2",
HandshakeMessageType::Msg1 => "Msg1",
HandshakeMessageType::Msg2 => "Msg2",
HandshakeMessageType::Msg3 => "Msg3",
};
write!(f, "{}", name)
}
@@ -169,6 +173,242 @@ impl Disconnect {
}
}
// ============================================================================
// Protocol Negotiation Payload
// ============================================================================
//
// Encodes/decodes the negotiation payload embedded in XX handshake
// messages (msg2/msg3). Each layer (FMP, FSP) uses the same wire format with
// layer-specific version ranges and feature catalogs.
//
// Wire Format:
// Byte 0: format (must be 0)
// Byte 1: [version_min:4 high][version_max:4 low]
// Bytes 2-9: feature bitfield (64 bits, LE)
// Bytes 10+: TLV entries, each: [field_num:2 LE][length:2 LE][value:N]
/// Size of the fixed negotiation header (format + version + features).
pub const NEGOTIATION_HEADER_SIZE: usize = 10;
/// Format byte value for the initial negotiation format.
pub(crate) const NEGOTIATION_FORMAT_V0: u8 = 0;
/// TLV field number for the rekey marker.
///
/// Its value is the session index the *receiver* allocated for the session this
/// handshake replaces. Absence means the handshake is not a rekey.
pub const TLV_REKEY_OF: u16 = 1;
// --- FMP feature bitfield constants ---
/// Mask for the 3-bit node profile enum (bits 0-2).
pub const FMP_FEAT_PROFILE_MASK: u64 = 0x07;
/// Bit 3: Can provide MMP sender reports.
pub const FMP_FEAT_PROVIDES_SR: u64 = 1 << 3;
/// Bit 4: Can provide MMP receiver reports.
pub const FMP_FEAT_PROVIDES_RR: u64 = 1 << 4;
/// Bit 5: Want MMP sender reports from peer.
pub const FMP_FEAT_WANTS_SR: u64 = 1 << 5;
/// Bit 6: Want MMP receiver reports from peer.
pub const FMP_FEAT_WANTS_RR: u64 = 1 << 6;
/// Node profile advertised during FMP negotiation.
///
/// Encoded in bits 0-2 of the FMP feature bitfield. Self-declared (not
/// AND-intersected). At least one side of a link must be `Full` or the
/// link is rejected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum NodeProfile {
/// Full routing node. Combines bloom filters, forwards transit.
Full = 0,
/// Non-routing node. Tree participation, one-way bloom receipt,
/// no transit forwarding.
NonRouting = 1,
/// Leaf node. Single upstream peer, no tree/bloom/transit.
Leaf = 2,
}
impl fmt::Display for NodeProfile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Full => write!(f, "full"),
Self::NonRouting => write!(f, "non-routing"),
Self::Leaf => write!(f, "leaf"),
}
}
}
impl TryFrom<u8> for NodeProfile {
type Error = Error;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Full),
1 => Ok(Self::NonRouting),
2 => Ok(Self::Leaf),
_ => Err(Error::Malformed("unknown node profile")),
}
}
}
/// A TLV entry in the negotiation payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlvEntry {
/// Field number identifying this TLV.
pub field_num: u16,
/// Raw value bytes.
pub value: Vec<u8>,
}
/// Protocol negotiation payload.
///
/// Carried in XX msg2/msg3 encrypted payloads. Shared codec for both
/// FMP and FSP layers, with layer-specific version ranges and feature
/// bit assignments.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NegotiationPayload {
/// Format byte (must be 0).
pub format: u8,
/// Minimum protocol version supported (4-bit, 0-15).
pub version_min: u8,
/// Maximum protocol version supported (4-bit, 0-15).
pub version_max: u8,
/// Feature bitfield (64 bits, LE).
pub features: u64,
/// Optional TLV extension entries.
pub tlv_entries: Vec<TlvEntry>,
}
impl NegotiationPayload {
/// Create a new negotiation payload.
pub fn new(version_min: u8, version_max: u8, features: u64) -> Self {
Self {
format: NEGOTIATION_FORMAT_V0,
version_min,
version_max,
features,
tlv_entries: Vec::new(),
}
}
/// Add a TLV entry.
pub fn with_tlv(mut self, field_num: u16, value: Vec<u8>) -> Self {
self.tlv_entries.push(TlvEntry { field_num, value });
self
}
/// Declare this handshake a rekey of the session the receiver indexes as
/// `their_index`.
///
/// The marker names the *receiver's* index, not the sender's, because the
/// receiver matches it against its own `our_index`. Absence means the
/// handshake is not a rekey — a responder cannot otherwise tell a rekey from
/// a fresh dial, since both arrive as a new msg1 on a new link.
pub fn with_rekey_of(self, their_index: SessionIndex) -> Self {
self.with_tlv(TLV_REKEY_OF, their_index.to_le_bytes().to_vec())
}
/// The session index this handshake declares it replaces, if any.
///
/// A present-but-malformed marker is an **error**, not an absence. Reading a
/// truncated marker as "not a rekey" would reintroduce the silent
/// fall-through this field exists to remove.
pub fn rekey_of(&self) -> Result<Option<SessionIndex>, Error> {
let Some(entry) = self
.tlv_entries
.iter()
.find(|e| e.field_num == TLV_REKEY_OF)
else {
return Ok(None);
};
let bytes: [u8; 4] = entry
.value
.as_slice()
.try_into()
.map_err(|_| Error::Malformed("rekey marker is not 4 bytes"))?;
Ok(Some(SessionIndex::from_le_bytes(bytes)))
}
/// Encode to wire format.
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(NEGOTIATION_HEADER_SIZE);
buf.push(self.format);
buf.push((self.version_min << 4) | (self.version_max & 0x0F));
buf.extend_from_slice(&self.features.to_le_bytes());
for entry in &self.tlv_entries {
buf.extend_from_slice(&entry.field_num.to_le_bytes());
let len = entry.value.len() as u16;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&entry.value);
}
buf
}
/// Decode from wire format.
pub fn decode(data: &[u8]) -> Result<Self, Error> {
if data.len() < NEGOTIATION_HEADER_SIZE {
return Err(Error::MessageTooShort {
expected: NEGOTIATION_HEADER_SIZE,
got: data.len(),
});
}
let format = data[0];
if format != NEGOTIATION_FORMAT_V0 {
return Err(Error::Malformed("unknown negotiation format"));
}
let version_min = data[1] >> 4;
let version_max = data[1] & 0x0F;
if version_min > version_max {
return Err(Error::Malformed("version_min > version_max"));
}
let features = u64::from_le_bytes(data[2..10].try_into().unwrap());
let mut tlv_entries = Vec::new();
let mut offset = NEGOTIATION_HEADER_SIZE;
while offset < data.len() {
// Need at least 4 bytes for field_num + length
if offset + 4 > data.len() {
return Err(Error::Malformed("truncated TLV header"));
}
let field_num = u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap());
let length =
u16::from_le_bytes(data[offset + 2..offset + 4].try_into().unwrap()) as usize;
offset += 4;
if offset + length > data.len() {
return Err(Error::Malformed(
"TLV field declared length exceeds remaining data",
));
}
let value = data[offset..offset + length].to_vec();
offset += length;
tlv_entries.push(TlvEntry { field_num, value });
}
Ok(Self {
format,
version_min,
version_max,
features,
tlv_entries,
})
}
}
// ============================================================================
// FMP link wire framing (relocated from node/wire.rs)
// ============================================================================
@@ -185,36 +425,45 @@ impl Disconnect {
// | Phase | Type | Size | Description |
// |-------|-----------------|------------|--------------------------------|
// | 0x0 | Encrypted frame | 32+ bytes | Post-handshake encrypted data |
// | 0x1 | Noise IK msg1 | 114 bytes | Handshake initiation |
// | 0x2 | Noise IK msg2 | 69 bytes | Handshake response |
// | 0x1 | Noise XX msg1 | 41 bytes | Handshake initiation |
// | 0x2 | Noise XX msg2 | 118+ bytes | Handshake response |
// | 0x3 | Noise XX msg3 | 85+ bytes | Handshake completion |
// ============================================================================
// Constants
// ============================================================================
/// FMP protocol version (4 high bits of byte 0).
pub const FMP_VERSION: u8 = 0;
pub const FMP_VERSION: u8 = 1;
/// Phase value for established (encrypted) frames.
pub const PHASE_ESTABLISHED: u8 = 0x0;
/// Phase value for Noise IK message 1 (handshake initiation).
/// Phase value for handshake message 1 (initiation).
pub const PHASE_MSG1: u8 = 0x1;
/// Phase value for Noise IK message 2 (handshake response).
/// Phase value for handshake message 2 (response).
pub const PHASE_MSG2: u8 = 0x2;
/// Phase value for handshake message 3 (completion, XX only).
pub const PHASE_MSG3: u8 = 0x3;
/// Size of the common packet prefix (all packet types).
pub const COMMON_PREFIX_SIZE: usize = 4;
/// Size of the full established frame header (prefix + receiver_idx + counter).
pub const ESTABLISHED_HEADER_SIZE: usize = 16;
/// Size of Noise IK message 1 wire packet: prefix + sender_idx + noise_msg1.
pub const MSG1_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + HANDSHAKE_MSG1_SIZE; // 114 bytes
/// Size of handshake msg1 wire packet: prefix + sender_idx + noise_msg1.
pub const MSG1_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + HANDSHAKE_MSG1_SIZE; // 41 bytes
/// Size of Noise IK message 2 wire packet: prefix + sender_idx + receiver_idx + noise_msg2.
pub const MSG2_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + 4 + HANDSHAKE_MSG2_SIZE; // 69 bytes
/// Minimum size of handshake msg2 wire packet: prefix + sender_idx + receiver_idx + noise_msg2.
/// Actual size may be larger due to optional negotiation payload.
pub const MSG2_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + 4 + HANDSHAKE_MSG2_SIZE; // 118 bytes
/// Minimum size of handshake msg3 wire packet: prefix + sender_idx + receiver_idx + noise_msg3.
/// Actual size may be larger due to optional negotiation payload.
pub const MSG3_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + 4 + HANDSHAKE_MSG3_SIZE; // 85 bytes
/// Minimum size for encrypted frame: header + tag (no plaintext).
pub const ENCRYPTED_MIN_SIZE: usize = ESTABLISHED_HEADER_SIZE + TAG_SIZE; // 32 bytes
@@ -230,9 +479,6 @@ pub const FLAG_KEY_EPOCH: u8 = 0x01;
#[allow(dead_code)]
/// Congestion Experienced echo flag.
pub const FLAG_CE: u8 = 0x02;
#[allow(dead_code)]
/// Spin bit for RTT measurement.
pub const FLAG_SP: u8 = 0x04;
// ============================================================================
// Common Prefix
@@ -363,11 +609,11 @@ impl EncryptedHeader {
// Msg1 Header
// ============================================================================
/// Parsed Noise IK message 1 header (phase 0x1).
/// Parsed handshake message 1 header (phase 0x1).
///
/// Wire format (114 bytes):
/// Wire format (41 bytes, Noise XX):
/// ```text
/// [0x01][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:106]
/// [0x11][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:33]
/// ```
#[derive(Clone, Debug)]
pub struct Msg1Header {
@@ -417,12 +663,13 @@ impl Msg1Header {
// Msg2 Header
// ============================================================================
/// Parsed Noise IK message 2 header (phase 0x2).
/// Parsed handshake message 2 header (phase 0x2).
///
/// Wire format (69 bytes):
/// Wire format (118+ bytes, Noise XX):
/// ```text
/// [0x02][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:57]
/// [0x12][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:106+]
/// ```
/// Size is variable due to optional negotiation payload appended after base XX msg2.
#[derive(Clone, Debug)]
pub struct Msg2Header {
/// Session index chosen by the responder.
@@ -436,9 +683,10 @@ pub struct Msg2Header {
impl Msg2Header {
/// Parse a msg2 header from packet data.
///
/// Returns None if the packet has wrong size or version/phase.
/// Returns None if the packet is too short or has wrong version/phase.
/// Accepts variable size (base + optional negotiation payload).
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() != MSG2_WIRE_SIZE {
if data.len() < MSG2_WIRE_SIZE {
return None;
}
@@ -464,11 +712,83 @@ impl Msg2Header {
})
}
/// Get the Noise msg2 payload from the original packet.
/// Get the Noise msg2 payload from the original packet (variable length).
#[cfg(test)]
pub fn noise_msg2<'a>(&self, data: &'a [u8]) -> &'a [u8] {
&data[self.noise_msg2_offset..]
}
/// Get the total noise payload length (base + optional negotiation).
#[allow(dead_code)]
pub fn noise_payload_len(&self, data: &[u8]) -> usize {
data.len() - self.noise_msg2_offset
}
}
// ============================================================================
// Msg3 Header
// ============================================================================
/// Parsed handshake message 3 header (phase 0x3, XX pattern).
///
/// Wire format (85+ bytes, Noise XX):
/// ```text
/// [0x13][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg3:73+]
/// ```
/// Size is variable due to optional negotiation payload appended after base XX msg3.
#[derive(Clone, Debug)]
pub struct Msg3Header {
/// Session index chosen by the initiator (echo of msg1 sender_idx).
pub sender_idx: SessionIndex,
/// Echo of the responder's sender_idx from msg2.
pub receiver_idx: SessionIndex,
/// Offset where Noise msg3 payload begins.
pub noise_msg3_offset: usize,
}
impl Msg3Header {
/// Parse a msg3 header from packet data.
///
/// Returns None if the packet is too short or has wrong version/phase.
/// Accepts variable size (base + optional negotiation payload).
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() < MSG3_WIRE_SIZE {
return None;
}
let version = data[0] >> 4;
let phase = data[0] & 0x0F;
if version != FMP_VERSION || phase != PHASE_MSG3 {
return None;
}
// flags must be zero during handshake
if data[1] != 0 {
return None;
}
let sender_idx = SessionIndex::from_le_bytes([data[4], data[5], data[6], data[7]]);
let receiver_idx = SessionIndex::from_le_bytes([data[8], data[9], data[10], data[11]]);
Some(Self {
sender_idx,
receiver_idx,
noise_msg3_offset: COMMON_PREFIX_SIZE + 4 + 4, // 12
})
}
/// Get the Noise msg3 payload from the original packet (variable length).
#[cfg(test)]
pub fn noise_msg3<'a>(&self, data: &'a [u8]) -> &'a [u8] {
&data[self.noise_msg3_offset..]
}
/// Get the total noise payload length (base + optional negotiation).
#[allow(dead_code)]
pub fn noise_payload_len(&self, data: &[u8]) -> usize {
data.len() - self.noise_msg3_offset
}
}
// ============================================================================
@@ -477,7 +797,7 @@ impl Msg2Header {
/// Build a wire-format msg1 packet.
///
/// Format: `[0x01][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:106]`
/// Format: `[0x11][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:33]`
pub fn build_msg1(sender_idx: SessionIndex, noise_msg1: &[u8]) -> Vec<u8> {
debug_assert_eq!(noise_msg1.len(), HANDSHAKE_MSG1_SIZE);
@@ -494,17 +814,19 @@ pub fn build_msg1(sender_idx: SessionIndex, noise_msg1: &[u8]) -> Vec<u8> {
/// Build a wire-format msg2 packet.
///
/// Format: `[0x02][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:57]`
/// Format: `[0x12][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:106+]`
/// The noise_msg2 may include an optional negotiation payload beyond the base XX msg2.
pub fn build_msg2(
sender_idx: SessionIndex,
receiver_idx: SessionIndex,
noise_msg2: &[u8],
) -> Vec<u8> {
debug_assert_eq!(noise_msg2.len(), HANDSHAKE_MSG2_SIZE);
debug_assert!(noise_msg2.len() >= HANDSHAKE_MSG2_SIZE);
let payload_len = (4 + 4 + noise_msg2.len()) as u16; // sender + receiver + noise
let total = COMMON_PREFIX_SIZE + 4 + 4 + noise_msg2.len();
let mut packet = Vec::with_capacity(MSG2_WIRE_SIZE);
let mut packet = Vec::with_capacity(total);
packet.push(CommonPrefix::ver_phase_byte(FMP_VERSION, PHASE_MSG2));
packet.push(0x00); // flags must be zero
packet.extend_from_slice(&payload_len.to_le_bytes());
@@ -514,6 +836,30 @@ pub fn build_msg2(
packet
}
/// Build a wire-format msg3 packet (XX handshake completion).
///
/// Format: `[0x13][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg3:73+]`
/// The noise_msg3 may include an optional negotiation payload beyond the base XX msg3.
pub fn build_msg3(
sender_idx: SessionIndex,
receiver_idx: SessionIndex,
noise_msg3: &[u8],
) -> Vec<u8> {
debug_assert!(noise_msg3.len() >= HANDSHAKE_MSG3_SIZE);
let payload_len = (4 + 4 + noise_msg3.len()) as u16; // sender + receiver + noise
let total = COMMON_PREFIX_SIZE + 4 + 4 + noise_msg3.len();
let mut packet = Vec::with_capacity(total);
packet.push(CommonPrefix::ver_phase_byte(FMP_VERSION, PHASE_MSG3));
packet.push(0x00); // flags must be zero
packet.extend_from_slice(&payload_len.to_le_bytes());
packet.extend_from_slice(&sender_idx.to_le_bytes());
packet.extend_from_slice(&receiver_idx.to_le_bytes());
packet.extend_from_slice(noise_msg3);
packet
}
/// Build the 16-byte outer header for an established frame.
///
/// Returns the header bytes (for use as AAD) separately from the construction.
@@ -582,11 +928,11 @@ mod tests {
#[test]
fn test_common_prefix_parse() {
let data = [0x00, 0x04, 0x20, 0x00]; // ver=0, phase=0, flags=SP, payload_len=32
let data = [0x10, 0x02, 0x20, 0x00]; // ver=1, phase=0, flags=CE, payload_len=32
let prefix = CommonPrefix::parse(&data).unwrap();
assert_eq!(prefix.version, 0);
assert_eq!(prefix.version, 1);
assert_eq!(prefix.phase, 0);
assert_eq!(prefix.flags, FLAG_SP);
assert_eq!(prefix.flags, FLAG_CE);
assert_eq!(prefix.payload_len, 32);
}
@@ -607,7 +953,7 @@ mod tests {
let packet = build_encrypted(&header, &ciphertext);
assert_eq!(packet.len(), ESTABLISHED_HEADER_SIZE + 48);
assert_eq!(packet[0], 0x00); // ver=0, phase=0
assert_eq!(packet[0], 0x10); // ver=1, phase=0
let parsed = EncryptedHeader::parse(&packet).expect("should parse");
assert_eq!(parsed.receiver_idx, receiver_idx);
@@ -627,14 +973,14 @@ mod tests {
#[test]
fn test_encrypted_header_wrong_phase() {
let mut packet = vec![0x00; ENCRYPTED_MIN_SIZE];
packet[0] = 0x01; // phase 1 (msg1), not established
packet[0] = 0x11; // ver=1, phase 1 (msg1), not established
assert!(EncryptedHeader::parse(&packet).is_none());
}
#[test]
fn test_encrypted_header_wrong_version() {
let mut packet = vec![0x00; ENCRYPTED_MIN_SIZE];
packet[0] = 0x10; // version 1, phase 0
packet[0] = 0x00; // version 0 (old), phase 0
assert!(EncryptedHeader::parse(&packet).is_none());
}
@@ -646,7 +992,7 @@ mod tests {
let packet = build_msg1(sender_idx, &noise_msg1);
assert_eq!(packet.len(), MSG1_WIRE_SIZE);
assert_eq!(packet[0], 0x01); // ver=0, phase=1
assert_eq!(packet[0], 0x11); // ver=1, phase=1
let header = Msg1Header::parse(&packet).expect("should parse");
assert_eq!(header.sender_idx, sender_idx);
@@ -656,17 +1002,17 @@ mod tests {
#[test]
fn test_msg1_header_wrong_size() {
let packet = vec![0x01; MSG1_WIRE_SIZE - 1];
let packet = vec![0x11; MSG1_WIRE_SIZE - 1];
assert!(Msg1Header::parse(&packet).is_none());
let packet = vec![0x01; MSG1_WIRE_SIZE + 1];
let packet = vec![0x11; MSG1_WIRE_SIZE + 1];
assert!(Msg1Header::parse(&packet).is_none());
}
#[test]
fn test_msg1_header_wrong_phase() {
let mut packet = vec![0x00; MSG1_WIRE_SIZE];
packet[0] = 0x02; // phase 2, not phase 1
packet[0] = 0x12; // ver=1, phase 2, not phase 1
assert!(Msg1Header::parse(&packet).is_none());
}
@@ -686,7 +1032,7 @@ mod tests {
let packet = build_msg2(sender_idx, receiver_idx, &noise_msg2);
assert_eq!(packet.len(), MSG2_WIRE_SIZE);
assert_eq!(packet[0], 0x02); // ver=0, phase=2
assert_eq!(packet[0], 0x12); // ver=1, phase=2
let header = Msg2Header::parse(&packet).expect("should parse");
assert_eq!(header.sender_idx, sender_idx);
@@ -697,24 +1043,29 @@ mod tests {
#[test]
fn test_msg2_header_wrong_size() {
let packet = vec![0x02; MSG2_WIRE_SIZE - 1];
let packet = vec![0x12; MSG2_WIRE_SIZE - 1];
assert!(Msg2Header::parse(&packet).is_none());
let packet = vec![0x02; MSG2_WIRE_SIZE + 1];
assert!(Msg2Header::parse(&packet).is_none());
// Larger than minimum is now accepted (variable-length negotiation payload)
let mut packet = vec![0x12; MSG2_WIRE_SIZE + 10];
packet[0] = 0x12; // ver=1, phase=2
packet[1] = 0x00;
let header = Msg2Header::parse(&packet);
assert!(header.is_some());
}
#[test]
fn test_msg2_header_wrong_phase() {
let mut packet = vec![0x00; MSG2_WIRE_SIZE];
packet[0] = 0x00; // phase 0, not phase 2
packet[0] = 0x10; // ver=1, phase 0, not phase 2
assert!(Msg2Header::parse(&packet).is_none());
}
#[test]
fn test_wire_sizes() {
assert_eq!(MSG1_WIRE_SIZE, 114); // 4 + 4 + 106
assert_eq!(MSG2_WIRE_SIZE, 69); // 4 + 4 + 4 + 57
assert_eq!(MSG1_WIRE_SIZE, 41); // 4 + 4 + 33 (XX msg1)
assert_eq!(MSG2_WIRE_SIZE, 118); // 4 + 4 + 4 + 106 (XX msg2 minimum)
assert_eq!(MSG3_WIRE_SIZE, 85); // 4 + 4 + 4 + 73 (XX msg3 minimum)
assert_eq!(ENCRYPTED_MIN_SIZE, 32); // 16 + 16
assert_eq!(COMMON_PREFIX_SIZE, 4);
assert_eq!(ESTABLISHED_HEADER_SIZE, 16);
@@ -757,8 +1108,8 @@ mod tests {
#[test]
fn test_flags_byte() {
let header =
build_established_header(SessionIndex::new(1), 0, FLAG_KEY_EPOCH | FLAG_SP, 100);
assert_eq!(header[1], 0x05); // bits 0 and 2 set
build_established_header(SessionIndex::new(1), 0, FLAG_KEY_EPOCH | FLAG_CE, 100);
assert_eq!(header[1], 0x03); // bits 0 and 1 set
let parsed = EncryptedHeader::parse(&[
header[0], header[1], header[2], header[3], header[4], header[5], header[6], header[7],
@@ -768,16 +1119,15 @@ mod tests {
])
.unwrap();
assert_eq!(parsed.flags & FLAG_KEY_EPOCH, FLAG_KEY_EPOCH);
assert_eq!(parsed.flags & FLAG_CE, 0);
assert_eq!(parsed.flags & FLAG_SP, FLAG_SP);
assert_eq!(parsed.flags & FLAG_CE, FLAG_CE);
}
#[test]
fn test_payload_len_in_msg1() {
let packet = build_msg1(SessionIndex::new(1), &[0u8; HANDSHAKE_MSG1_SIZE]);
let prefix = CommonPrefix::parse(&packet).unwrap();
// payload_len = sender_idx(4) + noise_msg1(106) = 110
assert_eq!(prefix.payload_len, 110);
// payload_len = sender_idx(4) + noise_msg1(33) = 37
assert_eq!(prefix.payload_len, 37);
}
#[test]
@@ -788,7 +1138,68 @@ mod tests {
&[0u8; HANDSHAKE_MSG2_SIZE],
);
let prefix = CommonPrefix::parse(&packet).unwrap();
// payload_len = sender_idx(4) + receiver_idx(4) + noise_msg2(57) = 65
assert_eq!(prefix.payload_len, 65);
// payload_len = sender_idx(4) + receiver_idx(4) + noise_msg2(106) = 114
assert_eq!(prefix.payload_len, 114);
}
#[test]
fn test_msg3_header_parse() {
let sender_idx = SessionIndex::new(0xAABBCCDD);
let receiver_idx = SessionIndex::new(0x11223344);
let noise_msg3 = vec![0xdd; HANDSHAKE_MSG3_SIZE];
let packet = build_msg3(sender_idx, receiver_idx, &noise_msg3);
assert_eq!(packet.len(), MSG3_WIRE_SIZE);
assert_eq!(packet[0], 0x13); // ver=1, phase=3
let header = Msg3Header::parse(&packet).expect("should parse");
assert_eq!(header.sender_idx, sender_idx);
assert_eq!(header.receiver_idx, receiver_idx);
assert_eq!(header.noise_msg3_offset, 12);
assert_eq!(header.noise_msg3(&packet), &noise_msg3[..]);
}
#[test]
fn test_msg3_header_wrong_size() {
let packet = vec![0x13; MSG3_WIRE_SIZE - 1];
assert!(Msg3Header::parse(&packet).is_none());
// Larger than minimum is now accepted (variable-length negotiation payload)
let mut packet = vec![0x13; MSG3_WIRE_SIZE + 10];
packet[0] = 0x13; // ver=1, phase=3
packet[1] = 0x00;
let header = Msg3Header::parse(&packet);
assert!(header.is_some());
}
#[test]
fn test_msg3_header_wrong_phase() {
let mut packet = vec![0x00; MSG3_WIRE_SIZE];
packet[0] = 0x12; // ver=1, phase 2, not phase 3
assert!(Msg3Header::parse(&packet).is_none());
}
#[test]
fn test_msg3_header_nonzero_flags() {
let mut packet = build_msg3(
SessionIndex::new(1),
SessionIndex::new(2),
&[0u8; HANDSHAKE_MSG3_SIZE],
);
packet[1] = 0x01; // flags must be zero during handshake
assert!(Msg3Header::parse(&packet).is_none());
}
#[test]
fn test_payload_len_in_msg3() {
let packet = build_msg3(
SessionIndex::new(1),
SessionIndex::new(2),
&[0u8; HANDSHAKE_MSG3_SIZE],
);
let prefix = CommonPrefix::parse(&packet).unwrap();
// payload_len = sender_idx(4) + receiver_idx(4) + noise_msg3(73) = 81
assert_eq!(prefix.payload_len, 81);
}
}
+5 -20
View File
@@ -19,11 +19,12 @@ fn make_coords(ids: &[u8]) -> TreeCoordinate {
#[test]
fn test_session_message_type_roundtrip() {
let types = [
SessionMessageType::SessionSetup,
SessionMessageType::SessionAck,
SessionMessageType::DataPacket,
SessionMessageType::SenderReport,
SessionMessageType::ReceiverReport,
SessionMessageType::PathMtuNotification,
SessionMessageType::CoordsWarmup,
];
for ty in types {
@@ -209,30 +210,14 @@ fn test_fsp_flags_ignores_reserved_bits() {
#[test]
fn test_fsp_inner_flags_default() {
let flags = FspInnerFlags::new();
assert!(!flags.spin_bit);
assert_eq!(flags.to_byte(), 0x00);
}
#[test]
fn test_fsp_inner_flags_roundtrip() {
let flags = FspInnerFlags::from_byte(0x01);
assert!(flags.spin_bit);
assert_eq!(flags.to_byte(), 0x01);
let flags = FspInnerFlags::from_byte(0x00);
assert!(!flags.spin_bit);
assert_eq!(flags.to_byte(), 0x00);
}
#[test]
fn test_fsp_inner_flags_ignores_reserved() {
let flags = FspInnerFlags::from_byte(0xFE);
assert!(!flags.spin_bit);
assert_eq!(flags.to_byte(), 0x00);
fn test_fsp_inner_flags_from_byte() {
// All bits are reserved; from_byte always returns default
let flags = FspInnerFlags::from_byte(0xFF);
assert!(flags.spin_bit);
assert_eq!(flags.to_byte(), 0x01);
assert_eq!(flags.to_byte(), 0x00);
}
// ===== New SessionMessageType Values =====
+44 -62
View File
@@ -91,12 +91,6 @@ pub const FSP_FLAG_K: u8 = 0x02;
/// Unencrypted — payload is plaintext (error signals).
pub const FSP_FLAG_U: u8 = 0x04;
// Inner flag bit constants (byte 5 of decrypted inner header).
/// Spin bit for end-to-end RTT measurement (inside AEAD).
#[allow(dead_code)]
pub const FSP_INNER_FLAG_SP: u8 = 0x01;
// ============================================================================
// Common Prefix
// ============================================================================
@@ -369,6 +363,12 @@ pub fn parse_encrypted_coords(
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum SessionMessageType {
// Session establishment (0x00-0x0F)
/// Session setup with coordinates (warms router caches).
SessionSetup = 0x00,
/// Session acknowledgement.
SessionAck = 0x01,
// Data and metrics (0x10-0x1F) — encrypted, inner header msg_type
/// Port-multiplexed service payload: `[src_port:2 LE][dst_port:2 LE][service data...]`.
/// Port 256 = IPv6 shim (compressed header). Receiver dispatches by dst_port.
@@ -387,6 +387,8 @@ impl SessionMessageType {
/// Try to convert from a byte.
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x00 => Some(SessionMessageType::SessionSetup),
0x01 => Some(SessionMessageType::SessionAck),
0x10 => Some(SessionMessageType::DataPacket),
0x11 => Some(SessionMessageType::SenderReport),
0x12 => Some(SessionMessageType::ReceiverReport),
@@ -405,6 +407,8 @@ impl SessionMessageType {
impl fmt::Display for SessionMessageType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
SessionMessageType::SessionSetup => "SessionSetup",
SessionMessageType::SessionAck => "SessionAck",
SessionMessageType::DataPacket => "DataPacket",
SessionMessageType::SenderReport => "SenderReport",
SessionMessageType::ReceiverReport => "ReceiverReport",
@@ -527,32 +531,26 @@ impl FspFlags {
///
/// | Bit | Name | Description |
/// |-----|------|---------------------------------|
/// | 0 | SP | Spin bit for RTT measurement |
/// | 1-7 | | Reserved |
/// | 0-7 | | Reserved (all zero) |
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FspInnerFlags {
/// Spin bit for passive RTT measurement.
pub spin_bit: bool,
}
pub struct FspInnerFlags;
#[allow(clippy::wrong_self_convention)]
impl FspInnerFlags {
/// Create default inner flags (all clear).
#[cfg_attr(not(test), allow(dead_code))]
pub fn new() -> Self {
Self::default()
Self
}
/// Convert to a byte.
pub fn to_byte(&self) -> u8 {
if self.spin_bit { 0x01 } else { 0x00 }
0x00
}
/// Convert from a byte.
pub fn from_byte(byte: u8) -> Self {
Self {
spin_bit: byte & 0x01 != 0,
}
pub fn from_byte(_byte: u8) -> Self {
Self
}
}
@@ -564,29 +562,20 @@ impl FspInnerFlags {
///
/// Carried inside a SessionDatagram envelope which provides src_addr and
/// dest_addr. The SessionSetup payload contains coordinates, session flags,
/// and the Noise XK handshake message for session establishment.
///
/// SessionSetup, SessionAck, and SessionMsg3 are identified by the **phase**
/// field in the FSP common prefix (0x1, 0x2, 0x3), not by a message-type byte.
/// The `msg_type` field in the encrypted inner header applies only to
/// established-phase (0x0) messages.
/// and the Noise XX handshake message for session establishment.
///
/// ## Wire Format
///
/// Encoded with FSP common prefix: `[ver_phase:1][flags:1][payload_len:2 LE][body]`,
/// where `ver_phase = 0x01` (version 0, phase MSG1) and `flags = 0` for handshake.
///
/// **Body** (after 4-byte FSP prefix):
///
/// | Offset | Field | Size | Description |
/// |--------|-------------------|------------|-----------------------------------------------------|
/// | 0 | flags | 1 byte | Bit 0: REQUEST_ACK, Bit 1: BIDIRECTIONAL |
/// | 1 | src_coords_count | 2 bytes LE | Number of source coordinate entries |
/// | 3 | src_coords | 16 × n | Source's ancestry (NodeAddr, self → root) |
/// | ... | dest_coords_count | 2 bytes LE | Number of dest coordinate entries |
/// | ... | dest_coords | 16 × m | Destination's ancestry |
/// | ... | handshake_len | 2 bytes LE | Noise payload length |
/// | ... | handshake_payload | variable | Noise XK msg1 (33 bytes — ephemeral key only) |
/// | Offset | Field | Size | Description |
/// |--------|------------------|---------|-------------------------------------|
/// | 0 | msg_type | 1 byte | 0x00 |
/// | 1 | flags | 1 byte | Bit 0: REQUEST_ACK, Bit 1: BIDIR |
/// | 2 | src_coords_count | 2 bytes | u16 LE, number of src coord entries |
/// | 4 | src_coords | 16 × n | NodeAddr array (self → root) |
/// | ... | dest_coords_count| 2 bytes | u16 LE, number of dest coord entries|
/// | ... | dest_coords | 16 × m | NodeAddr array (dest → root) |
/// | ... | handshake_len | 2 bytes | u16 LE, Noise payload length |
/// | ... | handshake_payload| variable| Noise XX msg1 (33 bytes — ephemeral key) |
#[derive(Clone, Debug)]
pub struct SessionSetup {
/// Source coordinates (for return path caching).
@@ -595,7 +584,7 @@ pub struct SessionSetup {
pub dest_coords: TreeCoordinate,
/// Session options.
pub flags: SessionFlags,
/// Noise IK handshake message 1.
/// Noise XX handshake message 1.
pub handshake_payload: Vec<u8>,
}
@@ -680,27 +669,20 @@ impl SessionSetup {
/// dest_addr. The SessionAck payload contains both the acknowledger's and
/// initiator's coordinates for route cache warming (ensuring return-path
/// transit nodes can route independently of the forward path) and the Noise
/// XK handshake response.
///
/// SessionSetup, SessionAck, and SessionMsg3 are identified by the **phase**
/// field in the FSP common prefix (0x1, 0x2, 0x3), not by a message-type byte.
/// XX handshake response.
///
/// ## Wire Format
///
/// Encoded with FSP common prefix: `[ver_phase:1][flags:1][payload_len:2 LE][body]`,
/// where `ver_phase = 0x02` (version 0, phase MSG2) and `flags = 0` for handshake.
///
/// **Body** (after 4-byte FSP prefix):
///
/// | Offset | Field | Size | Description |
/// |--------|-------------------|------------|--------------------------------------------------------------|
/// | 0 | flags | 1 byte | Reserved |
/// | 1 | src_coords_count | 2 bytes LE | Number of acknowledger coordinate entries |
/// | 3 | src_coords | 16 × n | Acknowledger's ancestry (for cache warming) |
/// | ... | dest_coords_count | 2 bytes LE | Number of initiator coordinate entries |
/// | ... | dest_coords | 16 × m | Initiator's ancestry (for return-path cache warming) |
/// | ... | handshake_len | 2 bytes LE | Noise payload length |
/// | ... | handshake_payload | variable | Noise XK msg2 (57 bytes — ephemeral key + encrypted epoch) |
/// | Offset | Field | Size | Description |
/// |--------|------------------|---------|-------------------------------------|
/// | 0 | msg_type | 1 byte | 0x01 |
/// | 1 | flags | 1 byte | Reserved |
/// | 2 | src_coords_count | 2 bytes | u16 LE |
/// | 4 | src_coords | 16 × n | Acknowledger's coords (for caching) |
/// | ... | dest_coords_count| 2 bytes | u16 LE |
/// | ... | dest_coords | 16 × m | Initiator's coords (for return path)|
/// | ... | handshake_len | 2 bytes | u16 LE, Noise payload length |
/// | ... | handshake_payload| variable| Noise XX msg2 (106+ bytes — ephemeral + static + epoch + negotiation) |
#[derive(Clone, Debug)]
pub struct SessionAck {
/// Acknowledger's coordinates.
@@ -709,7 +691,7 @@ pub struct SessionAck {
pub dest_coords: TreeCoordinate,
/// Reserved flags byte (for forward compatibility).
pub flags: u8,
/// Noise IK handshake message 2.
/// Noise XX handshake message 2.
pub handshake_payload: Vec<u8>,
}
@@ -779,10 +761,10 @@ impl SessionAck {
}
// ============================================================================
// Session Msg3 (XK Handshake Message 3)
// Session Msg3 (XX Handshake Message 3)
// ============================================================================
/// XK handshake message 3 (initiator -> responder).
/// XX handshake message 3 (initiator -> responder).
///
/// Carries the initiator's encrypted static key and epoch. Sent by the
/// initiator after receiving msg2. The responder learns the initiator's
@@ -794,12 +776,12 @@ impl SessionAck {
/// |--------|------------------|---------|-------------------------------------|
/// | 0 | flags | 1 byte | Reserved |
/// | 1 | handshake_len | 2 bytes | u16 LE, Noise payload length |
/// | 3 | handshake_payload| variable| Noise XK msg3 (73 bytes typical) |
/// | 3 | handshake_payload| variable| Noise XX msg3 (73 bytes typical) |
#[derive(Clone, Debug)]
pub struct SessionMsg3 {
/// Reserved flags byte.
pub flags: u8,
/// Noise XK handshake message 3.
/// Noise XX handshake message 3.
pub handshake_payload: Vec<u8>,
}

Some files were not shown because too many files have changed in this diff Show More