Security audit
Date: 2026-06-16 Scope: security-sensitive surfaces of the chIRpChat C++ tree, with extra attention to recently-added features (LANEGRANT client promotion, signed Admin, identity revocation/rotation, peer-link auth). This is a read-only audit: no source, test, or existing doc was modified. The only artifact is this report.
Reviewed against the design intent in AGENTS.md, docs/IDENTITY.md,
docs/PROTOCOL.md, and docs/ROUTING.md. Build was green and all 430 host
tests passed at audit time (cmake --build build && ./build/lrc_tests).
Severities: High (exploitable, meaningful harm), Medium (exploitable but bounded harm or needs a precondition), Low (limited harm / hard precondition), Info (by-design, documented, or hardening note). Each entry distinguishes confirmed issues from by-design, documented trades and cites the design text that justifies them.
Executive summary
Section titled “Executive summary”The cryptographic core is in good shape. The strict, length-exact LPP decoder
(core/src/packet.cpp::decode) is the right shape and I found no over-read or
length-math overflow on the decode path. Registration, peer-link backbone auth,
signed Admin, and identity revocation/rotation are all carefully gated, match
their documented designs, and resist the obvious forge/replay/downgrade/
resurrection attacks. The tag/signature canonical-image exclusions are each
justifiable.
The one new surface that warrants attention is LANEGRANT (0x17):
- A non-router or a different client cannot forge a grant — the session key (SK) is shared only between the client and its home router, so a valid Tag8 necessarily comes from the router. Authentication is sufficient against forgery. (Confirmed.)
- A LANEGRANT can be replayed: there is no nonce, sequence, or MSGID dedup on the grant in the firmware path. A passive eavesdropper who captures one grant frame can re-inject it later to re-trigger the client’s uplink retune, with a fresh TTL each time. This is Finding 1 (Medium).
- The blast radius is genuinely bounded: a grant only ever moves the client’s
uplink lane, the freq slot stays in-band by construction, TX power/region
are never touched, the retune is additionally gated behind a local
lane retune onRF opt-in, and CAD/backoff still gate the actual transmit. So the realistic worst case is uplink-lane churn / a mild availability nudge, not an RF-safety or spoofing break.
Two smaller items: the LANEGRANT RX path never checks that p.src is the
client’s registered router (Finding 2, Low — currently redundant with the SK
check, but a latent footgun), and BYE is accepted without verifying its Tag8
(Finding 3, Low/Info — presence spoof, mitigated by router attestation
elsewhere). Everything else is Info / by-design.
Findings
Section titled “Findings”Finding 1 — LANEGRANT has no replay protection (no nonce/seq, not deduped)
Section titled “Finding 1 — LANEGRANT has no replay protection (no nonce/seq, not deduped)”- Severity: Medium
- Location:
firmware/src/main.cpp:1479-1498(LaneGrant RX case);core/include/lrc/lanes.h:64-73(LaneGrantInfo— payload isrtr/lane_id/preset/freq_slot/ttl_sonly, no nonce/seq);core/src/lanes.cpp:100-119(encode/parse). Comparedocs/PROTOCOL.md§“Lane promotion” and §“Lane promotion (LANEGRANT)”. - Description: The grant is authenticated with
verify_tag(frame, len, session_key())(main.cpp:1487) but carries no anti-replay material. The Tag8 covers the canonical image, which for a group-B unicast excludes only TTL/hops and the mutable PATH/VIA_TCP bits — it does not bind a nonce, a monotonic counter, or the MSGID (MSGID is excluded from the tag by design,docs/IDENTITY.md§7). Critically, the LaneGrant case is handled entirely inside the firmware switch andreturns beforeg_node->on_radio_frame()(main.cpp:1497vs thedefault:branch at1512-1513), so the Node’s(SRC, MSGID)dedup ring never sees LANEGRANT frames. A byte-identical replay therefore re-passesverify_tagand is re-applied, each time arming a fresh TTL:g_granted_expiry_ms = millis() + ttl_s*1000(main.cpp:1493). - Attack scenario: A passive on-air eavesdropper captures one valid
router→client LANEGRANT (it is plaintext; chIRpChat signs/tags but never encrypts —
docs/IDENTITY.md§intro). Whenever the router would prefer the client back on the anchor (e.g. the anchor is no longer busy, or the router rebooted and re-keyed), the attacker re-injects the captured frame to keep the client pinned to the old promoted lane past the router’s intent, holding it off the dial tone for up tottl_sand re-arming it indefinitely. No SK knowledge is needed to replay a frame the attacker already saw valid. - Why the blast radius is bounded (don’t over-rate this): the grant is
advisory uplink policy only. (a) It moves only the uplink lane, never
TX power or region —
lane_freq_khzderives the frequency assiphash(...) % nslotsso anyfreq_slotvalue stays in-band (core/include/lrc/presets.h:170-189). (b) Applying it to the radio is additionally gated behind the locallane retune onopt-in:scheduled_runtime_decisionreturns the anchor decision when!g_cfg.lane_retune_enabled(firmware/src/main.cpp:1264-1265), so on a default node a replayed grant has no RF effect at all. (c) CAD/backoff still gate transmit (docs/PROTOCOL.md§“Lane promotion”). The exposure is uplink churn / mild self-DoS on nodes that have opted into retune, not an RF-safety or authenticity break. - Recommendation: Give the grant freshness. Cheapest option that needs no
wire change: route LANEGRANT through the same
(SRC, MSGID)dedup the Node already applies to other unicast (i.e. don’treturnbefore dedup, or add a small dedup check in the firmware case) so a byte-identical replay is dropped. Stronger option (small wire change, follow thelrc-wire-changechecklist): bind the most recently confirmed routerboot_id, or echo a short client nonce, into the grant payload so a grant minted for one router incarnation cannot be replayed against a later one. Even with dedup, note that a replay after the dedup ring has rotated past the entry would still land; a monotonic per-session grant counter compared likepeer_rx_seqis the robust fix if this lane is ever promoted to a security boundary. - Resolution (2026-06-16): The portable core
Nodepath was already safe —Node::handle_frameruns the(SRC,MSGID)dedup gate before dispatching, so a replayed grant is dropped beforehandle_lane_grant. The firmware case, which accepts grants before the Node sees them, now keeps a small ring of recently accepted authentic grant keys and drops repeats (g_grant_dedupinfirmware/src/main.cpp). The stronger boot_id/nonce binding remains a future option if this lane ever becomes a security boundary.
Finding 2 — LANEGRANT RX does not verify p.src is the registered router
Section titled “Finding 2 — LANEGRANT RX does not verify p.src is the registered router”- Severity: Low (defense-in-depth / latent footgun; not currently exploitable)
- Location:
firmware/src/main.cpp:1479-1498. The handler checks role, registration,p.dst == self(1486) and the SK tag (1487), but never comparesp.srcagainstg_regclient->router()/g_regclient->router_pub()’s UID. - Description: Today this is safe because authentication is “knows my
SK”, and SK is shared only between the client and exactly one router
(
derive_session_keyover the X25519(IK,RK) DH plus the HELLO/WELCOME nonces,core/src/ident.cpp:91/core/src/reg.cpp:113,212). A third party cannot produce a valid tag, sop.srcspoofing buys nothing — the tag check is the real gate. The risk is latent: if a client ever holds SKs for multiple routers (the doc explicitly recommends registering with 1–3 routers,docs/IDENTITY.md§2) andsession_key()is ever generalized to select among them, a grant tagged with router A’s SK but claimingsrc = router Bcould be mis-attributed. The current single-sk_RegClient(core/include/lrc/reg.h:74) happens to avoid that, so this is a guardrail against future refactors, not a live bug. - Recommendation: Add an explicit
p.src == <registered router UID>check alongside the existingp.dst/tag checks so the grant’s authenticated origin is also its claimed origin. Cheap, and it documents the invariant for the next agent. - Resolution (2026-06-16): Done.
Node::set_session_keynow records the issuing router UID andNode::handle_lane_grantrequiresp.srcto equal it; the firmware RX case checksp.src == uid_of(g_regclient->router_pub()). The SK tag check stays — this is the defense-in-depth guardrail.
Finding 3 — BYE is accepted without verifying its Tag8
Section titled “Finding 3 — BYE is accepted without verifying its Tag8”- Severity: Low (presence spoof; mitigated by attestation elsewhere)
- Location:
core/src/node.cpp:5533-5538(Bye case →note_tombstone).docs/PROTOCOL.md§Packet types describes BYE as carrying “Tag8when a session key exists”, andcore/src/node.cpp:4105shows the local sender emitting BYE, but the receive path never callsverify_tag/verify_client_framebefore tombstoning. - Description: Any node can originate an unauthenticated BYE with
src = victim UID; the receiver renders an IRCQUITand suppresses the victim’s directory snapshots at the current liveness generation (note_tombstone). Because the access network re-advertises live attachments with a higherlive_revin RTRSYNC and presence is ultimately router-attested (Tier-1,docs/IDENTITY.md§4.1; tombstones lose to a newer reachable attachment perdocs/PROTOCOL.md§RTRSYNC), the spoof self-heals on the next directory refresh and is a transient nuisance, not a durable takedown. Still, it is an unauthenticated control-plane action keyed by an attacker-chosen UID. - Recommendation: When the receiving router holds a session key for the
BYE’s
src(it does for its own registered clients), require and verify the Tag8 before tombstoning that attachment; treat untagged BYEs for locally registered UIDs as advisory only. Document the chosen rule indocs/PROTOCOL.md§BYE so the intended authentication is explicit. - Resolution (2026-06-16): Done. The Node’s BYE handler now verifies the
Tag8 (
Registrar::verify_client_frame) whenp.srcis a client this router registered, and drops a forged/untagged eviction (rx_badtag). A BYE for a non-locally-registered UID carries no SK to check and is a federation announcement from the owning router — acted on as before (the documented cross-router trust boundary). Rule documented in PROTOCOL.md §BYE and ROUTING.md.
Finding 4 — Tag/MAC comparisons are not constant-time
Section titled “Finding 4 — Tag/MAC comparisons are not constant-time”- Severity: Info (hardening; low practical risk on this medium)
- Location:
core/src/packet.cpp:231(memcmpof Tag8 inverify_tag);core/include/lrc/peer_link.h:106(std::memcmpof the peer MAC). Ed25519 verification uses the vendored constant-time primitive, so this note is only about the SipHash tag/MAC paths. - Description: Tag8 and the 8-byte peer MAC are validated with ordinary
memcmp, which short-circuits on the first differing byte. A remote attacker who could measure verification timing precisely could in principle recover a forgery byte-by-byte. Over a high-jitter LoRa link, and given that a successful Tag8 forgery still only buys what the relevant tier already concedes (Tier-2 is explicitly a tripwire, not a signature —docs/IDENTITY.md§4.2), this is a theoretical hardening item, not a live break. The TCP peer MAC path is more timing-stable and is the one I’d prioritize. - Recommendation: Use a constant-time comparison for the 8-byte tag/MAC checks (a fixed-length XOR-accumulate compare). Small, portable, and removes the question entirely.
- Resolution (2026-06-16): Done.
verify_tag(the single chokepoint for all SipHash tag verification — chat tags, LANEGRANT, ROTATE, registration) andcheck_peer_link_macnow use a constant-timect_equal(siphash.h); accept/reject behaviour is byte-for-byte unchanged.
Finding 5 — Canonical image: excluded fields are each justified (by design)
Section titled “Finding 5 — Canonical image: excluded fields are each justified (by design)”- Severity: Info (by-design, documented — verification result)
- Location:
core/src/packet.cpp:189-220(canonical_image), governed bydocs/IDENTITY.md§7 anddocs/PROTOCOL.md§“Tag8 trailer”. - What is excluded and why it is safe to exclude:
TH(TTL/hops) zeroed (packet.cpp:215): must change in flight; a relay mutating TTL/hops cannot affect the tag, and excessive TTL is bounded by the 4-bit field (≤15 hops,docs/PROTOCOL.md§Common header) and the drop-at-zero rule. No authenticity harm.- Mutable flag bits
PATH | VIA_TCPmasked (packet.cpp:214,flags::kMutableMask,core/include/lrc/packet.h:50): these legitimately change as the frame is relayed / crosses TCP. The security-relevant flags (SIGNED_FULL,TAGGED,ACK_REQ,FRAG,PRIO,EXT_ADDR) remain covered, so an attacker cannot strip theTAGGED/SIGNED_FULLbit or flipPRIOwithout breaking the tag. Verified: the mask does not include those bits. - The PATH block skipped entirely (
packet.cpp:217-218): source-route hints are explicitly “a hint, not security” (docs/PROTOCOL.md§Path block); ambiguity only ever causes a duplicate relay that dedup absorbs. Excluding it is what lets a path-pinned DM fail over to a discovery path without re-signing (docs/PROTOCOL.md§ACK policy) — payload/tags stay valid because the path is outside the image. No harm: a mutated path cannot change SRC/DST/payload. SEQ3forced to the unstamped sentinel for group A (packet.cpp:216): tags are computed pre-stamp; the router’s sequence stamp is attested by the router, not the sender (docs/IDENTITY.md§7, and the “things that look like bugs but aren’t” note inAGENTS.md). A CHANSYNC replay can reconstitute the trailer precisely because Tag8 covers the sentinel, not the assigned seq (docs/PROTOCOL.md§Recovery). Correct.
- Dedup key:
SipHash(fixed_key, SRC ‖ MSGID)(packet.cpp:57-62,kDedupKeyis public — collision quality only, no secrecy claimed). The documented bounded false-negative trade of the 512/8192-entry ring is indedup.h/AGENTS.md. A 64-bit key over SRC‖MSGID is a reasonable collision surface for the dedup goal; an attacker forging a collision would only cause a same-key drop, which dedup is allowed to do. - Result: No confirmed issue. One forward-looking note: the canonical image
is the single chokepoint these systems die on (“sign-what-you-encode”), so any
future packet type that puts security-relevant state in a currently-masked
region (e.g. reusing a mutable flag bit) must re-audit this function.
out[1] = fl & ~(flags::kMutableMask | flags::Path)(packet.cpp:214) carries a redundant| flags::Path(Path is already inkMutableMask); it is harmless but slightly obscures the intent.
Finding 6 — Registration (HELLO/WELCOME): replay-resistant, self-consistent (by design)
Section titled “Finding 6 — Registration (HELLO/WELCOME): replay-resistant, self-consistent (by design)”- Severity: Info (verification result)
- Location:
core/src/reg.cpp(whole file);core/include/lrc/reg.h;docs/IDENTITY.md§2. - What I confirmed:
- HELLO is
SIGNED_FULLby IK; the router enforces identity self-consistencyuid_of(client_pub) == p.srcand verifies the signature under the embedded key before recording (reg.cpp:195-196). A spoofed UID or unsigned HELLO is rejected. - WELCOME binds to one specific HELLO: the client requires
memcmp(payload+32, nonce_c_, 8) == 0(nonce echo) and verifies the WELCOME signature under the RK carried in the WELCOME (reg.cpp:109-110). This blocks replaying an old WELCOME (the comment atreg.cpp:107-108names exactly this). The router contributes its ownnonce_r(reg.cpp:199-200), so SK = HMAC(X25519(IK,RK), nonce_c‖nonce_r) is fresh per attach (core/src/ident.cpp:91). Both nonces enter the derivation — neither side can pin SK alone. boot_idis taken only from the signed WELCOME (reg.cpp:116), matchingdocs/IDENTITY.md§2 (“clients accept it only from the signed WELCOME”); BEACONboot_idis advisory.- The reverse-path pin is applied only after signature+nonce validation
(
reg.cpp:117-126), perdocs/ROUTING.md§uplink.
- HELLO is
- Minor note (Info): the WELCOME nonce-echo is
memcmp, not constant-time, but the nonce is not secret (it is the client’s own value), so this is immaterial. No issue.
Finding 7 — Peer-link (backbone) auth: pinned-key + per-frame MAC + sequence (by design)
Section titled “Finding 7 — Peer-link (backbone) auth: pinned-key + per-frame MAC + sequence (by design)”- Severity: Info (verification result; one operational caveat)
- Location:
core/include/lrc/peer_link.h;daemon/main.cpp:2205-2533;docs/ROUTING.md§“Router ↔ router”. - What I confirmed:
- Pinned identity: on HELLO the peer’s RK UID must be in
peer_pins, must not be self, and the pubkey is captured for the proof (daemon/main.cpp:2438-2446). PROOF signs the bound transcriptLRC-PEER-AUTH-v1 ‖ signer_pub ‖ verifier_pub ‖ signer_nonce ‖ verifier_nonceand is verified withverify_sigunder the pinned pubkey (2458-2466,peer_link.h:38-57). An unpinned or mis-signed peer is dropped and counted (fed_auth_fail). - Per-frame MAC + monotone sequence: data frames carry
u32 seqand an 8-byte SipHash MAC overhdr ‖ seq ‖ payload(peer_link.h:89-107); the receiver enforces bothseq == c.peer_rx_seqand MAC, then increments (daemon/main.cpp:2514-2524). This rejects replays, reorders, and truncation/extension (thehdrincludes the length,peer_link.h:90-99). Directional keys (A→B vs B→A) are split via HMAC labels (peer_link.h:79-86), so a frame cannot be reflected back. - Unauth-frame / downgrade handling: once
peer_auth_required()(any pin configured), anLRframe received beforepeer_ready, or any bad magic, drops the link (daemon/main.cpp:2488-2501). There is no in-band way for a pinned link to negotiate down to the legacy framing. - Keepalives:
len == 0frames are MACed too (queue_peer_frameand theflen == 0branch at2525), so RTT probes can’t be forged.
- Pinned identity: on HELLO the peer’s RK UID must be in
- Operational caveat (Info, documented): unpinned
lrcdsockets keep the legacy unauthenticatedLR | len | packetframing (daemon/main.cpp:2266-2272, 2292-2293, 2504-2511). This is the documented lab/back-compat mode (docs/ROUTING.md§“Router ↔ router”: “Legacy unpinned links exist only for lab and backward compatibility”). An operator who runs a real backbone without--peer-pingets cleartext, unauthenticated federation that any on-path party can inject into. This is by design but is the single most important deployment footgun in the system — worth a louder warning in operator docs. No code issue.
Finding 8 — Signed Admin: deny-by-default authz, quorum, safe-key allowlist, callback-gated RF ops (by design)
Section titled “Finding 8 — Signed Admin: deny-by-default authz, quorum, safe-key allowlist, callback-gated RF ops (by design)”- Severity: Info (verification result)
- Location:
core/src/node.cpp:4679-5130(handle_admin),node.cpp:4559-4567(verify_admin_signature),node.cpp:302-394(key allowlist + value parsers),docs/PROTOCOL.md§ADMIN. - What I confirmed:
- Every mutating op verifies
uid_of(embedded_pub) == p.srcand theSIGNED_FULLsignature over the canonical image (verify_admin_signature,node.cpp:4564-4566), and rejects ops whosetarget_rtr != cfg_.rtr(node.cpp:4734, 5034). The signature covers the op payload (key/value), so the requested action is bound to the signature. - Deny-by-default authz: the requester UID must be in
cfg_.admin_operators; the allowlist scan de-duplicates operators and only setsallowedon an exact match (node.cpp:4750-4763, 5041-5054). Unlisted operators getstatus=denied. - Key allowlist: only the enumerated safe keys are accepted
(
admin_set_key_supported,node.cpp:302-323); values are range-checked (parse_admin_set_value, ceilings atnode.cpp:214-222). There is no key that enables TX, raises power, or changes region.tx.enabledaccepts onlyoff/0and only when a platformadmin_tx_offcallback exists (node.cpp:4769-4788);tx.enabled onis explicitly unsupported (one-way RF-safety key,docs/PROTOCOL.md§ADMIN).lane.planupdates stored schedule config only — “actual RF retune policy remains outside the ADMIN packet layout” (docs/PROTOCOL.md); it never retunes directly. - Quorum: when
admin.quorum > 1, identical mutations must arrive from that many distinct allowlisted UIDs within the window (node.cpp:4982-5003, 5084-5105); the approver list de-dups byp.src, the quorum-key bins identical (key,value)/reboot requests,admin.quorumcannot exceed the distinct operator count (node.cpp:4895-4899andparse_admin_set_valuerequiring nonzero), and the window is bounded to the 24 h Admin ceiling (kAdminMaxIntervalMs). - Reboot: callback-gated (
cfg_.admin_reboot); missing/failed callbacks deny (node.cpp:5059-5068). Replay/double-fire is prevented: the first request insertsp.dedup_key()intohandled_admin_sets_(node.cpp:5038); a duplicate hits the dedup ring and only re-ACKs (it does not re-enterapply_reboot) atnode.cpp:5375-5380. So a retried REBOOT cannot call the callback twice, matchingdocs/PROTOCOL.md§ADMIN. - No escalation to RF/unsafe ops found: the only RF-affecting action reachable is the one-way TX-off safety switch, itself behind a platform callback. I could not construct a path from a signed, allowlisted Admin op to enabling TX, raising power, changing band/region, or an un-gated reboot.
- Every mutating op verifies
- Result: No confirmed issue. The authz model is correctly deny-by-default and the RF-affecting surface is one-way-safe and callback-gated.
Finding 9 — Identity revocation & rotation: no resurrection of a banned identity (by design)
Section titled “Finding 9 — Identity revocation & rotation: no resurrection of a banned identity (by design)”- Severity: Info (verification result)
- Location:
core/src/node.cpp:937-1003(revoke state machine),node.cpp:794-891(cache + rotation gating),node.cpp:6390-6414(RTRSYNC revoke ingress),node.cpp:3013-3030(ROTATE handler),core/src/ident.cpp:183-225(record decode),docs/IDENTITY.md§§5-6,docs/ROUTING.md§“revoke-state”. - What I confirmed (resurrection resistance):
- Revoke state is a generationed
(generation, writer_rtr)tuple with an explicitrevokedbool; clears are persisted tombstones, not deletions (apply_revoke_state,node.cpp:953-976).apply_revoke_stateapplies a row only if strictly newer (generationgreater, or equal-gen with greaterwriter_rtr) — so a stale revoke row from an old peer snapshot cannot re-deny a UID after a clear has converged, and a stale clear cannot undo a newer revoke. This matchesdocs/ROUTING.md§“Accepted clear rows persist as tombstones”. - A revoked UID cannot refresh the cache:
cache_identity_recordearly-returns onidentity_revoked(node.cpp:795-798), andapply_identity_rotationdoes likewise (node.cpp:860-863). Revoked senders’ chat/rotate/checkpoint frames are dropped at ingress (revoked_source_type(p.type) && identity_revoked(p.src),node.cpp:5354-5357). So a banned identity cannot resurrect itself by re-serving a KEYRESP or rotating. - RTRSYNC revoke ingress is trusted-peer-only and validated-before-apply:
radio-ingress rows are skipped (
!from_peer), local identity UIDs are skipped, malformed rows abort the whole tail before any mutation (returnon badgeneration/writer_rtr/stateatnode.cpp:6404and on a trailing-byte mismatch at6408), and only then are rows applied (node.cpp:6409-6413). Signed Adminidentity.revoke/unrevokerefuses local identity UIDs (node.cpp:4824-4828). - ROTATE requires a cached verified IdentityRecord, verifies the signature
under the cached IK_pub, and overlays the CTK only on a strictly greater
epoch (
apply_identity_rotation,node.cpp:874); equal/older/zero epochs are rejected (node.cpp:864-866, 874-877). Same-pub is required on any identity-record refresh (same_pubcheck,node.cpp:802-805). A forged or downgraded rotation cannot move the CTK backward or swap the key. - Record decode validates
uid_of(pub) == uid, exact length math, the record signature, and every subkey certificate (ident.cpp:191-222); a newer record that omits a previously-cached subkey evicts it (perdocs/IDENTITY.md§6) since the cache replaces the record wholesale.
- Revoke state is a generationed
- Result: No confirmed issue. A stale/forged rotation or a cleared revoke cannot resurrect a banned identity.
Finding 10 — Decode path: strict, length-exact, no over-read found (verification result)
Section titled “Finding 10 — Decode path: strict, length-exact, no over-read found (verification result)”- Severity: Info
- Location:
core/src/packet.cpp:41-187(layout/decode),core/src/lanes.cpp(payload parsers),core/src/ident.cpp:183-225,core/src/node.cppadmin/RTRSYNC parsers. - What I confirmed:
decoderejects frames whereo.total != len(packet.cpp:146) — every byte must be accounted for.layoutreturns false on any arithmetic that overflows the 255-byte frame (packet.cpp:52), andpath_bytesis bounded bykMaxPathHopsbefore layout (packet.cpp:139-143). TheExtAddrbit is rejected on decode (packet.cpp:130), honoring the v1.1 reservation.canonical_imagere-validates type/flags/length and bails when the tag offset exceedslen(packet.cpp:205), so a truncated frame cannot produce a tag over uninitialized bytes.- Fixed-width payload parsers check exact length:
LaneGrantInfo::parse_payloadrequireslen == 7andpreset < 8(lanes.cpp:110-118);LaneProbeInfolen == 8/preset < 8(lanes.cpp:88-97);BeaconInfoboundsn_lanestokMaxLanesand recomputes the exact length (lanes.cpp:144-165). - Admin SET parsing bounds each length field before reading
(
node.cpp:4732, 4740, 4744); the RTRSYNC revoke tail bounds each row beforememcpyand aborts on residue (node.cpp:6395-6408). The identity record decoder is length-exact (Finding 9). - All
memcpys on the decode path I inspected copy fixed sizes or pre-validated lengths into adequately sized fixed buffers (p.path[kMaxPathHops],Uid::data(),canon[kMaxFrame]). No unbounded allocation on the radio path (heap is confined to the IRC layer perAGENTS.mdrule 2).
- Result: No confirmed over-read / overflow / unbounded-alloc issue on the decode path. The “decode is strict” invariant holds.
Prioritized recommendations
Section titled “Prioritized recommendations”- (Medium) Add replay protection to LANEGRANT — at minimum route it through
the existing
(SRC, MSGID)dedup; ideally bind the confirmed routerboot_idor a per-session counter into the grant (wire change → followlrc-wire-change). (Finding 1) - (Low) In the LANEGRANT RX path, also assert
p.srcequals the registered router UID — guards a future multi-SK refactor. (Finding 2) - (Low) Verify the BYE Tag8 for locally-registered source UIDs before
tombstoning; document the rule in
docs/PROTOCOL.md§BYE. (Finding 3) - (Info) Use constant-time comparison for the 8-byte Tag8 / peer-MAC checks (prioritize the TCP peer MAC). (Finding 4)
- (Info, docs) Make the “unpinned backbone = unauthenticated cleartext” caveat louder in operator-facing docs. (Finding 7)
No code, tests, or existing docs were changed. Confirmed-issue count: 1 Medium, 2 Low. Everything else verified as by-design / documented or is a hardening note.
Addendum (WS-OTA, post-audit) — new security surface, not re-audited
Section titled “Addendum (WS-OTA, post-audit) — new security surface, not re-audited”The findings above predate the OTA staging machinery (OTA.md, landed after this audit’s date). This is a note flagging the new surface for a future audit pass, not a re-review of it — treat the findings above as still scoped to the tree as of 2026-06-16.
New trust root: a release Ed25519 key, documented in
IDENTITY.md §1’s key inventory, signs OTA manifests only and
is deliberately outside the per-identity IK/SK/CTK/RK hierarchy — neither
direction of compromise (release key ↔ any identity/admin/router key)
crosses over. Its public half is admin-provisioned config
(ota.release_pubkey/ota.nightly_pubkey safe keys), not compiled in,
so the same deny-by-default / quorum-gateable Admin authorization Finding 8
covers already gates who can install a new trust root or promote a rollout
to the fleet — ota.promote and the two pubkey keys ride the exact
admin.quorum mechanism Finding 8 verified. Applying a verified, staged
image to hardware (the only step that could turn a manifest-trust bug into
bricked devices) is explicitly not built yet (OTA.md §8) — a future audit
pass should re-examine this section once that path lands, with particular
attention to: whether a resumed-but-corrupted stage can ever reach arm()
(host tests assert it cannot — see test_otastage.cpp’s
otastage_hash_mismatch_falls_back_to_idle_never_arms), and whether the
health-gate’s crash_slot_clean default (documented in OTA.md §6 as a
deliberate “innocent until proven crashed” default) is the right posture
once real hardware feeds it.