Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
069d450983 | ||
|
|
22e71b55a3 | ||
|
|
e8800c3a34 | ||
|
|
60fb3af359 | ||
|
|
746218b6bb | ||
|
|
540532de44 | ||
|
|
b6c1977e16 | ||
|
|
0f3f716142 | ||
|
|
679c9aaeb2 | ||
|
|
da57a57150 | ||
|
|
e30f3b7ddb | ||
|
|
026a9c6abf | ||
|
|
3d390fbafd | ||
|
|
30e1dcb989 | ||
|
|
b920200569 | ||
|
|
4a448cbe21 | ||
|
|
537f0b32c7 | ||
|
|
aa204cf729 | ||
|
|
911a000d85 | ||
|
|
2223763dab | ||
|
|
694d03375b | ||
|
|
bcfe9270bb | ||
|
|
e1543d201a | ||
|
|
28421c4b0e | ||
|
|
45406292aa | ||
|
|
e6d99d62c4 | ||
|
|
53ec14c2d5 | ||
|
|
c805a4492e | ||
|
|
719b587920 | ||
|
|
32b40221b0 | ||
|
|
308e2ce3b3 | ||
|
|
64f68fbc77 | ||
|
|
e9cb3f20a5 | ||
|
|
630a04c432 | ||
|
|
5851c7f36f | ||
|
|
d8ded7000f | ||
|
|
b56ec14d14 | ||
|
|
bd3b639570 | ||
|
|
feb1e02e9c | ||
|
|
deb5ded8eb | ||
|
|
d0a0824768 | ||
|
|
7089a2949b | ||
|
|
0dea111fab | ||
|
|
564b31a914 | ||
|
|
f1879b8fe9 | ||
|
|
afdae094dc | ||
|
|
60b1d27c0d | ||
|
|
6e54161022 | ||
|
|
cb96a601c2 | ||
|
|
26bd1ada5b | ||
|
|
b0cef7a221 | ||
|
|
a3c53a3b3c | ||
|
|
1e6b4cb918 | ||
|
|
df52ce459c | ||
|
|
91ba660213 | ||
|
|
7ea5718270 | ||
|
|
f7fa257349 | ||
|
|
7e5619ad32 | ||
|
|
b1adbe3af7 | ||
|
|
f65cd07048 | ||
|
|
4fa565e528 | ||
|
|
ba3c552dce | ||
|
|
d6785f2970 | ||
|
|
3f9de1adcd | ||
|
|
4779a3970d | ||
|
|
5a8da01f24 | ||
|
|
252b77683e | ||
|
|
2fad6be5e6 | ||
|
|
efbfb32e07 | ||
|
|
8abcd6ff8c | ||
|
|
4620d9f82e | ||
|
|
7e4358782e | ||
|
|
21b93f4116 | ||
|
|
a6f7fc2d2f | ||
|
|
22a5f1f807 | ||
|
|
1e934e7603 | ||
|
|
40d1a5bca7 | ||
|
|
cde1af2d1b | ||
|
|
09d0d41639 | ||
|
|
c15e63e101 | ||
|
|
5498b2a6a3 | ||
|
|
42de582ef0 | ||
|
|
0eaae58dc8 | ||
|
|
49677cc426 | ||
|
|
d58bd083fa | ||
|
|
c08888afe6 | ||
|
|
fa3f88b433 | ||
|
|
b7c3d8935c | ||
|
|
79f79cf6ac | ||
|
|
de72bf76ea | ||
|
|
a7de830b8d | ||
|
|
6f8590cd92 | ||
|
|
033957f23d | ||
|
|
3f214c8f63 | ||
|
|
ccd47bd742 | ||
|
|
775c7ae4c0 | ||
|
|
14edddc14a | ||
|
|
81baeebf22 | ||
|
|
b9eb725e5a | ||
|
|
f1ff410fd2 | ||
|
|
6954ebbf34 | ||
|
|
d2331ecb78 | ||
|
|
1ec7a4c680 | ||
|
|
0c948bd707 | ||
|
|
c5d48e5ef4 | ||
|
|
c0b2822d56 | ||
|
|
fff1d6285a | ||
|
|
45f65e7ca1 | ||
|
|
6a028064ef | ||
|
|
584edfac20 | ||
|
|
3b46bbe17d | ||
|
|
78eadbcd65 | ||
|
|
74ab6d58ba | ||
|
|
bfc1cd780f | ||
|
|
ceb60c9976 | ||
|
|
f7a0f906fa | ||
|
|
56bdea153b | ||
|
|
330c6aa9af | ||
|
|
c8c2ae96eb | ||
|
|
5bc42b4deb | ||
|
|
e305e1c4e4 | ||
|
|
d59e4d18f9 | ||
|
|
5b767827bb | ||
|
|
82ba8e656f | ||
|
|
db7ad32db8 | ||
|
|
ec123107ef | ||
|
|
86ec7a37e8 | ||
|
|
784e22ab1f | ||
|
|
0e5f7c90f4 | ||
|
|
d97c07fd8d | ||
|
|
65d44ab59a | ||
|
|
0aada5c156 | ||
|
|
2ea215f480 | ||
|
|
63b1821542 | ||
|
|
cefdc549f7 | ||
|
|
906e01b388 | ||
|
|
6aded6e365 | ||
|
|
8985ea66db | ||
|
|
878c40c91f | ||
|
|
aed64f6d80 | ||
|
|
9ce4f2e908 | ||
|
|
a3b9954797 | ||
|
|
84c690cecd | ||
|
|
95b475d59b | ||
|
|
1c28c2a736 | ||
|
|
66db3e14a7 | ||
|
|
2bc3a505f6 | ||
|
|
97f496afc4 | ||
|
|
40c06b28e5 | ||
|
|
ea3e7f8c21 | ||
|
|
3bf49de01f | ||
|
|
e181df0ac4 | ||
|
|
88e48fdc68 | ||
|
|
3b27a1604d | ||
|
|
1914b20485 | ||
|
|
bbd4de9e76 | ||
|
|
fccd760ad7 | ||
|
|
a965b1ec67 | ||
|
|
b643eb3a89 | ||
|
|
e1ae261eb2 | ||
|
|
939e2cbd4f | ||
|
|
726461c193 | ||
|
|
cc417a7bef | ||
|
|
8e72326ec2 | ||
|
|
a6e4481c1f | ||
|
|
0a84a25db1 | ||
|
|
05124367db | ||
|
|
df342f268a | ||
|
|
61ac4c6a32 | ||
|
|
49735980b8 | ||
|
|
09df743485 | ||
|
|
60c42daa82 | ||
|
|
d6729086de | ||
|
|
d1b2f31a3b | ||
|
|
c80b3eb0e3 | ||
|
|
6bb17be8b8 | ||
|
|
a014296639 | ||
|
|
74b08b9b6f | ||
|
|
418c9d61e0 | ||
|
|
358a06be06 | ||
|
|
183ad148de | ||
|
|
195b3bad01 | ||
|
|
212c45bd37 | ||
|
|
b0e9627a77 | ||
|
|
7865557f8a | ||
|
|
7807516b8d | ||
|
|
e5071d74fb | ||
|
|
831ce5f741 | ||
|
|
dd65951244 | ||
|
|
c22b50c147 | ||
|
|
19b921ec5d | ||
|
|
45a8a4cd9b | ||
|
|
823b830289 | ||
|
|
933e9ee6ee | ||
|
|
fb834dc2ca | ||
|
|
acb750c0d2 | ||
|
|
5a80582b0b | ||
|
|
60a4353661 | ||
|
|
3cbb667655 | ||
|
|
4457587e5f | ||
|
|
1c8b8d55e8 | ||
|
|
b02eaf2c5b | ||
|
|
d457b6d5f6 | ||
|
|
e7fc4f7d10 | ||
|
|
5317735a91 | ||
|
|
9c35c7e55d | ||
|
|
15753cdc20 | ||
|
|
8a6477b7ff | ||
|
|
5a5dbe36da | ||
|
|
64c38b2e26 | ||
|
|
3edca4a84f | ||
|
|
bcb87165fa | ||
|
|
e5e6054229 | ||
|
|
14d2a4f2df | ||
|
|
fd9a8d132d | ||
|
|
dab0ed4854 | ||
|
|
2382c1438f | ||
|
|
942cc83878 | ||
|
|
c2133a0f29 | ||
|
|
76a7147b17 | ||
|
|
9e93b0b224 | ||
|
|
9f539ce3c5 | ||
|
|
5d898490ac | ||
|
|
fe6f9aaa78 | ||
|
|
44667d9cc2 | ||
|
|
10122d7d87 | ||
|
|
8b5f1e349f | ||
|
|
34f840a77b | ||
|
|
59b7ea765b | ||
|
|
eb8479eb20 | ||
|
|
8162d3c237 | ||
|
|
e06015dd43 | ||
|
|
4aded9a238 | ||
|
|
f4278f1dd7 | ||
|
|
8357200b0e | ||
|
|
ae0f791dbc | ||
|
|
179689d6f2 | ||
|
|
9ccaae5044 |
@@ -3,3 +3,6 @@
|
||||
|
||||
# rustfmt master-only code
|
||||
e9da598f8ab13de5dea3a1496531d675af6a0b94
|
||||
|
||||
# rustfmt noise-xx-only code
|
||||
fef3d011ab290743a13ae3f9b287ad4ebaa1713b
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1074,7 +1074,7 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
||||
|
||||
[[package]]
|
||||
name = "fips"
|
||||
version = "0.5.0-dev"
|
||||
version = "0.6.0-dev"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bech32",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||

|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](#status--roadmap)
|
||||
[](#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,
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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** |
|
||||
|
||||
@@ -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** |
|
||||
|
||||
@@ -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** |
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) — 19-byte header + RLE payload</text>
|
||||
|
||||
<!-- Row 0 (0–3): msg_type(1) + sequence starts -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
<!-- Row 0 (0-1): msg_type + flags -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–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 — bit 0: delta (XOR diff)</text>
|
||||
|
||||
<!-- Row 1 (4–8): sequence continued -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–8</text>
|
||||
<!-- Row 1 (2-9): sequence -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">2–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 — per-peer monotonic counter</text>
|
||||
|
||||
<!-- Row 2 (9–10): hash_count + size_class -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">9–10</text>
|
||||
<!-- Row 2 (10-17): base_seq -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">10–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 — 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 — filter size = 512 « 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 << size_class bytes)</text>
|
||||
<!-- Row 4 (19-): compressed_payload -->
|
||||
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">19–</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 — 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 |
@@ -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 — 41 bytes — pattern: → 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 — 144 bytes — pattern: ← 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 — 111 bytes — pattern: → 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 — 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 |
@@ -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">0–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–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>
|
||||
|
||||
<!-- 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">8–40</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">41–89</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">90–113</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 — 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">0–3</text>
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–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">4–7</text>
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–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">8–11</text>
|
||||
<!-- Row 2: ephemeral_pubkey (bytes 8-40, 33 bytes) -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">8–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">12–44</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">45–68</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 · payload_len = 37 · pattern: → e</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -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 — phase 0x2 (118–144 bytes)</text>
|
||||
|
||||
<!-- Row 0: Common prefix (bytes 0-3) -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–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–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–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–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–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() — extends Noise hash chain</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="425" fill="#777" font-size="10" text-anchor="middle">base: 118 bytes · with FMP negotiation: 144 bytes</text>
|
||||
<text x="310" y="442" fill="#777" font-size="10" text-anchor="middle">pattern: ← e, ee, s, es</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -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 — phase 0x3 (85–111 bytes)</text>
|
||||
|
||||
<!-- Row 0: Common prefix (bytes 0-3) -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–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–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–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–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() — extends Noise hash chain</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="378" fill="#777" font-size="10" text-anchor="middle">base: 85 bytes · with FMP negotiation: 111 bytes</text>
|
||||
<text x="310" y="395" fill="#777" font-size="10" text-anchor="middle">pattern: → s, se</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -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) — 54 bytes</text>
|
||||
|
||||
<!-- Row 0 (0–3): msg_type + reserved -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</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–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 (4–11): highest_counter -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–11</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–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 (12–19): cumulative_packets_recv -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">12–19</text>
|
||||
<!-- Row 2 (8-9): dwell_time -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">8–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 (20–27): cumulative_bytes_recv -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">20–27</text>
|
||||
<!-- Row 3 (10-17): highest_counter -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">10–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 (28–33): timestamp_echo + dwell_time -->
|
||||
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">28–33</text>
|
||||
<!-- Row 4 (18-25): cumulative_packets_recv -->
|
||||
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">18–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–33</text>
|
||||
|
||||
<!-- Row 5 (34–39): max_burst_loss + mean_burst_loss + reserved -->
|
||||
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">34–39</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–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 (40–47): jitter + ecn_ce_count -->
|
||||
<text x="50" y="374" fill="#666" font-size="10" text-anchor="end">40–47</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 (µ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 (48–55): owd_trend + burst_loss_count -->
|
||||
<text x="50" y="426" fill="#666" font-size="10" text-anchor="end">48–55</text>
|
||||
<!-- Row 7 (42-49): owd_trend + burst_loss_count -->
|
||||
<text x="50" y="426" fill="#666" font-size="10" text-anchor="end">42–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 (µ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 (56–63): cumulative_reorder_count + interval_packets_recv -->
|
||||
<text x="50" y="478" fill="#666" font-size="10" text-anchor="end">56–63</text>
|
||||
<!-- Row 8 (50-53): cumulative_reorder_count -->
|
||||
<text x="50" y="478" fill="#666" font-size="10" text-anchor="end">50–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 (64–67): interval_bytes_recv -->
|
||||
<text x="50" y="530" fill="#666" font-size="10" text-anchor="end">64–67</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 · format_version = 0 · total_length = 50</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.3 KiB |
@@ -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) — 20 bytes</text>
|
||||
|
||||
<!-- Row 0 (0–3): msg_type + reserved -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</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–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 (4–11): interval_start_counter -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–11</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–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 (12–19): interval_end_counter -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">12–19</text>
|
||||
<!-- Row 2 (8-11): interval_bytes_sent -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">8–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 (20–27): interval_start_timestamp + interval_end_timestamp -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">20–27</text>
|
||||
<!-- Row 3 (12-19): cumulative_packets_sent -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">12–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 (28–31): interval_bytes_sent -->
|
||||
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">28–31</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 (32–39): cumulative_packets_sent -->
|
||||
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">32–39</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 (40–47): cumulative_bytes_sent -->
|
||||
<text x="50" y="374" fill="#666" font-size="10" text-anchor="end">40–47</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 · format_version = 0 · total_length = 16</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -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) — Noise XX msg2</text>
|
||||
|
||||
<!-- Row 0 (0–3): FSP prefix -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</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 — 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 |
@@ -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) — Noise XX msg3</text>
|
||||
|
||||
<!-- Row 0 (0–3): FSP prefix -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</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 — 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 |
@@ -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) — Noise XX msg1</text>
|
||||
|
||||
<!-- Row 0 (0–3): FSP prefix -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</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 — 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 |
@@ -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.*`,
|
||||
|
||||
@@ -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 | ~232–402 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.
|
||||
|
||||

|
||||

|
||||
|
||||
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)
|
||||
|
||||

|
||||
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).
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
| 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.
|
||||
|
||||

|
||||
|
||||
| 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.
|
||||
|
||||

|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"[..]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||