Skip to content

1.0 readiness

This document is forward-looking (like ROADMAP.md, not normative): it tracks what stands between today’s tree and the 1.0 milestone“freeze LPP v1.1, security review, docs, packaging” (ROADMAP.md final phase). It is a checklist with honest blockers, not a claim that 1.0 is done.

Assessed: 2026-07-01. Build green, 929 host tests pass (cmake --build build && ./build/lrc_tests) — 925 pre-existing plus 4 added by this pass. python3 tests/smoke_lrcd.py, tests/smoke_sse.py, and tests/smoke_irc_compat.py all green. All 9 scenarios under tests/scenarios/*.scn pass via lrcsim, including credit_depth.scn (v2 credit routing) and eu868_duty_starve.scn (regulatory duty exhaustion). This assessment supersedes the 2026-06-16 pass below — see §0 v2-freeze program review for what changed and why the verdict moved.

  • Security disposition: hold for 1.0, not blocked, but two findings need a human call before a public release. The whole 1.0 program landed since the last pass (v2 wire — AC credit byte, RACK, BEACON region id — OTA staging, credit routing, regulatory TX gate, chirpscope SSE feed, guided peering). This pass re-derived the threat-contract claims (R1–R14, ROUTING_THREAT_MODEL.md) against the shipped code, not the design docs, and found them holding except two real, bounded gaps: an OTA reserved-stream offer accepts any registered sender (DoS on the staging window, not an image-integrity break — Medium), and RACK’s documented freshness anti-replay field is parsed but never checked (narrow exploitability, but the documented guarantee is not actually implemented — Low-Medium). Both are host-testable and now have regression probes; neither needs a wire change to fix. See §0.2 for the full findings table.
  • The v2 credit/ERB relay path is not actually multi-hop on any deployed node yet. Node::set_routing_distance_context() — the seam that would give a relay a real router-distance estimate — is never called by daemon/main.cpp or firmware/src/main.cpp. Every relay believes rdist=0 today, which self-limits v2 forwarding to exactly one hop network-wide (a fail-safe under-forward, not an amplification risk) — see §0.1. This is a functional gap, not a security one, but it means the credit-routing threat analysis in §0.1 is partly analyzing an unwired path; call this out explicitly wherever v2 routing capability is claimed before 1.0.
  • Docs: normative docs/ track continues to move with code (PROTOCOL.md’s RACK/AC/BEACON-region-id sections are detailed and accurate against the shipped implementation — verified line-by-line in this pass). One recommendation from the 2026-06-16 pass was never acted on (the “unpinned backbone” operator warning) and a new, same-shaped gap appeared (no operator guidance on firewalling the IRC/TUI/metrics/SSE ports) — both roll into one recommendation in §0.2.
  • Packaging: the single largest gap from the prior pass is closed. CMakeLists.txt now carries project(chIRpChat VERSION 0.3.0 ...), install() rules exist for lrcd/lrcctl/headers, and kLppWireVersionMax is a named, documented constant used at encode/decode and referenced by PROTOCOL.md. See §3 (unchanged structure, updated status).
  • The real blocker is still hardware, not host code. Unchanged from the prior pass — see §4.

Scope: everything landed since the 2026-06-16 audit — the LPP v2 wire batch (AC credit byte replacing TH, RACK frame, BEACON region id, Edge Routing Block), credit routing’s host decision layer, the regulatory TX gate (duty/dwell/LBT), the OTA staging/admin/health-gate stack, the chirpscope SSE event feed + GET /ota, guided federation peering pins, and the wizard/ backup-encoding UX. Method: read the shipped code (not just the design docs), write host-suite regression probes wherever a claim needed evidence, and run the existing libFuzzer decode harness against a large adversarial corpus. Every item below is evidenced — either by an existing test, a new one added in this pass, or by direct code inspection cited to a file/line.

0.1 v2 wire: AC credit byte, RACK, BEACON region id

Section titled “0.1 v2 wire: AC credit byte, RACK, BEACON region id”
# Area Finding Severity Status
A1 AC credit — flood amplification Re-origination scaling can only ever shrink a class budget, never inflate it: credit_reorigination_budget()’s if (budget > base) budget = base; floor (credit.h) is exercised across every region/sub-band/LBT-pressure combination by credit_reorigination_never_exceeds_class_ceiling_under_any_live_state (test_credit.cpp). Per-hop debit uses the relay’s own recomputed airtime_ms(), never the sender’s claim, so inflating AC upward on the wire burns spectrum only in the inflating node’s own neighborhood (its own AirtimeLedger::admit() still gates its real TX, §0.4) and buys no extra reach — the u8 ceiling (255 units = 12.75s cumulative path airtime) combined with the 4-bit ERB.rprog gradient bound (≤15 relays per re-origination domain) double-gates any pathological chain. Verified safe — matches the frozen-by-sweep claims in PROTOCOL.md §“The AC byte (v2)”. Info Checked, no issue
A2 AC credit — zeroing/targeted starvation An on-path relay setting AC=0 (or simply not forwarding) achieves exactly the same outcome as dropping the frame outright — AC lives in the mutable region by design (canonical_image() excludes byte 2, same as TH before it) precisely because no receiver ever makes a trust decision from it. Zeroing credit grants an attacker no additional power beyond what silent dropping already gives any on-path node — this is the same accepted-residual framing as the routing threat model’s suppression-abuse analysis (§3.2/R1), and AC never participates in a suppress/prune/cancel decision (only RACK does, and RACK is separately authenticated — see A4 below). Verified safe. Info Checked, no issue
A3 AC credit — re-origination abuse via forged region conditions credit_reorigination_budget()’s live-signal inputs (AirtimeLedger duty headroom, CreditLbtPressure deltas) are local, caller-measured state — a router’s own duty ledger and its own recent LBT admit/defer counters — never a wire-carried claim from a peer. There is no field on any v2 frame that feeds this function. Absent/missing evidence fails closed to the floor (credit_reorigination_missing_ledger_fails_closed_to_floor, credit_reorigination_lbt_zero_samples_is_conservative_floor), never to the full class budget. Verified safe — no forgery surface exists. Info Checked, no issue
A4 v2 relay path not yet multi-hop on any deployed node Node::set_routing_distance_context() (the seam that supplies a relay’s real rdist) has zero call sites in daemon/main.cpp or firmware/src/main.cpp — grep-confirmed. Every Node therefore relays with the default rdist=0 (“co-located with a router”). Added probe node_erb_relay_default_rdist_self_terminates_after_one_hop (tests/test_node.cpp) proves the consequence precisely: hop 1 always forwards (any rprog_in >= 1 passes 0 < rprog_in) and rewrites rprog to 0 for the next hop; hop 2 then sees rprog_in=0 against its own rdist=0, credit_makes_progress(0,0) is false, and the frame drops on DropNoProgress. The v2 gradient forwarding radius is self-limiting to exactly one hop today — a fail-safe under-forward, the opposite of an amplification risk, but it means v2’s multi-hop capability is unverified against real topology (NEXT_PASSES.md §3.1 is explicitly unchecked for exactly this reason: “unchecked until the chaos harness covers the integrated multi-node ERB/RD behavior”). Info (functional gap, not security) Documented — wire the BEACON-hop-distance seam before claiming v2 multi-hop relay works in the field
A5 RACK — auth tiers (SK-Tag8 + src check) Node::handle_rack() (node.cpp:3596) reverse-looks-up session_key_routers_ for an SK matching the RACK’s claimed p.src (issuing router), verifies Tag8 under that key via the existing constant-time verify_tag/ct_equal chokepoint (Finding 4, inherited automatically), and only then returns AuthenticatedRack. A tagged-but-unverifiable RACK, an untagged RACK, and a RACK claiming the wrong router (holding some router’s SK doesn’t authenticate a claim about a different router) all correctly collapse to UntrustedRack. Covered by node_rack_registered_relay_authenticates_and_cancels, node_rack_keyless_relay_shortens_not_cancels, node_rack_untagged_is_untrusted_not_dropped, node_rack_wrong_router_key_does_not_authenticate (pre-existing). Verified. Info Checked, no issue
A6 RACK — replay window / target_dedup_key The RACK frame itself transits the normal (SRC,MSGID) dedup gate before handle_rack is ever reached (node.cpp:6577, per the code comment citing the Finding-1 LANEGRANT lesson) — a byte-identical replay is dropped there. target_dedup_key (SipHash(SRC,MSGID) of the frame being acked) correctly prevents a captured RACK from being re-purposed against a different frame by construction (a 64-bit SipHash over the acknowledged frame’s identity). Verified for exact replay. Info Checked, no issue
A7 RACK — freshness field specified but not enforced Real finding. PROTOCOL.md §RACK documents freshness as what “binds the RACK to one router incarnation/session so a captured RACK cannot be replayed to re-suppress a later flow after that router reboots or rekeys.” Node::handle_rack() parses freshness into RackInfo and hands it to cfg_.on_rack, but never reads or compares it — authentication depends only on Tag8 validity, which does not change across a router reboot as long as the SK is still current. New probe node_rack_freshness_field_is_carried_but_not_yet_checked (tests/test_node.cpp) proves a RACK with freshness=1 or freshness=0 authenticates identically to a fresh one. Practical exploitability is narrow: exploiting this needs the original acknowledged frame’s (SRC,MSGID) to still be “live” — MSGID is a per-Node-instance rng.next_u32()-seeded, then-incrementing counter (node.cpp:627), so genuine MSGID reuse across a reboot is a coincidence, not attacker-controlled — and the RACK’s own dedup ring must have rotated past the original entry for a captured RACK to even reach handle_rack again. The blast radius when it does land is bounded to Cancel-ing (not creating) a legitimate relay’s own pending forward of a frame that happens to share the stale target_dedup_key — R1’s ignore-suppression fallback does not apply here because AuthenticatedRack is exactly the tier R1 permits to Cancel outright. Low-Medium Documented, not fixed — implementing the check needs a node-side “last-seen freshness per router” comparison rule (monotone-only vs. tolerant window) that wasn’t specified precisely enough to implement safely under this pass’s time budget; a rushed comparison risks a different bug (e.g., rejecting a legitimate RACK after a router’s counter genuinely resets). Recommend: land freshness enforcement as its own reviewed change before or shortly after 1.0, not as a same-pass patch.
A8 BEACON region id — spoofed/mismatched region fails safe beacon_region_accepted() (lanes.h) is called at the one and only BEACON-consuming site, firmware/src/main.cpp’s handle_radio_frame — grep-confirmed zero other call sites, and core/src/node.cpp has no PacketType::Beacon case at all (BEACON is receive-only, firmware/client-role traffic, never dispatched through the portable Node::handle_frame). A mismatched-region beacon is ignored and counted (region_mixed_beacon_ignored) before g_sched.set_plan() is ever called — no code path adopts a heard region it disagrees with. Self-unset accepts all (correct “no opinion yet” bias); self-configured rejects kBeaconRegionIdUnset (correct — “unset” is a concrete claim, not a wildcard). Covered by lanes_beacon_region_id_defaults_to_unset and the beacon_region_accepted unit tests (test_lanes.cpp). Verified — no downgrade-style attack surface: a permissive-region beacon from a rogue router is simply ignored by any node already configured for a different (stricter) region; it can never override a configured region. Info Checked, no issue

0.2 RACK/TRAIL trust boundary and OTA reserved-stream sender check

Section titled “0.2 RACK/TRAIL trust boundary and OTA reserved-stream sender check”
# Area Finding Severity Status
B1 RACK — forged-but-unverifiable RACK, given R1 An unverifiable (untagged, or Tag8-fails) RACK classifies as UntrustedRack, which routing_policy.h’s routing_suppression_apply() can only route to RoutingSuppressionAction::Shorten (floor untrusted_rack_floor_ms, clamped to never exceed the pending deadline) — never Cancel. Only AuthenticatedRack/LocalIngress/AuthenticatedForwardProgress (the last gated additionally by routing_event_advances_self) reach Cancel. This is the exact R1 contract (“no unauthenticated signal may cancel an honest relay — only delay it”) and it is enforced in the decision-layer code, not just documented. The pending-forward suppression scheduler that would consume this classification to actually cancel/shorten/fire a real queued TX is explicitly not wired yet (PROTOCOL.md §RACK: “TX-timing behavior… not yet wired into Node… needs human RF sign-off per AGENTS.md rule 5”) — so today, a forged RACK’s only reachable effect (via cfg_.on_rack) is whatever an embedder’s own callback chooses to do with an UntrustedRack classification; the load-bearing guarantee is enforced at the policy layer and is ready for the scheduler once that RF-gated work lands. Info Checked, no issue (structurally sound; the consuming scheduler is correctly still RF-gated and unbuilt)
B2 TRAIL remains trust-free end-to-end Grepped every consumer. core/src/node.cpp: TRAIL bytes are copied into NodeEvent.trail/trail_len for observability only — never read by any forwarding/prune/suppression/credit decision (confirmed: no call site reads p.erb_trail/p.erb_trail_len outside the event-emission path and the ERB layout decoder itself). daemon/sse_feed.cpp: encode_event_json emits trail as a hex string with a code comment citing “DIAGNOSTICS ONLY… the security contract forbids feeding it to any trust decision.” web/src/model/records.ts, web/src/model/agg.ts, web/src/views/topology.tsx: the web UI renders TRAIL behind an explicit badge-warn "TRAIL: UNVERIFIED diagnostics" badge with a tooltip explaining “Hop bytes are single-byte UID prefixes: ambiguous by construction, rendered as diagnostics, never as trusted topology” — three independent code comments in the web layer make the same claim the core layer’s design doc does. Verified end-to-end — TRAIL is trust-free at every layer that touches it. Info Checked, no issue
B3 OTA reserved-filename OFFER accepts any registered sender, not just the intended uploader Real finding. Node::handle_ota_dcc_offer() (node.cpp:3232) gates a DCCCTL OFFER named kOtaDccFilename on stage state == Staging, !ota_stream_active_, and offer geometry (size/chunk_size) matching the admin-verified manifest — but never checks offer.peer (the sender’s UID) against any expected uploader identity; no such concept exists anywhere in the OTA design (otaadmin.h’s safe-key family has no “trusted OTA source” key). New probe node_ota_dcc_offer_accepts_any_registered_sender_not_just_uploader (tests/test_node.cpp) proves an ordinary registered client with no OTA/admin role is auto-accepted exactly like the real uploader would be, once an operator has opened the staging window (accept_verified_image + begin_staging). Blast radius is bounded to denial-of-service, not image substitution: the same probe confirms an attacker who wins the race and then sends garbage chunks cannot arm a malicious image — the whole-image SHA-256 gate (verify_staged_hash()) still catches it and falls the stage back to Idle (matching the pre-existing unit coverage in otastage_hash_mismatch_falls_back_to_idle_never_arms). The realistic harm is an attacker occupying the single-stream binding and forcing the operator to restart the rollout — a griefing/availability nuisance during the staging window, not a firmware-integrity break. Medium (bounded to DoS; no path to image substitution or privilege escalation found) Documented, not fixed — a correct fix needs a new trust-model primitive (an admin-provisioned “expected OTA source” identity, wired through Node::Config and the daemon’s admin-apply path) that does not exist today and was out of scope to invent safely under this pass; recommend scoping it as its own reviewed change.
B4 Regulatory gate — airtime DoS economics vs. credit bounds Confirmed by code, not just docs: AirtimeLedger::admit() is the single physical-TX chokepoint (firmware/src/main.cpp:1506, the actual radio-loop TX site) and gates every transmission this node makes — chat, relay, ACK, RACK, whatever Node::handle_frame decided to emit — against this node’s own real, per-sub-band duty budget. A peer forcing more traffic at a victim can only make the victim burn its own budget faster (matching ROUTING_THREAT_MODEL.md’s already-accepted §3.9/R-residual-1 “Mostly” mitigated framing); it cannot borrow or forge another node’s budget, and credit_forward()’s debit and AirtimeLedger’s admission both call the identical airtime_ms() so a frame’s claimed AC can never disagree with what a hop is actually legally allowed to spend. No new v2-introduced amplification vector foundeu868_duty_starve.scn (CI-gated scenario) exercises real starvation behavior and passes. Info Checked, no issue
B5 LBT observe-seam trust LbtObservation (region.h) carries only locally, physically measured fields (observed_cs_time_us, observed_rssi_dbm, continuous_tx_ms_so_far) — there is no wire field anywhere that lets a remote peer feed lbt_admit() a claim. CreditLbtPressure (the only LBT signal that reaches credit routing) is explicitly documented as “recent-window delta of region.lbt_admit/region.lbt_deferred” — local counters, matching the routing threat model’s R3 (self-affecting-only) pattern. A region requiring LBT with no sourced carrier-sense data fails closed to DeferUnverifiedParams, never silently admits (region_lbt_kr920_fails_closed_on_unverified_params). Verified — no forgery surface; trust boundary is physics (a node can only observe its own antenna). Info Checked, no issue

0.3 SSE feed, GET /ota, and network exposure stance

Section titled “0.3 SSE feed, GET /ota, and network exposure stance”
# Area Finding Severity Status
C1 SSE feed CORS (Access-Control-Allow-Origin: *) — what crosses Enumerated encode_event_json/encode_counters_json/encode_airtime_json (daemon/sse_feed.cpp) field-by-field: packet type/flags/ttl/hops/has_erb/credit/debit_units/msgid, hex src/dst UIDs (public identifiers), group-A scope (channel hash + router id) + seq24, payload_len (a byte count, never content), via_tcp, and TRAIL hex bytes (explicitly diagnostic, see B2). Counter snapshots are the full stats registry (already unauthenticated on /metrics). Airtime snapshots are region name/freq/preset/duty numbers. No field carries message text anywhere in the encoder. Info Checked, no issue
C2 SSE feed — DM/mailbox paths specifically NodeEvent (node.h:341) structurally has no payload/text field at all — it is not merely omitted by the encoder, the type itself cannot carry it. The single chokepoint Node::emit_node_event() (node.cpp:1407) builds every packet event from header-only fields off the decoded Packet and never touches p.payload; it fires unconditionally for every accepted frame type (node.cpp:6336, before type-specific dispatch), including MailboxSync and Dm. Pre-existing sse_encode_packet_rx_has_header_fields_no_payload_text (test_sse_feed.cpp) and smoke_sse.py’s “no leaked message text” check covered the JSON-encoding layer; this pass added node_mailboxsync_observatory_event_never_carries_message_text (tests/test_node.cpp), an end-to-end probe that decodes a real MailboxSync peer frame carrying a marked secret string through a live Node with cfg.on_event wired and scans every emitted NodeEvent for it — closing the gap between “the encoder is safe” and “nothing upstream of the encoder ever hands it something unsafe.” Verified end-to-end on the highest-risk path. Info Checked, no issue (now with end-to-end evidence, not just encoder-level)
C3 SSE feed — local-network exposure stance sse_preamble()’s CORS-wildcard is a deliberate choice (code comment: “the stream carries the same non-secret network-health data /metrics already exposes unauthenticated on the operator LAN”). Given C1/C2 confirm no secret/message content ever reaches this stream, the wildcard CORS is consistent with the data’s actual sensitivity. No new exposure introduced versus the existing /metrics stance. Info Checked, no issue
C4 SSE feed — slow-client/resource-exhaustion handling SseClientBuffer (sse_feed.h) is a fixed-capacity (cap=256) std::deque that drops-and-counts (Counter::sse_overflow_drop) once full rather than growing unbounded or blocking the shared single-threaded poll() loop — verified by sse_buffer_drops_and_counts_past_capacity (test_sse_feed.cpp). The drop path cannot be gamed to grow the buffer past cap: push() checks size before appending, and drain() is the only way the queue shrinks. A slow/malicious client can force its own buffer to overflow-and-drop but cannot affect other clients’ buffers or the daemon’s poll() loop. Verified bounded. Info Checked, no issue
C5 GET /ota — CORS and content GET /ota (and /metrics) sets no Access-Control-Allow-Origin header at all — the conservative default (a browser cannot read a cross-origin response body without an explicit CORS grant), unlike the SSE endpoint’s deliberate wildcard. Content (OtaFleetCoordinator::render_status()) is fleet rollout metadata only: image size, canary-wave UIDs, per-node staging state/chunk-progress/offered flag — no keys, no manifest signatures, no secret material. Verified — no exposure beyond what §C1 already covers for the rest of the observability surface. Info Checked, no issue

0.4 OTA stack — manifest verification, reserved-filename discrimination, admin/quorum, health-gate

Section titled “0.4 OTA stack — manifest verification, reserved-filename discrimination, admin/quorum, health-gate”
# Area Finding Severity Status
D1 Manifest verification order and wrong-board/downgrade/version-replay verify_release_manifest() (daemon/ota_manifest_verify.cpp) checks in the correct order: decode → signature → board_id → profile → min-version floor. Signature is checked before any identity/version claim is trusted, so a malformed-signature manifest cannot be waved through by claiming a matching board_id. Wrong-board (ota_manifest_verify_rejects_board_mismatch), profile mismatch (ota_manifest_verify_rejects_profile_mismatch), and downgrade/version-replay via the min_running_semver floor (ota_manifest_verify_rejects_version_below_floor, ota_manifest_verify_accepts_version_at_floor) are all pre-existing, passing unit coverage. Verified. Info Checked, no issue
D2 Resumed-stage substitution vs. whole-image SHA-256 otastage_hash_mismatch_falls_back_to_idle_never_arms (pre-existing) proves a stage whose reassembled bytes don’t match the manifest’s whole-image SHA-256 can never reach arm() — it falls back to Idle. This pass’s new end-to-end probe (B3’s second half) confirms the same outcome holds when the mismatched content arrives via a real attacker-controlled DCC stream, not just directly through the unit-level state machine API. Verified — resumed-stage substitution is not possible; the whole-image hash is the sole integrity authority, matching the documented design. Info Checked, no issue
D3 Reserved-filename discrimination — what gates an OFFER reaching the stage machine See B3. The Staging-state + geometry gates are correct and necessary but not sufficient: they correctly stop a reserved-name offer from ever being surfaced as a user file offer or from starting before an admin has verified a manifest, but they do not check who sent it. Medium See B3
D4 Admin-key provisioning story and quorum gating ota.release_pubkey/ota.nightly_pubkey/ota.wave/ota.promote all ride the same deny-by-default, admin.quorum-gated authorization the standing audit’s Finding 8 already verified sound — no new authz mechanism was invented for OTA, they reuse the generic ADMIN op=3 SET machinery (signature-bound to the op payload, allowlisted operators, quorum window). Round-trip coverage: node_admin_authorized_set_ota_keys_round_trip, node_admin_ota_promote_requires_configured_listener. Verified — no new authz surface, correctly reuses the audited one. Info Checked, no issue
D5 Health-gate/rollback decision logic failure modes Timeout (otastage_health_gate_rolls_back_on_window_expiry), immediate crash (otastage_health_gate_rolls_back_on_crash_immediately), and boot-attempt ceiling (otastage_health_gate_rolls_back_on_boot_attempt_ceiling) all correctly resolve to RollBack, not a stuck/ambiguous state. The crash_slot_clean “innocent until proven crashed” default was already flagged in the 2026-06-16 audit’s OTA addendum as needing re-examination once real hardware feeds it (OTA.md §6) — applying a staged image to actual hardware is still not built, so this remains correctly out of scope for a host-only pass; not a new finding, tracked correctly as already-forward-pointed. Info Checked, no issue (pre-existing forward pointer still accurate)
# Area Finding Severity Status
E1 Peering pin entropy/lifetime/replay, what a leaked pin grants PeeringPinPayload (fed_peering.h) carries no secret material — host, port, the generating router’s own public UID, and an expiry, plus a 2-byte truncated-SHA-256 checksum the header comments explicitly call “NOT a security mechanism… the actual trust decision happens when both operators compare the rendered fingerprint out of band.” A leaked pin grants an attacker nothing beyond what dialing the router’s own (meant-to-be-dialable) listen port and reading its (meant-to-be-public) UID would already give them. Default TTL is 15 minutes (kFedPeeringPinDefaultTtlS, fed_peering_pin_default_ttl_is_15_minutes), and expiry is enforced in decode_peering_pin/peering_pin_outcome. Verified — the pin is a UX convenience against transposition typos, not a credential. Info Checked, no issue
E2 Fingerprint verification UX — does it bind what users think it binds The pin’s decoded UID feeds directly into lrcd’s peer_pins list (daemon/main.cpp:3335) — the exact same list --peer-pin populates and the exact same list the cryptographic HELLO/PROOF handshake (peer_link.h, standing audit Finding 7) checks before trusting a connection. A pin never bypasses that handshake; it only pre-populates the expected UID the redeemer will require the far side to cryptographically prove. TOFU semantics are correctly implemented: trust comes from the operator’s out-of-band fingerprint comparison, the crypto enforces it once granted. Verified — binds exactly what the docs claim. Info Checked, no issue
E3 Seed backup exposure paths backup_encoding.h’s phonetic-hex/QR renderers are presentation-only and don’t decide where a secret is displayed. The actual exposure surface is Node::service_key_export() (node.cpp:3831), reachable via /msg *lrc key export over any IRC/Telnet-TUI session — and chIRpChat’s identity model has no nick/username ownership or password mechanism: Node::identity_for(username) (node.cpp:753) mints or loads a persistent seed keyed purely by the connecting session’s claimed USER name, first-come-first-served. daemon/main.cpp’s listen_on() binds every listener (IRC, peer, TUI, metrics, SSE) to in6addr_any (all interfaces) by default — confirmed by code, not assumption. docs/ARCHITECTURE.md’s own framing (“a family shares one XIAO: three laptops, three nicks, three keys, one radio”) makes clear the design’s threat model assumes whoever can reach the device’s IRC/TUI port is already a trusted household/LAN member — this is a legitimate design choice for the stated use case, not a bug, but it is not stated as an explicit operator-facing warning anywhere: a network-exposed lrcd (LAN-wide, or worse, port-forwarded) lets anyone who connects and claims an existing username export that identity’s private seed. This is the same shape of gap as the already-known-but-never-actioned Finding 7 recommendation (§0.6). Info (design-as-intended for the stated single-household/trusted-LAN use case; documentation gap, not a code defect) Documented — fold into the operator-deployment-stance recommendation below; no code change proposed (the trust model itself — nick-scoped identity with no auth — is a legitimate, load-bearing design choice for this product, not something to patch reactively)

Cross-checked against the 2026-06-16 pass’s §3 gaps:

Prior gap Current state
No project version Closed. CMakeLists.txt:4: project(chIRpChat VERSION 0.3.0 LANGUAGES CXX C).
No install() rules Closed. install(TARGETS lrcd ...), install(PROGRAMS daemon/lrcctl ...), install(FILES core/include/lrc/packet.h ...) all present (CMakeLists.txt).
No named LPP wire-version constant Closed. constexpr uint8_t kLppWireVersionMax = kLppV2; (packet.h:17), used at encode/decode, documented in PROTOCOL.md §Versioning, and the v1/v2 split (kLppV1/kLppV2) is golden-byte pinned (packet_golden_bytes, packet_erb_v2_golden_bytes, packet_ac_v2_golden_bytes, RACK golden bytes — test_packet.cpp).
No release artifacts / CHANGELOG / operator deployment guide Not re-verified this pass — out of scope for a security review; carry forward from the prior pass if still open.

All three host-actionable packaging gaps the prior pass called “the single largest 1.0 gap” are now closed.

0.7 Fuzz coverage note (honest accounting)

Section titled “0.7 Fuzz coverage note (honest accounting)”

The existing libFuzzer harness (tests/fuzz/decode_fuzz.cpp, covers decode() and decode_identity_record() — the same unified decoder v1 and v2 both go through, selected by the version bits) could not be linked as a true libFuzzer binary on this machine (Apple Clang 17 lacks the bundled libclang_rt.fuzzer_osx.a runtime in this environment) — this is a local toolchain limitation, not a codebase issue; CI’s fuzz-smoke.yml runs the real libFuzzer harness on Linux for 90s per run and is unaffected. As a substitute, this pass: (1) ran the harness’s documented DECODE_FUZZ_STANDALONE fallback build (no sanitizer instrumentation) against the checked-in 7-line seed corpus plus ~43,000 generated adversarial inputs — random garbage across the full 0–255 byte domain, v2-header-biased inputs, and inputs specifically shaped around RACK’s 12-byte payload boundaries, BEACON region-id edge values (0x00, unset 0xFF, and out-of-table values), and malformed ERB/TRAIL length claims — all drained cleanly in <0.2s with no crash; (2) separately built and ran the same corpus under -fsanitize=undefined only (no ASAN), also clean, no UBSAN diagnostics; (3) isolated and confirmed that an ASAN+UBSAN combined build hangs on this machine even on empty stdin (zero bytes processed before the hang), proving the hang is a local ASAN-interceptor/stdio artifact on this toolchain, not input-dependent or decode-logic-related, before discarding that build. Honest summary: no crash/hang/OOB found in ~43,000 adversarial inputs against the real decode path under UBSAN and a non-instrumented build; full ASAN coverage was not achieved locally and relies on CI’s Linux run, which this pass did not trigger or inspect.


1. Security review (2026-06-16 baseline — superseded by §0 for anything in scope there)

Section titled “1. Security review (2026-06-16 baseline — superseded by §0 for anything in scope there)”

SECURITY_AUDIT.md is current as of 2026-06-16. Disposition unchanged by this pass (none of Findings 1–10 touch v2 surface):

Finding Sev Status
1 — LANEGRANT replay protection Medium Resolved (firmware grant-dedup ring + core (SRC,MSGID) gate)
2 — LANEGRANT p.src not bound to registered router Low Resolved (set_session_key records issuer; RX checks p.src)
3 — BYE accepted without Tag8 Low Resolved (Tag8 verified for locally-registered SRC; forged eviction dropped)
4 — Tag/MAC compares not constant-time Info Resolved (ct_equal in verify_tag + peer-MAC) — confirmed in this pass to be inherited automatically by RACK’s Tag8 verification (§0.1 A5)
5 — Canonical-image exclusions Info Verified by-design; confirmed in this pass that AC (byte 2) and the ERB both correctly join the same excluded region TH/Path occupied (§0.1 A2, A6)
6 — Registration replay-resistance Info Verified by-design
7 — Peer-link backbone auth Info Verified by-design; recommendation below still not acted on
8 — Signed Admin authz/quorum/RF-safety Info Verified by-design; confirmed in this pass that the new ota.* safe-key family correctly reuses this exact mechanism (§0.4 D4) rather than inventing a parallel one
9 — Identity revoke/rotation resurrection Info Verified by-design
10 — Decode strictness / no over-read Info Verified by-design; confirmed in this pass via ~43,000 adversarial fuzz inputs against the v2-extended decoder (§0.7)

1.2 Fresh pass (2026-06-16, surfaces the original audit under-weighted)

Section titled “1.2 Fresh pass (2026-06-16, surfaces the original audit under-weighted)”

Unchanged from the prior document — MAILBOXSYNC ingress, IRC gateway flood control, and the DCC bulk path were all verified safe/adequate/bounded on 2026-06-16 and nothing in this pass’s scope touched those code paths in a way that would invalidate that verification.

  1. (Info, docs — STILL OPEN, one release later) The “unpinned backbone = unauthenticated cleartext” caveat (Finding 7) was recommended for louder operator-facing docs on 2026-06-16. It is still not present in USERGUIDE.md or README.md as of this pass (grep-confirmed). This pass found a second, same-shaped gap: no operator-facing guidance exists anywhere about firewalling the IRC/TUI/metrics/SSE listeners, which all bind to every interface by default (§0.5 E3) — and chIRpChat’s identity model (nick-scoped, no password) means a network-exposed lrcd/firmware extends the intended “trusted household member” boundary to anyone who can reach the port. Recommend combining both into one operator-deployment-stance section (“what to expose on your network and why”) before 1.0’s docs are considered complete — this is now two independent findings pointing at the same missing document section, which raises its priority versus treating either as a standalone nit.
  2. (Info, cosmetic — STILL OPEN) packet.cpp’s canonical-image mask still carries the redundant | flags::Path noted on 2026-06-16 (Path is already in kMutableMask). Harmless, unchanged.
  • Resolve B3 (OTA reserved-stream sender check) and A7 (RACK freshness enforcement) as scoped follow-ups — both are host-testable, neither needs a wire change, and both now have regression probes pinning the current (gap) behavior so a fix is a green-to-green move with a clear “this now passes” signal.
  • Land the operator-deployment-stance docs section (§1.3 item 1) — this is now the single most-recommended-and-least-acted-on item across two consecutive security passes.
  • A fresh external/independent security review of the v2 wire surface specifically (credit routing, RACK, ERB, BEACON region id) — this pass is thorough but is still the same team’s second internal look, not an outside one. The v2 surface is materially larger and newer than what the 2026-06-16 external-review recommendation was scoped against.
  • Full libFuzzer/ASAN coverage of the v2-extended decode path on the actual CI Linux runner, verified by a human reading the fuzz-smoke.yml run logs directly (this pass’s local substitute run is good corroborating evidence, not a replacement for that).
  • Wire Node::set_routing_distance_context() from real BEACON-derived hop distance (§0.1 A4) before advertising v2 credit routing as functioning multi-hop capability anywhere user-facing (README, site, USERGUIDE) — the gap is not a security hole, but shipping a security review that says “the gradient loop guard holds” without also saying “and it’s not wired up yet” would be misleading positioning.

The normative docs/ set (PROTOCOL, IDENTITY, ROUTING, RADIO, STORAGE, POWER, HARDWARE, TELEMETRY, ARCHITECTURE, USERGUIDE, OTA, OBSERVATORY) is complete and, per AGENTS.md rule 3, moves in the same commit as behaviour — confirmed again in this pass: PROTOCOL.md’s RACK/AC/BEACON-region-id sections match the shipped implementation in every detail checked (§0.1, §0.2). Governance docs and CI remain wired.

Not re-verified this pass (out of scope for a security review; see the 2026-06-16 version of this document for the prior README/COMPARISON.md staleness findings — re-check those before 1.0 if they have not been independently resolved):

  • README.md version/test-count headline currency.
  • COMPARISON.md / Reticulum coverage.
  • CHANGELOG existence.

New in this pass: the operator-deployment-stance gap, §1.3 item 1 — the single doc recommendation this review considers most consequential for a public release, because it now has two independent, still-open findings behind it.


See §0.6 — all three host-actionable gaps the prior pass flagged as “the single largest 1.0 gap” are closed. Release-artifact story (CPack/tarball/ container/systemd unit) and a CHANGELOG were not re-verified this pass.


4. Hardware-gated blockers (cannot be closed on-host)

Section titled “4. Hardware-gated blockers (cannot be closed on-host)”

Unchanged from the 2026-06-16 pass. Per AGENTS.md rule 5, anything touching firmware/variants/, RF-switch, TCXO, or TX paths needs human RF sign-off and physical radios; these gate a 1.0 declaration, not the host-side security disposition. Two items from this pass now belong on that list explicitly:

  • RACK suppression scheduler (§0.2 B1) — the decision layer (R1’s Shorten/Cancel contract) is built and correctly enforced; the TX-timing scheduler that would consume it and actually cancel/shorten/fire a queued radio transmit is unbuilt and RF-gated by design (PROTOCOL.md §RACK is explicit about this).
  • v2 credit routing’s real multi-hop behavior (§0.1 A4) — the decision layer is built and host-tested against synthetic rdist inputs; nothing currently feeds it real topology, so its field behavior beyond one hop is unverified. This is closable on-host (wiring set_routing_distance_context from BEACON observations is host-testable, not RF-gated) but should land and get chaos-harness coverage (NEXT_PASSES.md §3.1) before 1.0 claims multi-hop v2 routing works.

  1. B3 + A7 follow-ups — OTA sender check and RACK freshness enforcement, both host-only, both already have regression probes pinning the current gap.
  2. Operator-deployment-stance docs (§1.3 item 1) — the two-releases- running recommendation.
  3. Wire set_routing_distance_context from BEACON hop-distance observations (§0.1 A4, §4) — host-testable, unblocks an honest “v2 multi-hop routing works” claim.
  4. Fresh external review of the v2 wire surface + full CI-side libFuzzer/ASAN confirmation (§1.4).
  5. Freeze declaration — the byte layout is already frozen (fad30ba/61edc42); this step is procedural once 1–4 land.
  6. Hardware bring-up under human RF sign-off — unchanged gate between an RC and a stamped 1.0.

The host-side security disposition does not block a 1.0 release candidate, but two real findings (B3: OTA reserved-stream sender check, Medium; A7: RACK freshness not enforced, Low-Medium) should be resolved or explicitly risk-accepted by a human before a public release, and the operator-deployment-stance documentation gap (network-exposed IRC/TUI/metrics/SSE ports plus the unpinned-backbone caveat) has now been flagged twice without action and should not go into 1.0 a third time unaddressed. Everything else examined in the v2-freeze program — credit routing’s amplification/starvation bounds, RACK’s authentication tiers and R1 suppression contract, BEACON region-id fail-safety, the SSE feed’s content boundary (including a new end-to-end DM/mailbox-path proof), OTA’s manifest verification and image-integrity gates, and the peering-pin trust model — held against direct code inspection and, where a claim needed evidence beyond reading, a new host-suite regression test now pins it down.