Skip to content

cell M6: per-cell keys and the handover security context (802.11r-style) - #540

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

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

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

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 are hex(HMAC-SHA1(master, info)), used as the HMAC keys exactly as CELL_PSK strings are.
    • Cell key 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.
    • Area key 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.
    • UE key 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.
  • Formats:
    • F1 v9: HoCommand::ctx (24 bytes);
    • handover IE v3: carries ctx;
    • UE beacon IE v3 (kCellProtoVersion 3): an area tag (label V) after its tag.
  • CU (RAN_MASTER_KEY): wraps the target cell's key for the UE's home key in every command (ran.ho_cmd ctx, home). home_of follows a UE across handovers.
  • DU: relays the context in its handover IE without reading it. It airs the NL under the area key and checks other cells' UE beacons by their area tag.
  • UE (CELL_AREA_KEY, CELL_UE_KEY):
    • unwraps the context when it accepts a command, and refuses one that isn't its own (cell.ue.ho_no_context);
    • verifies the target's buffered maps with K_t;
    • switches key at the switch and back on a fallback (the serving key is read under a lock by the RX thread);
    • takes the NL under the area key and airs the area tag;
    • its RACH uses the serving key.
  • Without CELL_AREA_KEY: one shared key for everything, as before.
  • Selftests:
    • cell_m6: the derivation, the hex round trip, the context (wrong UE / id / target / tampered → refused), and the context through F1 and the IE;
    • cell_node: the area tag accepted only with the area key and the cell tag only with the cell key.
  • Docs: docs/cell-m6.md gains 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)

case runs result
handover under per-cell keys 2 context in every command (home 1:1); switch, target in slot 99.8 %, ran.ho_done; 0 maps refused
UE holding cell 2's key in cell 1 (a cell-2 insider forging cell 1) 2 0 of 879 maps taken, never heard in slot; cell 2 unaffected
UE holding another UE's key 2 every command refused (no context), no switch; DU1 kept it (heard 100 %, in slot 99.8 %)
coordination with "heard" from area tags 1 ran_coord_analyze pass, both cells' UEs heard by the other DU

The 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:

  • a RACH-joined UE has no provisioned UE key, so under per-cell keys it doesn't hand over;
  • an area-key holder can fake measurement provenance and heard/DAPS presence, but never control.

🤖 Generated with Claude Code

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>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Isolate cell keys and pre-stage UE handover security contexts

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Derive separate cell, area, and UE keys to limit the reach of compromised credentials.
• Carry a UE-specific target key through handover, enabling authenticated target maps and key
 switching.
• Authenticate cross-cell presence, retain shared-key mode, and document tests and remaining
 provisioning gaps.
Diagram

sequenceDiagram
    participant Keys as Key derivation
    participant CU as Controller
    participant Target as Target DU
    participant Source as Source DU
    participant UE as UE
    CU->>Keys: Derive target and home keys
    Keys-->>CU: Return keys
    CU->>Target: Stage handover context
    CU->>Source: Send handover context
    Source->>UE: Relay authenticated handover IE
    Target->>UE: Send target grant maps
    UE->>UE: Unwrap, verify, switch key
    UE->>Target: Send area-tagged beacon
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep one shared cell key
  • ➕ Requires no new provisioning or handover context.
  • ➖ A holder can forge traffic for every cell, the problem this PR addresses.
2. Establish a direct CU–UE key channel
  • ➕ Could deliver target keys without relaying a wrapped context through DU handover messages.
  • ➖ Requires a new authenticated transport and UE protocol rather than using the existing F1 and handover IE paths.

Recommendation: Use the PR's wrapped-context approach: it limits cell-control credentials without adding a CU–UE transport and preserves shared-key operation. Treat join-time UE-key provisioning and the forgery scope of the shared area key as follow-up security work.

Files changed (9) +485 / -32

Enhancement (6) +340 / -25
cell.hAdd an area tag to version 3 UE beacons +17/-8

Add an area tag to version 3 UE beacons

• Extends UE beacons with a second tag and lets parsing verify either the serving-cell tag or the area tag for cross-cell presence checks.

examples/cell/cell.h

main.cppApply cell, area, and UE keys across DU and UE operation +100/-12

Apply cell, area, and UE keys across DU and UE operation

• The DU authenticates neighboring UE beacons with the area key, signs neighbor lists with it, and relays opaque handover contexts. The UE unwraps a context before accepting handover, verifies buffered target maps with the target key, switches keys at handover, and restores the source key on fallback.

examples/cell/main.cpp

F1.hCarry handover contexts in F1 version 9 +4/-1

Carry handover contexts in F1 version 9

• Adds the 24-byte context to handover command encoding and decoding and advances the F1 protocol version.

src/cell/F1.h

HoCommand.hCarry contexts through handover commands and version 3 IEs +11/-2

Carry contexts through handover commands and version 3 IEs

• Adds context fields to the command and over-the-air IE, including them in IE encoding, decoding, CRC, and tag coverage.

src/cell/HoCommand.h

Keys.hDefine key derivation and UE-specific context wrapping +175/-0

Define key derivation and UE-specific context wrapping

• Introduces master-derived area, cell, and home-UE keys, hex conversion, and a 24-byte wrapped target key with a key check. Documents credential ownership and the shared-key fallback.

src/cell/Keys.h

main.cppWrap target keys for each UE's home identity +33/-2

Wrap target keys for each UE's home identity

• Uses the configured master key to create handover contexts and tracks a UE's home identity across handovers. Clears provisional home mappings on cancellation or timeout and logs whether commands contain a context.

tools/ran-controller/main.cpp

Tests (2) +77 / -5
cell_m6_selftest.cppTest key derivation and handover context integrity +63/-1

Test key derivation and handover context integrity

• Tests distinct derived keys, hex conversion, context rejection for wrong credentials or tampering, and context preservation through F1 and handover IE serialization.

tests/cell_m6_selftest.cpp

cell_node_selftest.cppTest independent cell and area beacon tags +14/-4

Test independent cell and area beacon tags

• Adjusts beacon-format assertions for version 3 and verifies that each tag accepts its corresponding key but rejects the other.

tests/cell_node_selftest.cpp

Documentation (1) +68 / -2
cell-m6.mdDocument the key model and handover security context +68/-2

Document the key model and handover security context

• Explains key ownership, context delivery, keyed rig results, and shared-key compatibility. Replaces the resolved security-context open item with join-time provisioning and area-key exposure limitations.

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


Remediation recommended

1. Reused identities block later handovers ✓ Resolved
Description
home_of retains a mapping for a UE's former serving identity after that UE successfully moves to
another cell. When a different UE later receives the vacated identity, the CU wraps its handover
context using the previous UE's home key, so the new UE refuses the command.

2. Area-tag checks silently disappear ✓ Resolved
Description
area_auth() converts a configured CELL_AREA_KEY into an empty authenticator on builds without
OpenSSL, while auth_ok() checks only CELL_PSK for that build. If the area key is set but
CELL_PSK is empty, startup succeeds and the new cross-cell beacon and neighbour-list paths accept
unauthenticated frames.

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

…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>
@josephnef

Copy link
Copy Markdown
Collaborator Author

Qodo review addressed in f9f7be6:

  1. Reused identities block later handovers: at ran.ho_done the CU now drops home_of for the source identity the UE left, so a UE given that identity later never inherits the previous UE's home key. The target mapping stays while the UE holds it, and the abort paths still drop the provisional one. Rig re-check under per-cell keys: a crossing there and back (1 → 2 → 1) wraps both commands for the UE's own home (home 1:1) and passes.
  2. Area-tag checks silently disappear without OpenSSL: CELL_AREA_KEY and CELL_UE_KEY are now refused at start on a build without OpenSSL, as CELL_PSK is, so a set key can never become an empty authenticator. The CU already exits for any HMAC key there; its message now names RAN_MASTER_KEY too.

ctest 95/95.

@josephnef

Copy link
Copy Markdown
Collaborator Author

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.

@josephnef josephnef added the skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision) label Nov 7, 2026
@josephnef
josephnef merged commit 6bf572d into master Nov 7, 2026
27 of 28 checks passed
@josephnef
josephnef deleted the feat/m6-cell-keys branch November 7, 2026 07:56
josephnef added a commit that referenced this pull request Nov 7, 2026
…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>
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