Repository navigation
cell M6: per-cell keys and the handover security context (802.11r-style) - #540
Conversation
Every cell, DU and UE shared one key: whoever held it could forge any cell. Now (src/cell/Keys.h) every key derives from one master only the CU and provisioning hold: - the cell key K_c tags what controls cell c's UEs (grant map, handover IE, DL body, the UE beacon's own tag, MR, RACH): an insider forges one cell; - the area key K_A tags what another cell checks: the neighbour list and a new area tag on the UE beacon (v3), which a neighbour DU now checks before counting a UE as heard or forwarding it for DAPS (before: any frame with the right SA); - the UE key K_(c,u) of the UE's home identity. The CU (RAN_MASTER_KEY) wraps the target cell's key for the UE alone in every handover command (F1 v9, HoCommand::ctx, 24 bytes: a per-handover pad + a key check); the source DU relays it in its handover IE (v3) without reading it; the UE unwraps it (CELL_UE_KEY), refuses a context not its own (cell.ue.ho_no_context), verifies the target's buffered maps with it, switches key at the switch and back on a fallback. The CU follows a UE's home across handovers. Without CELL_AREA_KEY: one shared key, as before. Rig (testrig scripts/m6_cellkeys.sh, every role's keys derived from one master): handover 2/2 (context in every command, target in slot 99.8 %); a UE holding cell 2's key in cell 1 takes 0 of 879 maps (2/2); a UE holding another UE's key refuses every command and stays (2/2); coordination with "heard" from area tags passes. The M6 suite under per-cell keys passes; a shared-key handover too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PR Summary by QodoIsolate cell keys and pre-stage UE handover security contexts
AI Description
Diagram
High-Level Assessment
Files changed (9)
|
Code Review by Qodo
1.
|
…eys without OpenSSL refused - ran.ho_done drops home_of for the source identity the UE left, so a UE later given that identity never inherits the previous UE's home key. - Without OpenSSL, CELL_AREA_KEY / CELL_UE_KEY are refused at start (as CELL_PSK): a key there would become an empty, silently-accepting authenticator. The CU's message names RAN_MASTER_KEY too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Qodo review addressed in f9f7be6:
ctest 95/95. |
|
CI green; both Qodo bugs resolved. The remaining items are the two high-level alternatives (keep one shared key; a direct CU–UE key channel), for which Qodo recommends this PR's wrapped-context approach. Join-time UE-key provisioning and the area-key scope are listed as open in docs/cell-m6.md. Merging with skip-qodo-gate. |
…cell keys (#541) 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](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… 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>
M6 (#260, EPIC #254): per-cell keys, and the handover security context pre-staged for the UE in the 802.11r style. This closes the "security context" open item.
Why
Every cell, DU and UE shared one key (
CELL_PSK), so whoever held it could forge any cell.What
src/cell/Keys.h(new): every key derives from one master that only the CU and provisioning hold. Keys arehex(HMAC-SHA1(master, info)), used as the HMAC keys exactly asCELL_PSKstrings are.K_c: tags everything that controls cell c's UEs (grant map, handover IE, DL body, the UE beacon's own tag, MR, RACH). A compromised DU or UE forges that one cell.K_A: tags what another cell must check: the neighbour list (measurement provenance) and a new area tag on the UE beacon. A neighbour DU now checks that tag before it counts a UE as heard or forwards it for DAPS; before, it took "heard" from any frame with the right address.K_(c,u): belongs to the UE's home identity.wrap_context:raw(K_t) ⊕ HMAC(K_u, 'X' id target) ‖ HMAC(K_u, 'K' id target raw(K_t))[0..4]. The pad is per handover, and the 4-byte key check lets the UE refuse a context that isn't its own.HoCommand::ctx(24 bytes);ctx;kCellProtoVersion3): an area tag (labelV) after its tag.RAN_MASTER_KEY): wraps the target cell's key for the UE's home key in every command (ran.ho_cmdctx,home).home_offollows a UE across handovers.CELL_AREA_KEY,CELL_UE_KEY):cell.ue.ho_no_context);K_t;CELL_AREA_KEY: one shared key for everything, as before.docs/cell-m6.mdgains a section on per-cell keys and the handover context. The open items now cover joined UEs (no provisioned UE key) and the area-key exposure.Rig (testrig
scripts/m6_cellkeys.sh; the scripts derive every role's keys from one master)ran.ho_done; 0 maps refusedran_coord_analyzepass, both cells' UEs heard by the other DUThe M6 suite under per-cell keys passes: inter-frequency handover and fallback, DAPS, measurement gaps, phase planning, the crossing with source failure, clock fit across a DU restart, and the RACH re-join. One shared-key handover also passes, so the old mode is unchanged.
Open:
🤖 Generated with Claude Code