Skip to content

cell M6: UE keys for RACH-joined UEs — a joiner hands over under per-cell keys - #541

Merged
josephnef merged 2 commits into
masterfrom
feat/m6-joiner-keys
Nov 7, 2026
Merged

josephnef merged 2 commits into
masterfrom
feat/m6-joiner-keys

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

M6 (#260, EPIC #254): UE keys for RACH-joined UEs, so a joiner can hand over under per-cell keys. This closes the open item from #540.

Why

Under per-cell keys (#540), the CU wraps each handover's context for the UE's key, which is derived from its home (cell, UE) identity. A joiner has no such identity, since its id comes from the join. So the CU couldn't wrap a context for it, and a joiner never handed over.

What

  • Keys.h joiner_key(master, mac): K_j = hex(HMAC(master, 'J' mac(6))). A joiner's UE key is bound to the MAC it joins with (CELL_JOINER_MAC, or derived from host and radio) and provisioned out of band like a SIM's (CELL_UE_KEY).
  • F1 v10 Report.joined: the UEs a DU admitted through the RACH, with their joiner MACs (UeRegistry::owners()). Static UEs and handover arrivals hold the cell's synthetic MAC and aren't listed. The DU takes the snapshot under mu before the report takes f1_mu, so the two locks never nest.
  • CU: a home is now either a static (cell, UE) or a joiner MAC. On a joiner's first handover the CU takes its home from the source DU's report and wraps under K_j (ran.ho_cmd home J:<mac>). home_of carries it to later handovers.
  • A false MAC gains nothing: a UE that joins claiming another's MAC only gets contexts it can't unwrap.
  • Selftests (cell_m6): joiner_key is deterministic and distinct from other joiners and from static keys; Report.joined round-trips and is bounded; UeRegistry::owners.
  • Docs: docs/cell-m6.md covers the joiner key and the rig result, and the open item is removed.

Rig (testrig scripts/m6_joinerkeys.sh, per-cell keys)

UE1 joins cell 1 through the RACH with a fixed joiner MAC and that MAC's K_j, and is handed over by the A3 at a −2 dB offset.

case runs result
the joiner hands over 2 joined as UE 2; context for its MAC (home J:02cefeaabb01); switch, target in slot 99.9 %, ran.ho_done
the joiner holds another MAC's key 2 every command refused (no context), no switch; DU1 kept it (heard ≥ 99.9 %, in slot 100 %)

Regression on F1 v10: the static per-cell key set (handover, forge, isolate, coordination) and the re-join after a DU restart under per-cell keys both pass.

🤖 Generated with Claude Code

…keys

A joiner has no home (cell, UE) identity, so under per-cell keys (#540) the
CU could not wrap a handover context for it and it never handed over. Its
UE key is now bound to the MAC it joins with: K_j = HMAC(master, 'J' mac)
(Keys.h joiner_key), provisioned out of band as a SIM's (CELL_UE_KEY). A DU
reports the UEs it admitted through the RACH with their joiner MACs (F1 v10
Report.joined; static UEs and handover arrivals hold the cell's synthetic MAC
and are not listed); on a joiner's first handover the CU takes its home from
that report (ran.ho_cmd home J:<mac>) and home_of carries it on. A UE that
joins claiming another's MAC only gets contexts it cannot unwrap.

Rig (testrig scripts/m6_joinerkeys.sh): the joiner hands over 2/2 (context
for its MAC, target in slot 99.9 %); holding another MAC's key it refuses
every command and stays 2/2. Static per-cell keys and the re-join after a DU
restart pass on F1 v10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Enable per-cell-key handover for RACH-joined UEs

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Derive joiner UE keys from provisioned MAC identities so RACH joiners can unwrap handover
 contexts.
• Carry joined UE identities in F1 reports and preserve them across subsequent handovers.
• Add key, report, and registry selftests; document joiner rig results and regressions.
Diagram

sequenceDiagram
    actor UE as Joined UE
    participant DU as Source DU
    participant F1 as F1 v10
    participant CU as Controller
    participant Keys as Key derivation
    participant Target as Target DU
    UE->>DU: Join with MAC
    DU->>F1: Report joined identity
    F1->>CU: Deliver joined identity
    CU->>Keys: Derive MAC-bound key
    Keys-->>CU: Joiner UE key
    CU->>DU: Wrapped handover command
    DU->>UE: Relay context
    UE->>Target: Switch after unwrap
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Send a join-time identity event to the CU
  • ➕ Avoids repeating joiner MACs in every periodic F1 report.
  • ➖ Requires event delivery and state reconciliation after missed events or DU restarts.

Recommendation: Prefer the report-based approach: it reuses the DU registry and lets periodic state refresh recover join identities without a separate event lifecycle. Reviewers should verify that the source report's identity remains consistent with the UE selected for handover.

Files changed (7) +147 / -13

Enhancement (4) +63 / -3
main.cppInclude RACH joiners in DU reports +13/-0

Include RACH joiners in DU reports

• Snapshots registry owners under the DU mutex, excludes synthetic MACs used by static UEs and handover arrivals, and adds bounded joiner entries before acquiring the F1 mutex.

examples/cell/main.cpp

F1.hAdd bounded joined-UE identities to F1 v10 reports +28/-2

Add bounded joined-UE identities to F1 v10 reports

• Adds UE ID and MAC entries to reports and updates encoding and decoding with a maximum-entry check. Bumps the F1 wire version from 9 to 10.

src/cell/F1.h

Keys.hDerive MAC-bound joiner UE keys +14/-1

Derive MAC-bound joiner UE keys

• Adds joiner_key using a distinct 'J' derivation domain and the six-byte joiner MAC. Documents out-of-band provisioning through CELL_UE_KEY.

src/cell/Keys.h

UeRegistry.hExpose admitted UE IDs and owner MACs +8/-0

Expose admitted UE IDs and owner MACs

• Adds owners() to return occupied registry positions with their admitting MACs, enabling the DU to identify RACH joiners for reporting.

src/cell/UeRegistry.h

Bug fix (1) +29 / -6
main.cppUse joiner MAC identities when wrapping handover contexts +29/-6

Use joiner MAC identities when wrapping handover contexts

• Extends tracked UE homes to represent either a static identity or a joiner MAC. On a first handover, the CU checks the source report for that MAC, derives its key, and carries the selected home to later handovers.

tools/ran-controller/main.cpp

Tests (1) +27 / -0
cell_m6_selftest.cppCover joiner keys, F1 reports, and registry owners +27/-0

Cover joiner keys, F1 reports, and registry owners

• Tests deterministic and distinct joiner key derivation, joined-report round-tripping and bounds, and registry owner enumeration.

tests/cell_m6_selftest.cpp

Documentation (1) +28 / -4
cell-m6.mdDocument joiner keys and handover rig results +28/-4

Document joiner keys and handover rig results

• Explains MAC-bound joiner keys, F1 reporting, and CU context wrapping. Records successful and wrong-key rig outcomes and removes the resolved joined-UE limitation.

docs/cell-m6.md

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

qodo-free-for-open-source-projects Bot commented Nov 7, 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. Joined UEs lose handover after a CU restart ✓ Resolved
Description
home_of retains a handed-over joiner's MAC only in memory, while the fallback at a later handover
treats an unlisted UE as a static one. After the CU restarts, the target DU cannot list that UE in
Report.joined because it arrived by handover, so the next context is wrapped with a static key the
joiner cannot use.

2. A new joiner receives a departed UE's key ✓ Resolved
Description
home_of.find takes precedence over the source DU's joined entry, even when the map entry belongs
to a previous occupant of that UE ID. After a handed-over joiner departs and its target DU reuses
the ID for another RACH joiner, the CU wraps the new joiner's next context under the departed
joiner's MAC-derived key.

3. Full cells can lose their CU reports ✓ Resolved
Description
encode_report appends up to 193 bytes of joined-UE data without accounting for the CU's 2,048-byte
receive buffer. A valid report with 24 UEs and 120 UE-cell measurements fits before this addition
but exceeds the buffer afterward; the truncated datagram fails decoding, leaving the CU without that
report.


Remediation recommended

4. Some RACH joiners cannot hand over ✓ Resolved
Description
run_du excludes an owner from Report.joined whenever its MAC equals the synthetic MAC for its
assigned UE ID, rather than checking how it was admitted. RACH admission accepts that MAC without
reserving synthetic addresses, so such a joiner is reported as static and receives a context wrapped
under the wrong key.

5. A reused ID can receive the wrong context ✓ Resolved
Description
run_du snapshots joined under mu and only later copies the reported UE view under f1_mu, so
the two parts of one report can describe different occupants of an ID. If a UE is released and
another joins with that ID between the snapshots, the CU can select the first UE's MAC when wrapping
a handover for the second.

Grey Divider

Tip of the day
💡 Did you know, you can hide the parts of a finding you never read, like the evidence or the agent prompt

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

…over command; one F1 datagram bound

The CU inferred homes (an in-memory map lost on a CU restart and stale on
an id reused for another UE; MAC equality, which cannot tell provenance).
Now every DU records a UE's home WHEN it admits it — static (cell, UE), a
RACH joiner's authenticated MAC, or the home its handover command carries
(F1 v10 HoCommand::home, Keys.h HomeId) — and reports the homes of exactly
the UEs a report names, under the same lock (Report.homes, replacing
Report.joined). The CU keeps none of it: each context is wrapped for the
home in the SOURCE DU's current report; no home, no context (never a
guessed static key). UeRegistry is back as on master.

F1 receive buffers on both sides are now kMaxDatagram (8192): a report at
every list bound is 3673 bytes, which the 2048-byte buffers could already
not hold before this PR; the cell_m6 ctest encodes one.

Rig: joiner handover 2/2, another MAC's key refused 2/2, and a crossing
there and back with the CU killed and restarted between the handovers: the
restarted CU wraps the return for the joiner. Static per-cell keys, the
re-join, and a shared-key handover pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@josephnef

Copy link
Copy Markdown
Collaborator Author

Qodo review addressed in 1b3e387 by a design change: homes are now the DUs' record, not inferred by the CU.

  1. Joined UEs lose handover after a CU restart, 2. a new joiner receives a departed UE's key, 4. some RACH joiners can't hand over, 5. a reused ID can receive the wrong context:
    • Every DU now records a UE's home at its admission: a static UE its (cell, UE), a RACH joiner the authenticated MAC its RACH answer names (not MAC equality: provenance, Fix build on Windows #4), and a handover arrival the home its handover command carries (F1 v10 HoCommand::home).
    • The DU reports the homes of exactly the UEs a report names, read under the same f1_mu as that UE list (Report.homes, replacing Report.joined; Remove colored console output on Windows #5).
    • Every admission path writes its own home, so a reused id never keeps another UE's (Log libusb error code in rtw_read #2).
    • The CU keeps no home state at all (home_of removed). It wraps each context for the home in the source DU's current report, so a restarted CU recovers it from the DUs (Small mods to build on android #1). With no home reported there is no context, never a guessed static key.
    • Rig: a joiner crossing there and back with the CU killed and restarted between the two handovers. The restarted CU wrapped the return for the joiner (home J:02cefeaabb01, from DU2's report) and the UE came back.
  2. Full cells can lose their CU reports: both F1 receive buffers are now f1::kMaxDatagram (8192). The cell_m6 ctest encodes a report at every list bound: 3673 bytes, which the 2048-byte buffers could already not hold before this PR.

Also on the rig: joiner handover 2/2, another MAC's key refused 2/2; static per-cell keys (handover, forge, isolate, coordination), the re-join under per-cell keys, and a shared-key handover pass. ctest 95/95.

@josephnef

Copy link
Copy Markdown
Collaborator Author

CI green; all five Qodo findings resolved. The remaining item is the high-level alternative (a join-time identity event to the CU), for which Qodo recommends the report-based approach used here (periodic DU state recovers identities without a separate event lifecycle). Merging with skip-qodo-gate.

@josephnef josephnef added the skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision) label Nov 7, 2026
@josephnef
josephnef merged commit ed922d0 into master Nov 7, 2026
27 of 28 checks passed
@josephnef
josephnef deleted the feat/m6-joiner-keys branch November 7, 2026 11:06
josephnef added a commit that referenced this pull request Nov 7, 2026
… own key (#542)

M6 (#260, EPIC #254): per-UE area credentials. A UE beacon's area tag is
now keyed by the UE's own key, which narrows the area-key exposure item
from #540.

## Why
Under per-cell keys (#540) the area key tagged two things: the neighbour
list, and every UE beacon's area tag, which neighbour DUs check before
counting a UE as heard or forwarding it for DAPS. Every UE holds the
area key, so any UE could make another UE count as heard at a neighbour
DU, or get forged uplinks forwarded for DAPS.

## What
- **`Keys.h`:**
- `ue_area_key(ue_mac)`: `A_u = hex(HMAC(K_u, 'V'))`, derived from the
UE key the UE already holds (no new provisioning);
- `wrap_area_key(du_mac, cell, ue, nonce, key)`: `key ⊕ HMAC(K_du, 'W'
cell ue nonce)`, with a fresh nonce per wrap and its own inverse.
- **F1 v11 `AreaKeys` (type 8):** CU → DU, the other cells' UEs' area
keys, each wrapped under the receiving DU's cell key, since F1 is
authenticated but not encrypted. It replaces the DU's table; `seq` gives
freshness. It is bounded at 192 entries (`kMaxAreaKeys`), which fits
`kMaxDatagram`.
- **CU:** each plan round, for every fresh DU, derives `A_u` for every
UE of the other fresh DUs from the homes they report (#541), wraps and
sends.
- **DU:** unwraps with its cell key into a (cell, UE) → key table. Under
per-cell keys a heard UE beacon counts, as heard and for DAPS
forwarding, only if its area tag verifies with *that UE's* key. One the
DU has no key for isn't counted.
- **UE:** under per-cell keys it tags its beacon's area tag with `A_u`.
- **The area key keeps** only the neighbour list (what a UE checks of
another cell). That residual is documented: measurement provenance only,
no longer heard or DAPS presence.
- **Selftests (cell_m6):** per-UE keys differ; the wrap is its own
inverse, a fresh nonce gives a fresh pad, another DU's key fails;
AreaKeys round-trips, is refused when forged, and a full table fits the
datagram (one over the bound is refused).
- **Docs:** `docs/cell-m6.md`.

## Rig (testrig `scripts/m6_ueareakeys.sh`, per-cell keys, co-channel)
| case | runs | result |
|---|---|---|
| each DU counts the other cell's UE as heard (plan edges 1/1, 2/1) from
per-UE tags | 2 | both edges; `ran_coord_analyze` pass |
| UE1 holds the area key but a wrong UE key (wrong `A_u`) | 2 | DU2
never counts it as heard (no 1/1; 2/1 stays); DU1 still serves it, in
slot 99.7–99.8 % |

**Regression under per-cell keys:**
- DAPS, now forwarded only by the UE's own area key: 0 vs 15 missing;
- source failure mid-crossing: 0 vs 10 missing in the window;
- joiner handover, re-join and a shared-key handover pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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