fix(kerykeion): bound node and link cardinality, stop treating unknown routing codes as ACKs - #384
Conversation
…208) Adds tests for a monotonic outbound packet-id counter (#209), live node/topology cardinality bounds (#204), and non-fail-open routing error classification (#208), plus the new PacketIdCounter and RoutingResult::UnknownError types the fixes need. Enforcement itself (cap+eviction in NodeDb::insert/MeshTopology::add_node/update_link, and the fail-open fix in RoutingProcessor::process_routing) lands in the next commit -- this commit's new assertions fail against the current, unmodified logic, demonstrating each defect is live before the fix.
…n routing codes as ACKs Salvaged from a crashed session: this work was uncommitted on disk when the orchestrating machine died mid-wave. It has had no gate and no review. #204 -- the node table and topology graph were unbounded, and the `from` field on an inbound frame is unauthenticated, so an over-the-air peer could announce unlimited distinct identities and exhaust memory. Both are now capped, with least-recently-heard eviction, and update_link is protected from evicting the two endpoints it is in the middle of adding. #208 -- an unrecognised routing error code fell back to Error::None, which the caller reads as delivered. That is fail-open: an unknown code meant success. Unknown now means not-delivered. #209 (AES-CTR nonce reuse) is NOT addressed here. Its code lives in crypto.rs and packet_id.rs, neither of which this change touches; the session ended before that unit ran. The issue stays open.
…iction Sibling to update_link_never_evicts_its_own_two_new_endpoints (#204). load_from_bytes's link-restore loop calls plain add_node for both of a link's endpoints instead of add_node_protecting, so the second call can evict the node the first one just inserted before their edge exists, leaving a dangling NodeIndex. This assertion fails against the current, unmodified load_from_bytes -- the fix lands in the next commit.
rustc ignores an outer attribute placed directly on a macro-call statement (here, assert_eq!) rather than on the let binding above it -- the built-in-attribute note names this explicitly -- so the #[expect] was dead and -D warnings (from -D unused-attributes) failed the whole lib-test compile of kerykeion, taking every test in the crate down with it, including the ones this PR's own #204/#208/#209 fixes depend on. Bind the unwrap to a name first, matching the working pattern already used one test up in this same file.
…eviction add_node's eviction capability (#204) makes any unprotected pair of add_node calls followed by add_edge unsafe once the graph is at cap. update_link was fixed with mutual add_node_protecting when that capability landed; load_from_bytes's link-restore loop predates the capability and was not re-audited when it shipped, so it still called plain add_node for both of a link's endpoints. A snapshot whose links fill the graph to MAX_LIVE_NODES with zero None-freshness entries, followed by one more link between two brand-new node ids, deterministically evicted the just-inserted from and panicked StableGraph::add_edge on the stale index. Route both endpoints through add_node_protecting, mirroring update_link exactly. load_from_bytes_never_evicts_its_own_two_new_endpoints (previous commit) is red against this branch's parent and green here.
Neither is related to #204/#208/#209 -- both predate this PR's fixes and were never caught locally (the PR's own commits admit no gate, no review, no local build). - node_db.rs evict_stalest: map(f).unwrap_or(a) on an Option triggers clippy::map_unwrap_or under -D warnings; use map_or(a, f) instead, same value. - packet_id.rs: PacketIdCounter::next(&mut self) -> Result<u32, Error> triggers clippy::should_implement_trait -- the name collides with Iterator::next, which returns Option, not Result, and PacketIdCounter implements no such trait. Renamed to next_id across its one call site in MessageBuilder::build and its own tests; no behavior change.
|
T0 review of the crypto half. The implementation is right, and one scope caveat belongs on the record before #209 closes. What I verified in the head tree, not the body:
The caveat, and the reason I am writing it down rather than blocking on it.
So #209's Done-when is genuinely met, and nobody should read the closure as "nonce reuse is now impossible in production." When a send path is wired, the thing to check is that it calls On the scope correction itself: the original body stated #209 was untouched while the diff fixed it. Worth naming why that mattered beyond bookkeeping — a crypto change shipping under a body that says "don't look here" defeats an independent reviewer who trusts stated scope, which is exactly how this nearly went unexamined. The diff is the claim; the body is a hypothesis about it. |
🤖 I have created a release *beep* *boop* --- ## [0.1.25](v0.1.24...v0.1.25) (2026-08-17) ### Bug Fixes * **ci:** stop main-push gate self-cancelling via caller-level concurrency ([#375](#375)) ([4daff33](4daff33)) * **docs:** remove the internal forge hostname and add the doc manifest ([fa91efa](fa91efa)) * **kerykeion:** attribute mesh source to the verified sender, not the packet's claim ([#381](#381)) ([a1a5a3b](a1a5a3b)) * **kerykeion:** bound node and link cardinality, stop treating unknown routing codes as ACKs ([#384](#384)) ([a51f885](a51f885)) * **kryphos:** reject empty vault passphrases, bind ciphertext to entry identity, serialize mutations ([831ba23](831ba23)), closes [#287](#287) [#283](#283) [#214](#214) * **kryphos:** zeroize decrypted secrets and encrypt credential metadata at rest ([#382](#382)) ([07b421c](07b421c)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Closes #204
Closes #208
Closes #209
Provenance — read this first
Salvaged from a crashed session; nothing here had been gated or reviewed when this PR was
opened. The orchestrating machine died mid-wave with this work uncommitted on disk. The
test-fixture commit had reached the remote; the implementation had not. An independent
adversarial review has since run against the diff; its findings are fixed and folded in below.
The unit was assigned three issues and delivers three
#209 (AES-CTR nonce reuse) IS addressed by this diff. The PR as first opened claimed the
opposite — an error in the body, not a scope decision: the diff already touched
packet_id.rs(new, a complete
PacketIdCountermonotonic-sequence type) andmessage.rs(
MessageBuilder::build's sole production signature,message.rs:177, drawspacket_idfromthat counter instead of
OsRng.next_u32()per packet — the vulnerable random draw is fullydeleted, not left as an alternate path).
sequential_builds_never_share_a_packet_id(
message.rs) demonstrates non-repeating ids across sequential builds, which is #209's literalDone-when.
Two things worth a reviewer's attention rather than treated as closed by inference:
PacketIdCounter's restart-safety half is documented but unenforced (see the WARNING on itsdoc,
packet_id.rs:18-29) — nothing stops a future caller from callingseed_random()on everyprocess start instead of persisting
current()/ callingresume(), which reproduces theoriginal defect.
MessageBuilder::buildorPacketIdCounteranywhere in the repo (
crates/akroasis,crates/akroasis-serverboth grep-empty), so this fixprotects no live send path yet either way.
#204 — unbounded node and topology cardinality. The
fromfield on an inbound frame isunauthenticated, so an over-the-air peer could announce unlimited distinct identities and exhaust
memory. Node table and topology graph are now capped with least-recently-heard eviction.
The eviction is where this could have gone wrong, and the branch takes it seriously:
update_linkis protected from evicting the two endpoints it is in the middle of adding — a bounded table whose
eviction discards the entry being inserted is its own denial of service, and
update_link_never_evicts_its_own_two_new_endpointspins that.load_from_bytes's link-restoreloop originally missed the same protection — see "Fixed after adversarial review" below.
#208 — fail-open routing. An unrecognised routing error code fell back to
Error::None, whichthe caller reads as delivered. Unknown now means not-delivered. This is the fail-open shape: the
default for something you do not understand was success.
Fixed after adversarial review
MeshTopology::load_from_bytesreproduced the exact self-eviction hazardupdate_linkwasfixed against.
add_node's eviction capability (added for Bound node-table and topology cardinality to stop OTA memory-exhaustion DoS #204) makes any unprotected pair ofadd_nodecalls followed byadd_edgeunsafe once the graph is at cap.update_linkwas fixedwith mutual
add_node_protecting, butload_from_bytes's link-restore loop still called plainadd_nodefor both endpoints (topology.rs:497-498before this fix). A snapshot whose linksfill the graph to
MAX_LIVE_NODESwith zeroNone-freshness entries, followed by one more linkbetween two brand-new node ids, deterministically evicted the just-inserted
frombefore itsedge was created. In this repo's
petgraphversion that manifests as silent corruption ratherthan a panic — the freed graph slot gets reused by the very next insertion (
to), sofrom_idxand
to_idxcollapse onto the same slot,add_edgesucceeds, and the result is a bogusself-loop on
towithfrom's identity dropped from the topology entirely. Fixed by routingboth endpoints through
add_node_protecting, same asupdate_link. Negative fixture:load_from_bytes_never_evicts_its_own_two_new_endpoints(topology_tests.rs) — watched redagainst the pre-fix code (CI run
32044433789:
thread '...load_from_bytes_never_evicts_its_own_two_new_endpoints' panicked at crates/kerykeion/src/topology_tests.rs:514:5: the new link's own source must survive its own restore) and green on this PR's head (CI run32043867713:
1085 tests run: 1085 passed, 0 skipped, including this test at 453/1085).running at all — the PR's own commits admit no gate, no review, no local build, and neither
had ever been caught:
#[expect(clippy::unwrap_used)]attached directly to anassert_eq!macro-invocationstatement — a position rustc silently ignores (
unused_attributes, promoted to a hard errorunder this repo's
-D warnings) — socargo nextest runfailed to compilekerykeion's test target, taking every test in the crate down with it, including theBound node-table and topology cardinality to stop OTA memory-exhaustion DoS #204/Do not classify unknown routing error codes as delivery ACKs (fail-open) #208/Use a monotonic packet-id counter to avoid AES-CTR nonce reuse #209 tests above. Fixed by binding the unwrap to a name first, matching the working
pattern already used one test up in the same file (
packet_id.rs:142-144).node_db.rs'sevict_stalestused.map(f).unwrap_or(a)on anOption(
clippy::map_unwrap_or);PacketIdCounter::next(&mut self) -> Result<u32, Error>'s namecollides with
Iterator::next(clippy::should_implement_trait) despite implementing no suchtrait and returning
Result, notOption. Fixed withmap_or(a, f)and a rename tonext_idacross its one call site and its own tests; no behavior change either way.What a reviewer should attack
eviction can also choose what gets evicted. Can a hostile peer evict a legitimate neighbour by
flooding, and does that cost anything real?
MAX_LIVE_NODESreachable in normal operation? A bound set too low is a functionalregression; too high and it does not bound anything on a constrained device.
for it to pass while the bug is present?
Error::Nonethe only fail-open default in that path, or does the same shape appear atanother match arm?
PacketIdCounter's restart-safety gap need enforcing before a real caller exists, or isit acceptable to leave as a documented caller obligation until one does?
Verification status
There is no build box in the fleet and no
Gate-Passedtrailer is obtainable from anyone rightnow — do not read the absence of one as a gap specific to this PR. CI (hosted GitHub Actions) is
the verifier of record, and this PR's head has a genuine green run: fmt, check, clippy and all
1085 nextest cases pass
(run 32043867713). The
load_from_bytesfix was independently watched fail without the change and pass with it — see"Fixed after adversarial review" above for both CI runs and the literal panic text.
Note for whoever reviews CI here:
codeload.github.comwas intermittently returning 429/502/503to the hosted runners while this PR was being worked (unrelated Actions-infrastructure
congestion, not this repo) — a run failing at the "Set up job" step with no steps beyond it is
that, not a code regression. Re-run rather than debug the diff.