Skip to content

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.


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


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 is rtr/lane_id/preset/freq_slot/ttl_s only, no nonce/seq); core/src/lanes.cpp:100-119 (encode/parse). Compare docs/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 and returns before g_node->on_radio_frame() (main.cpp:1497 vs the default: branch at 1512-1513), so the Node’s (SRC, MSGID) dedup ring never sees LANEGRANT frames. A byte-identical replay therefore re-passes verify_tag and 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 to ttl_s and 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_khz derives the frequency as siphash(...) % nslots so any freq_slot value stays in-band (core/include/lrc/presets.h:170-189). (b) Applying it to the radio is additionally gated behind the local lane retune on opt-in: scheduled_runtime_decision returns 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’t return before dedup, or add a small dedup check in the firmware case) so a byte-identical replay is dropped. Stronger option (small wire change, follow the lrc-wire-change checklist): bind the most recently confirmed router boot_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 like peer_rx_seq is the robust fix if this lane is ever promoted to a security boundary.
  • Resolution (2026-06-16): The portable core Node path was already safe — Node::handle_frame runs the (SRC,MSGID) dedup gate before dispatching, so a replayed grant is dropped before handle_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_dedup in firmware/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 compares p.src against g_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_key over 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, so p.src spoofing 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) and session_key() is ever generalized to select among them, a grant tagged with router A’s SK but claiming src = router B could 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 existing p.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_key now records the issuing router UID and Node::handle_lane_grant requires p.src to equal it; the firmware RX case checks p.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 “Tag8 when a session key exists”, and core/src/node.cpp:4105 shows the local sender emitting BYE, but the receive path never calls verify_tag / verify_client_frame before tombstoning.
  • Description: Any node can originate an unauthenticated BYE with src = victim UID; the receiver renders an IRC QUIT and suppresses the victim’s directory snapshots at the current liveness generation (note_tombstone). Because the access network re-advertises live attachments with a higher live_rev in RTRSYNC and presence is ultimately router-attested (Tier-1, docs/IDENTITY.md §4.1; tombstones lose to a newer reachable attachment per docs/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 in docs/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) when p.src is 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 (memcmp of Tag8 in verify_tag); core/include/lrc/peer_link.h:106 (std::memcmp of 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) and check_peer_link_mac now use a constant-time ct_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 by docs/IDENTITY.md §7 and docs/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_TCP masked (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 the TAGGED/SIGNED_FULL bit or flip PRIO without 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.
    • SEQ3 forced 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 in AGENTS.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, kDedupKey is public — collision quality only, no secrecy claimed). The documented bounded false-negative trade of the 512/8192-entry ring is in dedup.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 in kMutableMask); 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_FULL by IK; the router enforces identity self-consistency uid_of(client_pub) == p.src and 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 at reg.cpp:107-108 names exactly this). The router contributes its own nonce_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_id is taken only from the signed WELCOME (reg.cpp:116), matching docs/IDENTITY.md §2 (“clients accept it only from the signed WELCOME”); BEACON boot_id is advisory.
    • The reverse-path pin is applied only after signature+nonce validation (reg.cpp:117-126), per docs/ROUTING.md §uplink.
  • 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.
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 transcript LRC-PEER-AUTH-v1 ‖ signer_pub ‖ verifier_pub ‖ signer_nonce ‖ verifier_nonce and is verified with verify_sig under 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 seq and an 8-byte SipHash MAC over hdr ‖ seq ‖ payload (peer_link.h:89-107); the receiver enforces both seq == c.peer_rx_seq and MAC, then increments (daemon/main.cpp:2514-2524). This rejects replays, reorders, and truncation/extension (the hdr includes 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), an LR frame received before peer_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 == 0 frames are MACed too (queue_peer_frame and the flen == 0 branch at 2525), so RTT probes can’t be forged.
  • Operational caveat (Info, documented): unpinned lrcd sockets keep the legacy unauthenticated LR | len | packet framing (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-pin gets 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.src and the SIGNED_FULL signature over the canonical image (verify_admin_signature, node.cpp:4564-4566), and rejects ops whose target_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 sets allowed on an exact match (node.cpp:4750-4763, 5041-5054). Unlisted operators get status=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 at node.cpp:214-222). There is no key that enables TX, raises power, or changes region. tx.enabled accepts only off/0 and only when a platform admin_tx_off callback exists (node.cpp:4769-4788); tx.enabled on is explicitly unsupported (one-way RF-safety key, docs/PROTOCOL.md §ADMIN). lane.plan updates 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 by p.src, the quorum-key bins identical (key,value)/reboot requests, admin.quorum cannot exceed the distinct operator count (node.cpp:4895-4899 and parse_admin_set_value requiring 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 inserts p.dedup_key() into handled_admin_sets_ (node.cpp:5038); a duplicate hits the dedup ring and only re-ACKs (it does not re-enter apply_reboot) at node.cpp:5375-5380. So a retried REBOOT cannot call the callback twice, matching docs/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.
  • 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 explicit revoked bool; clears are persisted tombstones, not deletions (apply_revoke_state, node.cpp:953-976). apply_revoke_state applies a row only if strictly newer (generation greater, or equal-gen with greater writer_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 matches docs/ROUTING.md §“Accepted clear rows persist as tombstones”.
    • A revoked UID cannot refresh the cache: cache_identity_record early-returns on identity_revoked (node.cpp:795-798), and apply_identity_rotation does 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 (return on bad generation/writer_rtr/state at node.cpp:6404 and on a trailing-byte mismatch at 6408), and only then are rows applied (node.cpp:6409-6413). Signed Admin identity.revoke/unrevoke refuses 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_pub check, 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 (per docs/IDENTITY.md §6) since the cache replaces the record wholesale.
  • 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.cpp admin/RTRSYNC parsers.
  • What I confirmed:
    • decode rejects frames where o.total != len (packet.cpp:146) — every byte must be accounted for. layout returns false on any arithmetic that overflows the 255-byte frame (packet.cpp:52), and path_bytes is bounded by kMaxPathHops before layout (packet.cpp:139-143). The ExtAddr bit is rejected on decode (packet.cpp:130), honoring the v1.1 reservation.
    • canonical_image re-validates type/flags/length and bails when the tag offset exceeds len (packet.cpp:205), so a truncated frame cannot produce a tag over uninitialized bytes.
    • Fixed-width payload parsers check exact length: LaneGrantInfo::parse_payload requires len == 7 and preset < 8 (lanes.cpp:110-118); LaneProbeInfo len == 8/preset < 8 (lanes.cpp:88-97); BeaconInfo bounds n_lanes to kMaxLanes and 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 before memcpy and 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 per AGENTS.md rule 2).
  • Result: No confirmed over-read / overflow / unbounded-alloc issue on the decode path. The “decode is strict” invariant holds.

  1. (Medium) Add replay protection to LANEGRANT — at minimum route it through the existing (SRC, MSGID) dedup; ideally bind the confirmed router boot_id or a per-session counter into the grant (wire change → follow lrc-wire-change). (Finding 1)
  2. (Low) In the LANEGRANT RX path, also assert p.src equals the registered router UID — guards a future multi-SK refactor. (Finding 2)
  3. (Low) Verify the BYE Tag8 for locally-registered source UIDs before tombstoning; document the rule in docs/PROTOCOL.md §BYE. (Finding 3)
  4. (Info) Use constant-time comparison for the 8-byte Tag8 / peer-MAC checks (prioritize the TCP peer MAC). (Finding 4)
  5. (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.