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 real 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(LoRa Relay Chat 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) WIRED (2026-07-03) — the seam now has real suppliers with fixed precedence (CREDIT.md §Where rdist comes from): lrcd and firmware-router pin rdist 0 explicitly; everyone else runs the passive RdistLearner (accepted BEACON / SK-verified RACK ⇒ 1; overheard ERB rprogp+1; upward-only decay; learn-after-decide preserves the bootstrap forward this row described). The no-evidence probe is retained unchanged; node_erb_relay_learned_gradient_replaces_blind_default pins the multi-ring behavior. Field-claim caveat now reduces to the ordinary Phase 4 RF validation gate, not a missing supplier.
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 FIXED (2026-07-03) — enforcement landed as its own reviewed change (PROTOCOL.md §RACK “Freshness enforcement”). The comparison-rule risk called out here was resolved by not choosing between monotone-only and a tolerant window: boot_id is random, so the rule is equality-with-adoption (3 consecutive SK-verified RACKs adopt a new incarnation), and a mismatch de-tiers to the shorten-only tier instead of rejecting — an over-strict comparison can only add latency, never cancel a well-behaved relay (R1). Probe flipped to node_rack_freshness_stale_detiers_never_drops; new counter rack.freshness_detier.
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 a well-behaved 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) FIXED (2026-07-03) — the recommended primitive landed as its own reviewed change: the ota.uploader safe key (admin-provisioned expected-uploader UID riding the existing deny-by-default, quorum-gateable ADMIN SET machinery — no new authz mechanism invented) gates the reserved-filename OFFER before the geometry fences; wrong senders are refused and counted (ota.offer_wrong_sender), clear restores accept-any for single-operator fleets. Probe flipped to node_ota_dcc_offer_uploader_gate_blocks_unexpected_sender, which also preserves D2’s end-to-end wrong-bytes-never-arm evidence.
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 did not check who sent it. Medium FIXED via B3 — the ota.uploader gate now precedes the geometry fences
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 LoRa Relay Chat’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(LoRa Relay Chat 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.

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. 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) LANDED 2026-07-03: USERGUIDE.md now has the recommended combined section — “Deployment stance — what to expose on your network, and why” — covering both same-shaped gaps in one place: the nick-scoped/no-password identity consequence (reaching :6667/:2323 = trusted household member, including key export of private seeds; never port-forward those, firewall or island them), the “unpinned backbone = unauthenticated cleartext” caveat (Finding 7) with :6810 framed as the only designed-to-cross-networks listener when pinned, the metrics/SSE metadata stance, and the explicit rationale for not shipping a half-secure password knob. SECURITY.md points at it.
  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-upsboth landed 2026-07-03 as the scoped, individually reviewed changes this item asked for (see the updated A7/B3/D3 rows); the pinning probes flipped to asserting the new enforcement semantics.
  • Land the operator-deployment-stance docs section (§1.3 item 1)landed 2026-07-03 (USERGUIDE.md “Deployment stance”; see §1.3 item 1 for what it covers). Two passes’ worth of recommendation, finally acted on.
  • 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)wired 2026-07-03 (see the updated A4 row): routers pin rdist 0 explicitly, non-routers learn from accepted BEACONs / SK-verified RACKs / overheard ERB rprog with upward-only decay. Host-level multi-ring behavior is test-pinned; on-air multi-hop claims still wait on the ordinary Phase 4 RF validation gate before appearing user-facing.

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 a verified “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.