Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 48 additions & 13 deletions docs/cell-m6.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,13 +411,12 @@ heard in the new grid.
instance, as on a handover switch. That is the DL HARQ sequence space, the
MR generation (the DU checks it against its own new count), the neighbour
list and gap parking, and any handover IE the old instance aired.
- **The exposure:** grant maps carry no tag (they never did), so the epoch
rule orders maps; it does not authenticate them. A forged map could
already redirect a UE's slots. The restart path widens that: within the
re-attach window after a clock step, a forged lower-epoch map due near the
beacon's count also resets the UE's per-DU state (HARQ, MR generation,
neighbours, pending handover IEs). Grant-map authentication is open
(below).
- **Authentication:** grant maps carry the cell key's tag (v2,
`src/cell/GrantMap.h`), so with `CELL_PSK` a forged map is refused before
either rule sees it. It can neither move a UE's slots nor, through the
restart path, reset its per-DU state. What remains is a replayed genuine
map. A pre-restart one is due far from the new count and refused; one
from the new instance is that DU's own.

**Rig result** (testrig `scripts/m6_fitstep.sh`, restart case, 3 runs, all
pass).
Expand Down Expand Up @@ -447,6 +446,40 @@ pass).
position showed as DL out of slot (0.05 in slot); the frames themselves
were right. It now uses the grant in force.

## Grant-map authentication, and the suite keyed

The grant map IE (v2, `src/cell/GrantMap.h`) carries the cell key's tag
after its CRC, as the NL, MR and handover IEs do.
- **With `CELL_PSK`:** a UE takes only authentic maps. A forged map neither
moves its slots nor, through the restart path, resets its grant state.
- **Without a key:** an unauthenticated development cell, or a passive
witness, checks the structure only.
- **v1 maps** (untagged) are refused.
- **Counters:** the UE counts serving maps taken and refused (`gm_ok`,
`gm_refused` in `cell.ue.done`; the first refusal logs
`cell.ue.gm_refused`).

**On air** (testrig `scripts/m6_gmauth.sh`, 2 runs, pass). Every role holds
the cell key except UE2, which holds another. To UE2 each genuine map is a
map under the wrong key, as a forged one is to a keyed UE.

| | maps taken | maps refused | heard in slot by its DU |
|---|---|---|---|
| UE1 (cell key) | 586–587 | 0 | 99.8 % |
| UE2 (another key) | 0 | 586–587 | never |

**The M6 suite re-run keyed** (all roles on one `CELL_PSK`; all pass). No
keyed UE refused a map, apart from UE2 above.

| test | runs | result |
|---|---|---|
| same-channel handover + fallback | 2 + 1 | interruption 1 sf; fallback 10 sf |
| inter-frequency handover | 2 | interruption 2–3 sf, retune 23.6 ms |
| DAPS (source killed mid-crossing) | 2 + 2 | missing uplinks 0 / 0, against 11 / 16 without |
| measurement-gap handover | 2 | deaf 54.1 ms, A3 fired at defaults |
| phase planning | 2 | DU2 at 51200, both UEs 57 gaps heard |
| clock-fit jump + DU restart | 1 + 1 | restart followed and re-attached; transient ignored |

## DAPS: the duplicated uplink (same channel)

**Common sequence.** A UE uplinks once per superframe. Every DU that hears
Expand Down Expand Up @@ -547,10 +580,6 @@ superframe when there is nothing to serve.
running ones, which never move. With three or more cells on different
channels, the 26 ms retune leaves no layout where every pair can measure
both ways. Re-phasing live cells, or longer superframes, is open.
- **Grant-map authentication:** the grant map IE has no tag, unlike the
NL, MR and handover IEs. A tag keyed per cell (as `CELL_PSK`), checked
before any `offer`, would close the forged-map exposure, including the
restart path. It is a wire change for the DU, UE and witness.
- **A DU restart under RACH-joined UEs:** re-attachment (above) is for the
rig's static UEs, whose id the restarted DU grants again. A UE that joined
through the RACH holds an id the new DU instance does not know; re-joining
Expand All @@ -560,6 +589,12 @@ superframe when there is nothing to serve.
two cells. No A→B path-loss ramp has been run, so none of these numbers
validates a crossing. An emulated crossing (the source DU ramping its TX
power, which its beacons follow) is the way to get one.
- **Security context:** the cells share one key on the rig (`CELL_PSK`).
Pre-staging a per-cell context (802.11r-style) is open.
- **Security context:** the cells share one key (`CELL_PSK`). Pre-staging a
per-cell context (802.11r-style) is open.
- **Keyed rig runs only from the grant-map authentication on:** the
handover, DAPS, inter-frequency, measurement-gap, phase-planning and
clock-fit rig results above were run without `CELL_PSK`, an
unauthenticated development cell. Their tags were zeros and were not
checked; F1 was keyed throughout. The testrig scripts now give every cell
role one key. The suite re-run keyed is in the next section.
- **EVM:** not in the measurement reports.
6 changes: 3 additions & 3 deletions examples/cell/cell.h
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,16 @@ inline std::vector<uint8_t> beacon_head(const uint8_t sa[6], int interval_tu) {
}
} // namespace detail

// The DU beacon: header + the grant map IE (+ the RACH responses IE when
// The DU beacon: header + the grant map IE (tagged with `mac`, the cell key) (+ the RACH responses IE when
// `rach_rsp` — an encode_rach_responses body — has any entry). Empty if
// either cannot encode.
inline std::vector<uint8_t> build_du_beacon(const GrantMap &m,
inline std::vector<uint8_t> build_du_beacon(const GrantMap &m, const devourer::cell::Mac &mac,
const std::vector<uint8_t> &rach_rsp = {},
const std::vector<uint8_t> &extra_ies = {}) {
uint8_t sa[6];
cell_mac(sa, m.cell_id, 0);
auto f = detail::beacon_head(sa, m.period_tu);
const auto ie = devourer::cell::encode_grant_ie(m);
const auto ie = devourer::cell::encode_grant_ie(m, mac); /* tagged with the cell key */
if (ie.empty())
return {};
f.insert(f.end(), ie.begin(), ie.end());
Expand Down
37 changes: 28 additions & 9 deletions examples/cell/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ bool du_budget_ok(const Cfg &c, const std::vector<uint16_t> &ues) {
rar[1] = 'B';
rar[2] = static_cast<uint8_t>(devourer::cell::kRachMaxPerBeacon);
}
const int64_t bcn_need = 22 + ofdm_airtime_us(build_du_beacon(probe, rar).size() + 4, 6);
const int64_t bcn_need = 22 + ofdm_airtime_us(build_du_beacon(probe, cell_auth(c), rar).size() + 4, 6);
SlotBudget dl;
dl.mpdu_len = build_dl_frame(c.cell, 1, 0, 0, pad).size() + 4;
dl.acked = true;
Expand Down Expand Up @@ -773,7 +773,7 @@ int run_du(IRadio *dev, const Cfg &c) {
}
emit(l);
}
auto bcn = build_du_beacon(map);
auto bcn = build_du_beacon(map, auth);
if (bcn.empty() || !dev->StartBeacon(bcn.data(), bcn.size(), c.tu)) {
fprintf(stderr, "StartBeacon failed\n");
return 1;
Expand Down Expand Up @@ -2017,7 +2017,7 @@ int run_du(IRadio *dev, const Cfg &c) {
: ho_eff ? *ho_eff
: static_cast<uint64_t>(sf + 3);
}
const auto nb = build_du_beacon(next, rar_now, ho_ies);
const auto nb = build_du_beacon(next, auth, rar_now, ho_ies);
const bool ok = !nb.empty() && dev->UpdateBeaconPayload(nb.data(), nb.size());
beacon_fail_run = ok ? 0 : beacon_fail_run + 1;
if (ok) {
Expand Down Expand Up @@ -2213,6 +2213,9 @@ int run_ue(IRadio *dev, const Cfg &c) {
double dl_snr_sum = 0;
uint64_t dl_snr_n = 0;
uint64_t dl_new = 0, dl_dup = 0;
/* Serving beacons whose grant map is intact but not authentic (another
* key, or none): refused (mu). */
uint64_t gm_ok = 0, gm_refused = 0;
const int64_t P = static_cast<int64_t>(c.tu) * devourer::cell::kTuUs;
/* The capacity grid (same env as the DU): positions never move. */
const std::optional<SlotGrid> grid = grid_for(c);
Expand Down Expand Up @@ -2337,7 +2340,7 @@ int run_ue(IRadio *dev, const Cfg &c) {
* switch (they do not feed the clock: that is the source's until
* the switch, then shifted, not re-acquired). */
if (rxr_target && std::memcmp(p.Data.data() + 10, tgt_du_mac, 6) == 0)
if (auto m = devourer::cell::find_grant_map(p.Data.data(), p.Data.size()))
if (auto m = devourer::cell::find_grant_map(p.Data.data(), p.Data.size(), auth))
rxr_target->offer(*m);
return;
}
Expand All @@ -2364,7 +2367,18 @@ int run_ue(IRadio *dev, const Cfg &c) {
reattach_armed_steps = sync.Steps();
reattach_until_ms = ue_ms() + reattach_sf * P / 1000;
}
if (auto m = devourer::cell::find_grant_map(p.Data.data(), p.Data.size())) {
auto m = devourer::cell::find_grant_map(p.Data.data(), p.Data.size(), auth);
if (m) {
++gm_ok;
} else if (auth && devourer::cell::find_grant_map(p.Data.data(), p.Data.size(), {})) {
if (gm_refused++ == 0) {
char l[120];
snprintf(l, sizeof l, "{\"ev\":\"cell.ue.gm_refused\",\"ue\":%d,\"cell\":%u}",
me_a.load(), cell_a.load());
emit(l);
}
}
if (m) {
const uint32_t old_epoch = rxr.newest_epoch();
if (!rxr.offer(*m) && ue_ms() < reattach_until_ms &&
rxr.offer_after_restart(*m, static_cast<uint64_t>(*eg) / static_cast<uint64_t>(P))) {
Expand Down Expand Up @@ -3043,18 +3057,23 @@ int run_ue(IRadio *dev, const Cfg &c) {
dev->StopRxLoop();
rx.join();
dev->ClearAckResponder();
char l[200];
char l[300];
snprintf(l, sizeof l,
"{\"ev\":\"cell.ue.done\",\"ue\":%u,\"steps\":%ld,\"step_fails\":%ld,"
"\"listen_from_sf\":%lld,\"listen_to_sf\":%lld,\"dl_new\":%llu,\"dl_dup\":%llu}",
"\"listen_from_sf\":%lld,\"listen_to_sf\":%lld,\"dl_new\":%llu,\"dl_dup\":%llu,"
"\"keyed\":%d,\"gm_ok\":%llu,\"gm_refused\":%llu}",
me_a.load(), steps, step_fails, (long long)listen_from, (long long)listen_to,
(unsigned long long)dl_new, (unsigned long long)dl_dup);
(unsigned long long)dl_new, (unsigned long long)dl_dup, auth ? 1 : 0,
(unsigned long long)gm_ok, (unsigned long long)gm_refused);
emit(l);
return 0;
}

// ---------------------------------------------------------------------------
int run_witness(IRadio *dev, const Cfg &c) {
/* With CELL_PSK, a beacon's grant map counts only if authentic; without
* one the witness is a passive recorder (structure only). */
const CellMac wauth = cell_auth(c);
/* CELL_WITNESS_CELLS [CELL_ID]: the cells to record (M5: two co-channel
* cells on one receiver clock); each line names its cell. */
std::vector<uint16_t> cells;
Expand Down Expand Up @@ -3091,7 +3110,7 @@ int run_witness(IRadio *dev, const Cfg &c) {
if (st > 0) {
if (auto ub = parse_ue_beacon(d, p.Data.size(), cell))
ep = ub->epoch;
} else if (auto m = devourer::cell::find_grant_map(d, p.Data.size())) {
} else if (auto m = devourer::cell::find_grant_map(d, p.Data.size(), wauth)) {
ep = m->epoch;
}
snprintf(l, sizeof l,
Expand Down
79 changes: 62 additions & 17 deletions src/cell/GrantMap.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,25 @@
* (decode does not; that is UE state, see GrantMapReceiver).
*
* Wire format (little-endian), inside IE 221 (vendor) with OUI kGrantOui:
* 'G' 'M' ver(1)=1 flags(1) cell_id(2) epoch(4) effective_sf(8)
* 'G' 'M' ver(1)=2 flags(1) cell_id(2) epoch(4) effective_sf(8)
* period_tu(2) n(1) n x {ue(2) dl_slot(1) ul_slot(1) rate_mbps(1)
* tx_pwr_qdb(1, signed) ta_us(2, signed)} crc16(2)
* tx_pwr_qdb(1, signed) ta_us(2, signed)} crc16(2) tag(8)
* The CRC (CCITT-FALSE, over 'G' through the last grant) detects a torn or
* corrupted body from one frame. It is not authentication: integrity against
* an active attacker (EPIC #254's envelope item) is out of M1's scope.
* corrupted body from one frame. The tag (v2, M6 #260) is the cell key's:
* the first 8 bytes of mac('G' || 'G' 'M' .. crc), as the scheduling IEs'
* (NL, MR, handover): a receiver with the key refuses a map that is not the
* DU's, so a forged map neither moves a UE's slots nor, after a DU restart,
* resets its grant state. Without a key (an unauthenticated development
* cell, a passive witness) the tag is zeros and only the structure is
* checked. Replay of a genuine map stays the epoch's job (GrantMapReceiver).
* Pure header; cell_selftest covers encode/decode round trips and rejects. */
#ifndef DEVOURER_CELL_GRANTMAP_H
#define DEVOURER_CELL_GRANTMAP_H

#include <cstddef>
#include <cstdint>
#include <cstring>
#include <functional>
#include <optional>
#include <vector>

Expand All @@ -30,8 +37,14 @@
namespace devourer::cell {

constexpr uint8_t kGrantOui[3] = {0x57, 0x42, 0x75}; /* devourer test OUI */
constexpr uint8_t kGrantVersion = 1;
constexpr size_t kGrantMax = kMaxUesPerCell; /* 3+21+24*8+2 = 218 <= 255 IE bytes */
/* v2: the cell key's tag after the CRC (M6, #260). */
constexpr uint8_t kGrantVersion = 2;
constexpr size_t kGrantTagLen = 8;
constexpr size_t kGrantMax = kMaxUesPerCell; /* 3+21+24*8+2+8 = 226 <= 255 IE bytes */

/* HMAC-SHA1 keyed by the cell PSK: data -> 20-byte tag. False on failure.
* Empty = an unauthenticated cell: the tag is zeros and is not checked. */
using Mac = std::function<bool(const uint8_t *data, size_t len, uint8_t out[20])>;
/* GrantMap::flags. kGrantAutonomous (M4, #258): the DU has suspended its
* COORDINATED schedule (it lost sync with the fabric, see src/cell/SyncHealth.h).
* Its UEs fall back to carrier sense and a random uplink-beacon backoff, as
Expand Down Expand Up @@ -88,10 +101,26 @@ inline uint64_t get(const uint8_t *p, int n) {
}
constexpr size_t kHdrLen = 2 + 1 + 1 + 2 + 4 + 8 + 2 + 1; /* 'G'..n */
constexpr size_t kGrantLen = 2 + 1 + 1 + 1 + 1 + 2;
/* The first kGrantTagLen bytes of mac('G' || body), body = everything up to
* and including the CRC; zeros without a key. */
inline bool grant_tag(const Mac &mac, const uint8_t *body, size_t n, uint8_t out[kGrantTagLen]) {
if (!mac) {
std::memset(out, 0, kGrantTagLen);
return true;
}
std::vector<uint8_t> m = {'G'};
m.insert(m.end(), body, body + n);
uint8_t full[20];
if (!mac(m.data(), m.size(), full))
return false;
std::memcpy(out, full, kGrantTagLen);
return true;
}
} /* namespace detail */

/* The full IE (221, len, OUI, body). Empty when there are > kGrantMax grants. */
inline std::vector<uint8_t> encode_grant_ie(const GrantMap &m) {
/* The full IE (221, len, OUI, body, crc, tag). Empty when there are >
* kGrantMax grants or the Mac fails. */
inline std::vector<uint8_t> encode_grant_ie(const GrantMap &m, const Mac &mac) {
if (m.grants.size() > kGrantMax)
return {};
std::vector<uint8_t> body;
Expand All @@ -113,31 +142,46 @@ inline std::vector<uint8_t> encode_grant_ie(const GrantMap &m) {
detail::put(body, static_cast<uint16_t>(g.ta_us), 2);
}
detail::put(body, detail::crc16_ccitt(body.data(), body.size()), 2);
uint8_t tag[kGrantTagLen];
if (!detail::grant_tag(mac, body.data(), body.size(), tag))
return {};
body.insert(body.end(), tag, tag + kGrantTagLen);
std::vector<uint8_t> ie = {221, static_cast<uint8_t>(3 + body.size()),
kGrantOui[0], kGrantOui[1], kGrantOui[2]};
ie.insert(ie.end(), body.begin(), body.end());
return ie;
}

/* Decode one IE (starting at its 221 tag). nullopt on anything but an intact
* version-1 grant map: wrong tag/OUI/magic/version, a length that disagrees
* with n, or a CRC mismatch. */
inline std::optional<GrantMap> decode_grant_ie(const uint8_t *ie, size_t len) {
/* Decode one IE (starting at its 221 tag). nullopt on anything but an intact,
* authentic grant map: wrong tag/OUI/magic/version, a length that disagrees
* with n, a CRC mismatch, or (with a key) a tag mismatch — a forged map is
* inert. */
inline std::optional<GrantMap> decode_grant_ie(const uint8_t *ie, size_t len, const Mac &mac) {
if (ie == nullptr || len < 2 || ie[0] != 221 || static_cast<size_t>(ie[1]) + 2 > len)
return std::nullopt;
const size_t ie_len = ie[1];
if (ie_len < 3 + detail::kHdrLen + 2 || ie[2] != kGrantOui[0] ||
if (ie_len < 3 + detail::kHdrLen + 2 + kGrantTagLen || ie[2] != kGrantOui[0] ||
ie[3] != kGrantOui[1] || ie[4] != kGrantOui[2])
return std::nullopt;
const uint8_t *b = ie + 5;
const size_t blen = ie_len - 3;
const size_t blen = ie_len - 3 - kGrantTagLen; /* up to and including the CRC */
if (b[0] != 'G' || b[1] != 'M' || b[2] != kGrantVersion)
return std::nullopt;
const size_t n = b[detail::kHdrLen - 1];
if (n > kGrantMax || blen != detail::kHdrLen + n * detail::kGrantLen + 2)
return std::nullopt;
if (detail::get(b + blen - 2, 2) != detail::crc16_ccitt(b, blen - 2))
return std::nullopt;
if (mac) {
uint8_t want[kGrantTagLen];
if (!detail::grant_tag(mac, b, blen, want))
return std::nullopt;
uint8_t d = 0;
for (size_t i = 0; i < kGrantTagLen; ++i)
d |= static_cast<uint8_t>(want[i] ^ b[blen + i]);
if (d != 0)
return std::nullopt;
}
GrantMap m;
m.flags = b[3];
m.cell_id = static_cast<uint16_t>(detail::get(b + 4, 2));
Expand All @@ -159,8 +203,9 @@ inline std::optional<GrantMap> decode_grant_ie(const uint8_t *ie, size_t len) {
}

/* Find and decode the grant IE in a beacon MPDU (24-byte header, 12 fixed
* bytes, then IEs). nullopt when the beacon carries none or it is damaged. */
inline std::optional<GrantMap> find_grant_map(const uint8_t *mpdu, size_t len) {
* bytes, then IEs). nullopt when the beacon carries none, it is damaged, or
* (with a key) it is not authentic. */
inline std::optional<GrantMap> find_grant_map(const uint8_t *mpdu, size_t len, const Mac &mac) {
if (mpdu == nullptr || len < 36 || mpdu[0] != 0x80)
return std::nullopt;
size_t off = 36;
Expand All @@ -171,7 +216,7 @@ inline std::optional<GrantMap> find_grant_map(const uint8_t *mpdu, size_t len) {
if (mpdu[off] == 221 && l >= 5 && mpdu[off + 2] == kGrantOui[0] &&
mpdu[off + 3] == kGrantOui[1] && mpdu[off + 4] == kGrantOui[2] &&
mpdu[off + 5] == 'G' && mpdu[off + 6] == 'M')
return decode_grant_ie(mpdu + off, len - off);
return decode_grant_ie(mpdu + off, len - off, mac);
off += 2 + l;
}
return std::nullopt;
Expand Down
3 changes: 1 addition & 2 deletions src/cell/Rach.h
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@

namespace devourer::cell {

/* HMAC-SHA1 keyed by the cell PSK: data -> 20-byte tag. False on failure. */
using Mac = std::function<bool(const uint8_t *data, size_t len, uint8_t out[20])>;
/* Mac: cell/GrantMap.h (HMAC-SHA1 keyed by the cell PSK). */

constexpr size_t kRachTagLen = 8;
/* msgA envelope version: an unknown version is refused before anything else
Expand Down
7 changes: 4 additions & 3 deletions tests/cell_node_selftest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,15 @@ int main() {
g.epoch = 9;
g.period_tu = 100;
g.grants = {{1, 1, 4, 6, 0, -3}, {2, 2, 5, 6, 0, 7}};
const auto b = build_du_beacon(g);
const devourer::cell::Mac none;
const auto b = build_du_beacon(g, none);
CHECK(!b.empty() && b[0] == 0x80 && cell_station(b.data() + 10, 5) == 0);
CHECK(b[32] == 100 && b[33] == 0); /* beacon interval */
auto d = devourer::cell::find_grant_map(b.data(), b.size());
auto d = devourer::cell::find_grant_map(b.data(), b.size(), none);
CHECK(d.has_value() && *d == g);
GrantMap big = g;
big.grants.resize(devourer::cell::kGrantMax + 1);
CHECK(build_du_beacon(big).empty());
CHECK(build_du_beacon(big, none).empty());
}

/* UE beacon round trip and rejects. */
Expand Down
Loading
Loading