Skip to content

cell M6: bound the neighbour-list replay residual — signed freshness window + the live relay measured - #578

Merged
josephnef merged 3 commits into
masterfrom
feat/m6-nl-relay
Nov 14, 2026
Merged

josephnef merged 3 commits into
masterfrom
feat/m6-nl-relay

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

#566, the last open item: the neighbour-list residual. The signed lists (CA → cell key → NL) stop forgery, so the residual is a genuine beacon re-aired. This bounds both shapes it can take, and measures the one the rig can produce.

1. A store-and-forward relay — beaten by the phase check (measured).
A relay decodes a genuine beacon and re-airs it, so it cannot be on time: its copy lands about a beacon's airtime after the cell's own TBTT, outside the phase tolerance.

  • CELL_REPLAY_MODE=relay (test only) adds a live relay to the existing replayer role: it re-airs each genuine cell-2 beacon — signature, certificate, fresh window and all — as soon as its RX path has the whole frame.
  • UE phase-error instrumentation: CELL_UE_NBR_PHASE_LOG logs each neighbour beacon's phase error and verdict; cell.ue.nbr_phase_stats summarises the error of the beacons taken.
  • Rig (testrig scripts/m6_nlrelay.sh, 2 runs, DU2 alive): the relay re-aired 1267–1310 beacons per run; its copies landed a median 636 µs off and were refused on phase (895–896/run), 0 as stale (the signature is current — freshness does not stop it). Genuine cell 2 stayed measured in 100% of reports. Genuine neighbour beacons landed within 6–7 µs (p99), 8 µs (max) of their predicted phase, against the 300 µs tolerance — a ~40× margin.

2. An on-phase copy — bounded by the freshness window (code + selftests).
A cut-through repeater or reflector re-radiates the beacon with almost no delay, on phase; the phase check cannot tell it from the cell. The signed freshness window caps how long such a copy stays valid:

  • NS IE v3 signs win_sf (the window length) alongside win, so a replayer cannot relabel a 64-superframe signature as a 1-superframe one (or back). cell_m6 covers the v3 round-trip, a relabel refusal, and win_sf = 0 being invalid.
  • CELL_NL_WINDOW_SF (default kNlWindowSf = 64; down to 1) sets the window. At 1 superframe a copy's useful life is ~102 ms instead of ~6.5 s, at the cost of an Ed25519 signature per superframe. The UE's staleness check keys off the signed win_sf.

A copy fresher than the window, on phase, is physically the cell's own signal re-radiated; only distance bounding (or a per-beacon challenge, which this one-way broadcast path has no room for) would stop it. docs/cell-m6.md states both residuals and their bounds.

ctest: 95/95 pass.

🤖 Generated with Claude Code

…window + measure the live relay (#566)

The signed lists left a replay residual: a genuine beacon re-aired. Two
shapes survive the signature, and this bounds both.

- A store-and-forward relay is beaten by the phase check: it re-airs a
  beacon only after receiving it, so its copy lands ~a beacon's airtime
  after the cell's TBTT. A test-only CELL_REPLAY_MODE=relay (a live relay
  of a cell's genuine beacons) plus UE phase-error instrumentation
  (CELL_UE_NBR_PHASE_LOG, cell.ue.nbr_phase_stats) measure it on the rig
  (testrig m6_nlrelay.sh): the relay's copies land ~636 us off and are
  refused on phase (0 stale: fresh signature), while genuine beacons land
  within 8 us of phase against the 300 us tolerance.

- An on-phase copy (a cut-through repeater / reflector) the phase check
  cannot tell from the cell. It is now bounded by the signed freshness
  window: NS IE v3 signs win_sf (the window length) alongside win, so the
  length cannot be relabelled, and CELL_NL_WINDOW_SF (down to 1 superframe)
  caps a copy's useful life at ~102 ms instead of ~6.5 s.

cell_m6 covers the v3 round-trip, the signed window length, and a relabel
refusal. docs/cell-m6.md states both residuals and their bounds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Bound neighbour-list replay with signed windows and relay measurements

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Sign configurable freshness-window lengths to prevent relabelled neighbour-list replays.
• Add a live relay test mode and UE phase diagnostics to measure replay rejection.
• Document measured store-and-forward rejection and the remaining on-phase replay bound.
Diagram

graph TD
  DU["DU signer"] --> IE["Signed NL"] --> UE["UE verifier"] --> Fresh{"Window valid?"} -->|yes| Phase{"Phase valid?"} -->|yes| Meas["Measure neighbour"]
  IE --> Relay["Test relay"] --> UE
  Fresh -->|no| Reject["Refuse beacon"]
  Phase -->|no| Reject
Loading
High-Level Assessment

The configurable signed window extends the existing signature and phase-check design without requiring a new radio protocol. A per-beacon challenge or distance bounding could address the remaining on-phase copy, but neither fits this one-way broadcast path as a comparable change.

Files changed (4) +243 / -43

Enhancement (2) +173 / -30
main.cppConfigure signed windows and measure relayed beacon phase +140/-11

Configure signed windows and measure relayed beacon phase

• Adds the DU window-length setting and makes UE freshness comparisons use the signed length. Adds neighbour phase logs and summary statistics, plus a test-only role that re-airs received beacons live or after a configured beacon delay.

examples/cell/main.cpp

MeasGap.hBind freshness-window length into neighbour-list signatures +33/-19

Bind freshness-window length into neighbour-list signatures

• Bumps the neighbour-list signature and certificate IEs to version 3, signs and parses win_sf, and rejects zero-length windows. Verified lists now return the authenticated window length to the UE.

src/cell/MeasGap.h

Tests (1) +26 / -3
cell_m6_selftest.cppTest signed window lengths and relabel rejection +26/-3

Test signed window lengths and relabel rejection

• Updates signature tests for version 3 and checks a one-superframe round trip. Verifies that modifying the signed length fails validation and that signing rejects a zero-length window.

tests/cell_m6_selftest.cpp

Documentation (1) +44 / -10
cell-m6.mdExplain replay bounds and live-relay results +44/-10

Explain replay bounds and live-relay results

• Documents the signed window length and its signing-frequency trade-off. Distinguishes store-and-forward from on-phase replay and records the rig's phase-rejection measurements.

docs/cell-m6.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Cell switches reject live serving beacons ✓ Resolved
Description
The serving-beacon check compares win with the previous serving cell’s win before replacing
serv_win_sf, although those numbers use different divisors when the cells have different window
lengths. After a switch from length 1 to length 64, for example, the target’s current window appears
far behind and its beacons remain stale until its numeric window catches up; restarting a DU with
the longer length can produce the same failure.


Remediation recommended

2. Freshness bound for on-phase copies is understated ✓ Resolved
Description
The residual paragraph says a copy more than about win_sf superframes old is refused, so
CELL_NL_WINDOW_SF=1 caps its useful life at about 102 ms. The UE check in run_ue accepts a
neighbour beacon whose signed window differs from the serving window by at most one (dw in -1..1),
so a copy signed at the start of one window can still pass when the serving window is one ahead.
That allows an age of up to about 2·win_sf superframes, roughly 205 ms at win_sf=1, and the same
document states the 2·win_sf figure a few lines earlier. Readers relying on the 102 ms figure to
size the residual will underestimate how long an on-phase copy stays valid by up to 2x.

3. Serving beacon with a different window length is accepted ✓ Resolved
Description
In the serving branch, the go-back check sn->win + back win_sf matches. A beacon with a different
win_sf passes that check, is adopted, and sn->win_sf != serv_win_sf then resets serv_win_ms.
The docs say a serving window of a different length is refused. Window numbers for different lengths
can't be compared: a beacon signed with win_sf=1 (win ≈ net_sf) after the cell has moved to 64
(win ≈ net_sf/64) looks far in the future. It is taken as "now", and every genuine serving beacon
after it is refused as stale, along with every neighbour comparison.

4. Cells with different window settings never measure each other ✓ Resolved
Description
The neighbour check treats any signed win_sf that differs from the serving cell’s value as stale,
rather than comparing the windows on their shared network-superframe timeline. When DUs
independently configure different CELL_NL_WINDOW_SF values, the UE discards live neighbour beacons
before updating target measurements, so conditional handovers to or from that neighbour lack fresh
measurements and the stale refusal count is the only visible sign.

View review recommended (1)
5. Delayed-replay test mode is not on phase ✓ Resolved
Description
run_relay selects the beacon from N superframes earlier for CELL_REPLAY_DELAY_SF=N, but queues
it when the newest beacon arrives and sends it as soon as the sender thread wakes, without
scheduling its departure against TBTT. Because send_packet has no sub-slot departure control, the
copy arrives one airtime plus processing later rather than on phase, so rig runs intended to test
whether the freshness window rejects on-phase older signatures can instead encounter phase refusals.


Informational

6. Relay stop flag is set but never read ✓ Resolved
Description
run_relay declares std::atomic stop and sets it at the end, but neither the RX callback nor any
other code reads it. Shutdown relies entirely on StopRxLoop(), so a later reader may wrongly think
the flag controls shutdown.

Grey Divider

Tip of the day
💡 Did you know, you can ask Qodo to dismiss a finding you disagree with, with your reason on record

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

josephnef and others added 2 commits November 14, 2026 15:33
…rame timeline

A signature's window is win * win_sf superframes of net_sf; comparing raw
win across cells with different win_sf (a switch, a DU restart, or two DUs
with different CELL_NL_WINDOW_SF) was wrong — it could refuse genuine
beacons or adopt a far-future serving window and brick freshness. Both the
serving go-back check and the neighbour freshness now compare win*win_sf,
with one coarser window of slack, so differing window lengths interoperate.

Also: the on-phase residual bound is ~2*win_sf (the +-1 window tolerance),
~205 ms at win_sf=1 — docs corrected. CELL_REPLAY_DELAY_SF reworded as an
off-phase delayed replay (send_packet has no sub-slot departure). Dead
relay stop flag removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CELL_NL_WINDOW_SF=1 made win = net_sf / win_sf = net_sf (~2^34, PTP-derived)
overflow the 4-byte NS IE window field (it only fit at win_sf=64). The v3
window field is now 8 bytes; a small window no longer wraps. Selftest covers
a win above 2^32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@josephnef

Copy link
Copy Markdown
Collaborator Author

All six findings fixed in 0a927568, and the review caught a 32-bit overflow on the rig too.

1, 3, 4 — comparing windows of different lengths. These were three faces of one bug: comparing raw win across cells whose win_sf differs (a switch, a DU restart, or two DUs with different CELL_NL_WINDOW_SF). The serving go-back check and the neighbour freshness check now both compare on the shared network-superframe timeline (win * win_sf), with one coarser window of slack, so a length change is transparent and differing lengths interoperate. The serving branch no longer adopts a far-future window. A new rig case (m6_nlrelay.sh window-N: cell 1 at win_sf=64, cell 2 at win_sf=1) confirms UE1 still measures cell 2 (100% of reports, 0 stale refusals).

2 — the on-phase bound. Corrected to ~2·win_sf (~205 ms at win_sf=1), matching the ±1-window tolerance in the check and the signed-window bullet.

5 — CELL_REPLAY_DELAY_SF. Reworded as an off-phase delayed store-and-forward replay (ages the copy to probe staleness); send_packet has no sub-slot departure control, so it is not on phase.

6 — dead stop flag. Removed.

Plus: a 64-bit window. The new window-N case immediately failed because win = net_sf / win_sf with win_sf=1 is ~2³⁴ (net_sf is PTP/TAI-derived) and overflowed the 4-byte NS IE window field — it only fit at win_sf=64. v3's win is now 8 bytes; a selftest covers a win above 2³².

Rig (all pass): the live relay is refused on phase (896/run, ~645 µs off, 0 stale — fresh signature), genuine beacons land within 8 µs (p99 7) of phase; the cross-length case measures cell 2 cleanly. ctest 95/95.

@josephnef josephnef added the skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision) label Nov 14, 2026
@josephnef
josephnef merged commit 402ad51 into master Nov 14, 2026
30 of 31 checks passed
@josephnef
josephnef deleted the feat/m6-nl-relay branch November 14, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant