Cover the next-only work in the changelog, and drop a duplicated entry

The XX line had accumulated work with no entry: the explicit rekey
declaration in msg3, which is a wire change and belongs under Breaking;
node.rekey.enabled narrowing to mean initiate-only, which is the one an
operator can be bitten by, since a pair configured true on one end and
false on the other carried no traffic until the link-dead timer; the leaf
node that self-elected as root and partitioned the mesh; the session index
and link leaks at the reject arms; and the peer static verification, which
closes an on-path identity substitution on both handshake paths.

Also removes the msg1 metering entry that merged up from maint. The XX
line has its own version of it a hundred lines above, keyed on promotion
state rather than on an address-map entry, and the merged copy described
behaviour this branch does not have.
This commit is contained in:
Johnathan Corgan
2026-08-11 07:23:51 +00:00
parent 55c71f1311
commit 70ac24a167
+71 -10
View File
@@ -28,6 +28,19 @@ with v0.4.x or earlier peers.
in future releases.
- FMP msg1 reduced from 106 to 33 bytes (ephemeral key only, no
encrypted static key or DH products).
- A rekey is now declared explicitly in msg3, by a negotiation TLV naming
the session it replaces, instead of being inferred from session age. A
rekey and a fresh dial both arrive as a new msg1 on a new link, and the
fact that separates them is internal to the initiator, so nothing on the
wire carried it: a message-count-triggered rekey fires on a young
session, so a real rekey landed below the age floor, was resolved as a
cross-connection, and left the two ends of the link on different session
indices. The marker uses the index the receiver allocated, so the
receiver can match it against its own, and the shell resolves it to
none, matching or mismatched. A mismatched marker resends msg2 rather
than rejecting, because the initiator has already installed its pending
session and will cut over on its own timer while the reject path sends
nothing back. The age floor and the session-age snapshot are gone.
#### FMP Node Profiles
@@ -216,6 +229,22 @@ with v0.4.x or earlier peers.
### Changed
- `node.rekey.enabled` now means "initiate rekeys" and nothing else. The
responder half of the establish decision was also gated on it, and once the
rekey is declared in the msg3 negotiation payload that flag was the only
thing that could divert a msg3 whose marker matched the session we hold — it
diverted it to a msg2 resend while the initiator had already installed its
pending session and would cut over on its own timer regardless. A pair
configured with the flag true on one end and false on the other therefore
parted company at the initiator's cutover and carried no traffic in either
direction until the link-dead timer. Removing that gate exposed a second
reading of the same flag: the rekey poll returned early when rekey was
disabled, and its drain-expiry arm is the only thing that releases a demoted
session and its index, so a node that does not initiate but now accepts a
rekey would have pinned the previous session and its allocator index
forever. The trigger and the polled cutover stay gated on the flag; the
drain no longer is.
- Node health is determined at start completion instead of unconditionally
reaching a single running state. **Zero transports up is now fatal**: the
node tears down cleanly and the daemon exits with an error, where it
@@ -349,16 +378,6 @@ with v0.4.x or earlier peers.
the transport id several peers sharing one adopted transport are
indistinguishable from several separate adopted transports.
- Inbound msg1 is classified before it is rate limited, and rekey or restart
msg1 arriving on an established link now draws on its own token bucket
instead of competing with stranger admission for a single shared one. On a
node with many peers the shared bucket refused a large share of ordinary
rekey traffic: a field node at roughly 245 peers refused 8753 msg1 in 25
minutes, and 159 of the 201 distinct sources were peers it already held
sessions with. Nodes upgrade with no config change. The `Msg1 rate limited`
log line now reports which limb refused, the pending count or the token
bucket, which it previously did not distinguish.
- `SessionDatagram::decrement_ttl` and `SessionDatagram::can_forward` now match
the forwarder's IP hop-limit semantics: `decrement_ttl` decrements first and
reports false when the result is zero, and `can_forward` is true only at a
@@ -379,6 +398,31 @@ with v0.4.x or earlier peers.
### Fixed
- A leaf-profile node no longer self-elects as tree root. A leaf holding the
smallest node address elected itself, but its peers refuse a non-full node as
a parent, so it formed an isolated second root and partitioned the mesh: a
multi-hop session from the leaf to a non-adjacent full node then failed,
because the far node could not route a handshake reply back into the leaf's
separate coordinate tree. A leaf now attaches under its full upstream and
holds that subtree's coordinate for its own routing; it never announces that
coordinate, reaching peers via the ones carried on its session frames, and
the upstream already advertises it in the upstream's bloom filter.
`Node::with_identity` also derives the node profile, leaf-only flag and bloom
state from the config as `Node::new` does, where it previously hardcoded the
full profile and silently dropped a configured leaf or non-routing profile.
- Every XX handshake reject arm now releases the session index and the link it
holds. The msg2 self-connect drop and the msg3 reject arms disposed of a leg
without returning what that leg had allocated, so each rejected handshake
leaked an index and a link entry. One arm is deliberately not fixed like the
others: its index comes from the receiver field of the incoming header, which
the peer supplies, so freeing it unconditionally would let a hostile peer
release an index belonging to an unrelated live session, trading a memory
leak for a remote session teardown. That arm frees only after a
transport-blind predicate establishes the index is not claimed elsewhere.
The outbound ACL-reject arm also regains the reschedule call its dial-gate
sibling makes, so a configured peer no longer drops off the dial schedule.
- Nostr NAT traversal signals are now sent only to relays the client pool
actually holds. A signal is addressed to the merge of the peer's NIP-17 inbox
relays, the relays its advert nominates for signaling, and our own DM relays,
@@ -536,6 +580,23 @@ with v0.4.x or earlier peers.
### Security
- The peer static key is verified on both FMP handshake paths, not only at
rekey. Under Noise XX the static is learned during the handshake rather than
pinned in advance, and neither path that learns one checked it against what
we already knew, so an attacker able to observe and inject on path could
substitute their own identity on a fresh dial and on an established link. On
a fresh dial we recorded who we meant to reach and then overwrote it with
whoever answered without comparing the two, so an attacker who raced the real
peer to msg2 became the peer — promotion, the ACL check and the peer registry
all ran on the answering identity — and the intended node was never reached.
On an established link a rekey msg2 was matched to its peer only by the
session index we had put in the cleartext msg1 header, so anyone who saw that
header could answer with their own static and take the link over at cutover.
Both are now compared before anything is committed, with the dial-time
expectation held in a field that has no setter so the handshake cannot
overwrite it. Anonymous dials still promote whoever answers, which is what
shared-media discovery means.
- The FSP session address is now bound to the peer key the Noise handshake
authenticated, on both the initial and the rekey path. The responder recorded
a session under the source address carried in the datagram without ever