Protocol research#
Abstract#
The on-air behavior of a phone-driven ultra-wideband (UWB) proximity unlock. The phone runs the protocol exchange over Bluetooth LE, then the two run a UWB secure ranging exchange that lets the reader measure distance before it unlocks. Scope: what reaches the air, and what must hold before ranging can begin.
The work began from a specific failure: NFC tap-to-unlock worked, the door would not open on approach. Tap working means BLE transport, provisioning and credentials are healthy, so the fault is UWB-specific. The fallback to NFC when UWB is disturbed is by design.
Educational, research, and study use only. These notes exist for learning, independent research, and personal study of how BLE + UWB secure ranging systems behave on the air. They are not an invitation to bypass, defeat, or gain unauthorized access to any access control system. Only work with hardware you own or have explicit, written authorization to test. This report is not affiliated with, endorsed by, or speaking for any company, standards body, or specification owner mentioned or alluded to. All protocol specifications, standards, trademarks, product names, and other intellectual property referenced or implied remain the property of their respective owners, along with every right, license, and disclaimer attached to them.
1. System under study#
- The phone is the UWB initiator (the side sending poll packets). It carries a U-class UWB chip and runs iOS 26 or later.
- The reader is the fixed lock, the UWB responder with a single anchor. The radio is a bare DW3000-family part (a DW3110) on an nRF5340 with no UWB coprocessor, so MAC, PHY and STS are all host firmware.
- All protocol-level traffic rides on BLE. UWB carries no application data, only the distance measurement. Unlock policy is the reader's decision: in the configuration studied it unlocks at 150 cm or closer and relocks past 180 cm, 30 cm of hysteresis.
2. The full transaction, top to bottom#
Everything above the line is in the clear or authentication-protected; everything below is encrypted under BleSK.
1 Initiate Access Protocol phone → reader (clear)
2-9 Access/authentication exchange (AUTH0 [± cert, AUTH1])
10-11 EXCHANGE (carries the zero-length "URSK-ready" trigger, tag 0x98)
------ everything below encrypted under BleSK ---------------------------------
12 Reader: "Access Protocol Completed" reader → phone
13 Time Sync phone → reader (section 5)
14 (optional) Initiate Ranging Session phone → reader
15-18 Ranging Setup M1 / M2 / M3 / M4 (section 6)
... secure UWB ranging; distance computed at the reader ... (section 7)
19-22 Suspend / Resume (either side) (section 8)
23 Reader Status Changed → unlock/lock decision reader → phoneOnce "Access Protocol Completed" passes, both sides drop the authentication session keys. BleSK persists and guards all ranging control traffic from there on (time sync, M1-M4, suspend/resume).
3. Discovery and connection (all sniffable)#
The reader beacons ADV_IND on LE 1M (optionally coded S=2), carrying service data under 16-bit UUID 0xFFF2. Fields that matter:
- Byte 7, bit 7 is the "BLE + UWB flow supported" flag: a UWB-capable build sets it, a control-only build leaves it 0, and at 0 the phone will not range. Rest of that byte: bit 6 BLE-only flow, bits 4:3 notification/error, bits 2:0 advertisement version.
- Byte 8 is TX power. Bytes 9-18 are a truncated reader group identifier plus a sub-identifier.
- Bytes 19-22 are the dynamic tag expiry as Unix time, or
0xFFFFFFFFif the reader has no clock. Byte 23 is reserved. - Bytes 24-30 are the dynamic tag: the first 7 octets of
AES-128(GroupResolvingKey, pad ‖ AdvA ‖ expiry).
The phone scans passively and identifies its own readers by recomputing the dynamic tag with each group resolving key it holds; on a match it sends CONNECT_IND. A reader gone stale or reprovisioned is invisible to the phone even while advertising, because the tag no longer resolves.
The phone reads a characteristic returning the dynamic SPSM (in 0x0080-0x00FF), the supported protocol versions (v1.0 = 0x0100), and a feature bitmap (bit 0 time sync procedure 0, bit 1 procedure 1, bit 2 LE coded PHY), then writes back the version and bitmap it selected. Both version values fold into the secure channel KDF, so a downgrade makes both sides derive mismatched keys and the exchange fails. Everything then runs over an L2CAP connection-oriented channel on that SPSM. The reader is GAP peripheral and GATT server, the phone central and client; BLE pairing is not required.
The fastest sniffing sanity check is 0xFFF2 service data with byte 7 bit 7 set: it confirms the reader side of discovery is alive within seconds.
4. Authentication, and the origin of the ranging key#
The phone sends Initiate Access Protocol in the clear, and the reader runs one of two paths:
- The fast path is symmetric: from a cached long-term key
Kpersistentit produces a 160-byte block of derived keys. The target key is URSK, at offset 128, 32 bytes long. - The standard path is ECDH: AUTH0/AUTH1 with certificates, deriving a fresh 160-byte block (URSK still at offset 128) and caching a new
Kpersistent. If the fast-path check fails, it falls through to the standard path.
Two properties are load-bearing:
- The URSK never goes out on the wire. Both sides derive it independently from the authentication, tying UWB ranging to a credential check that passed. It cannot be lifted from sniffed BLE traffic and injected or replayed.
- After authentication, the reader sends an EXCHANGE command with a zero-length trigger (observed tagged
0x98) that instructs the phone to load the URSK into its UWB chip. If the phone never sees that trigger, it responds withURSK_Unavailable, leaving tap-style behavior and no ranging. A build that authenticates cleanly but never emits0x98fails silently.
Labels: Kpersistent is the long-term symmetric seed, BleSK guards the ranging control messages, URSK is the 32-byte ranging root, and transaction_identifier is a per-transaction value both sides feed into the crypto.
5. Time sync (using a BLE event to align the UWB clocks)#
A battery-powered reader cannot leave its UWB receiver on continuously, so it must know when the phone will transmit. The mechanism maps one BLE event both sides observe into each side's own UWB clock.
The reference instant is the anchor point of a BLE connection event: the on-air boundary where the phone (central) transmits and the reader catches the first packet, so both radios observe the same moment. Two procedures select the event:
- Procedure 0 happens at connection. The phone timestamps the first connection event, sets
DeviceEventCount = 0, and sends the time sync unprompted. It lands before any session key exists, so it is readable in the clear: the only time sync that can be snooped. - Procedure 1 is a resync. The reader initiates an
LE Set PHY(LL_PHY_REQ), and the event carrying the phone'sLL_PHY_UPDATE_INDbecomes the reference. The reader uses this when it judges the phone's clock has drifted more than about 1 ms (for example, the last sync is older than ~150 s, or there has been no valid measurement within 10 s of it).
Each side reads its own UWB clock at that instant. The phone reports its value as UWB_Device_Time; the reader keeps its own as UWBVehicleTime and never transmits it. How each side maps the BLE anchor into its UWB clock is implementation-defined; section 9 covers it.
The time sync message (phone to reader) carries: device event count (8 B), UWB device time (8 B, µs resolution, 64-bit, scoped to the session and free to start at any value), UWB device time uncertainty (1 B, log-encoded from 1 µs up to about an hour), a clock-skew available flag, device max PPM (2 B), a success field (0/1/2), and a retry delay (2 B).
same event mapped both sides: SyncOffset = UWB_Device_Time − UWBVehicleTime
different events: SyncOffset = UWB_Device_Time
+ (VehicleEventCount − DeviceEventCount) × ConnectionInterval
− UWBVehicleTime
reader opens block-i RX window at:
local_listen(i) = UWB_Time0 + i × T_Block − SyncOffset [µs, reader UWB clock]
± ( 2^(Uncertainty/8) µs + skew·elapsed + reader mapping error )UWB_Time0 is in µs (the same 64-bit counter as UWB_Device_Time), and T_Block = N_RAN × 96 ms. The phone's clock becomes undefined if the session suspends or dies, or after 30 s with neither UWB nor BLE present. At least one successful time sync is required before M1-M4. Once the first UWB packet lands everything resyncs in-band, far tighter than BLE could manage, so the BLE sync need only place the listen window within about 1 ms.
6. Ranging setup (M1-M4)#
These four messages ride the BleSK channel as ranging service messages (message IDs: M1=0, M2=1, M3=2, M4=3, suspend=4/5, resume=6/7). Either side can start the negotiation, but M1 must not be sent while a session is already live.
| Msg | Dir | Carries |
|---|---|---|
| M1 | R→P | UWB config id(s), pulse shape combination, channel bitmask, UWB Session Id |
| M2 | P→R | selected config/pulse/channel + SYNC code bitmask, RAN Multiplier, slot bitmask, hopping bitmask |
| M3 | R→P | selected RAN Multiplier, Chaps per Slot, Number Responder Nodes, Slots per Round (≥ responders+4), SYNC code subset, hopping, MAC Mode (1 or 2 rounds/block + round offset) |
| M4 | P→R | STS Index0, UWB Time0, Hop Mode Key, selected SYNC code index |
Every negotiated parameter is load-bearing for the crypto, not just the radio config. The set is hashed into a value called here the SaltedHash:
ProtocolVersion ‖ ConfigId ‖ SessionId ‖ STS_Index0 ‖ ResponderNodes ‖ RAN_Multiplier ‖ SlotsPerRound ‖ ChapsPerSlot ‖ PulseShape
and that hash feeds the ranging key KDF. If the two sides disagree on any of it they derive different STS keys and never hear each other, even though the setup handshake reported "success."
7. The UWB session#
Time grid (block → round → slot)#
RSTU = 416 / 499.2 MHz ≈ 833.33 ns
T_Chap = 1/3 ms = 400 RSTU
T_Slot = N_Chap_per_Slot × T_Chap (Chaps-per-Slot from M3)
T_Round = N_Slot_per_Round × T_Slot (Slots-per-Round from M3)
T_Block = N_RAN × 288 × T_Chap = N_RAN × 96 ms (RAN Multiplier from M2/M3)
N_Round = T_Block / T_Round (derived)
Ranging rate = 10.416667 / N_RAN Hz
block_start(i) = UWB_Time0 + i × T_Block
round_start(i,s) = block_start(i) + s × T_Round
slot_start(i,s,n) = round_start(i,s) + n × T_Slot
reader_local(…) = (any of the above) − SyncOffset [reader UWB clock]Of the N_Round rounds in a block only one or two are active (the MAC mode), selected by the hopping sequence.
Who transmits in which slot (DS-TWR, one to many)#
N_Resp is the number of responder nodes. The first pre-poll of a session goes out at UWB_Time0.
| Slot | Packet | Format | Sender |
|---|---|---|---|
| 0 | Pre-POLL | SP0 (data) | phone: session id, encrypted Poll_STS_Index, block i, Hop_Flag(i), Round_Idx(i) |
| 1 | POLL | SP3 (RFRAME) | phone |
| 2 … N_Resp+1 | Response_l | SP3 | one reader anchor each |
| N_Resp+2 | Final | SP3 | phone |
| N_Resp+3 | Final_Data | SP0 | phone: encrypted Poll/Final timestamps, Hop_Flag(i+1), Round_Idx(i+1) |
SP3 frames carry only a scrambled timestamp sequence (the STS): no readable payload, and the distance falls out of RMARKER timing across poll, response and final. SP0 frames are the opposite: plain data (vendor OUI 0x4A191B) wrapped in AES-CCM*. The two are structurally different packets, so rebuilding one round means switching the radio between no-STS data RX/TX and STS-only RFRAME RX/TX, and reloading the STS IV on every SP3 slot. The STS index increments on every slot used or not, so a skipped slot must skip its IV too; never reuse it.
Bench check: the phone honors N_Resp = 2 (2026-07-17)#
Everything this repo ships runs at N_Resp = 1. To test whether the phone builds the larger round, a firmware build advertised N_Resp = 2 with one physical anchor present: slot 2 (Response_0) ours as usual, slot 3 (Response_1) reserved and silent.
The count changes in two places at once, both feeding the RangingConfiguration SaltedHash the phone independently recomputes: the M3 Number Responder Nodes attribute and byte 12 of the RangingConfiguration blob in §6. If they disagree, every derived STS, dURSK and dUDSK diverges and nothing decodes. ultrawidelock_round_config.h defines both from one macro (ULTRAWIDELOCK_NUM_RESPONDERS, default 1) so they cannot desync.
Two frames move when N_Resp goes from 1 to 2, and they arrive on independent receive paths:
| Frame | Slot | Offset from POLL | N_Resp=1 | N_Resp=2 | Measured |
|---|---|---|---|---|---|
| Final | N_Resp+2 | N_Resp+1 | 2 | 3 | 3 (40 rounds) |
| Final_Data | N_Resp+3 | N_Resp+2 | 3 | 4 | 4 (38 rounds, none at 3) |
FINAL result st=… cper=0 ip=… d=…(…us) slots=3 stsq=…/… idx=… × 40 (× 4 missed)
FINALDATA-2RESP blk=… nresp=1 fd_slots=4 × 38 (× 0 at 3)Both offsets are differences of real RX timestamps, not printed constants. The Final result is the stronger because it fails cleanly rather than quietly: the receiver is armed at POLL+3 carrying the STS for index+3, so had the phone kept a 1-responder round its Final at POLL+2 would have been missed altogether instead of reported at a wrong offset. 40 hits at exactly 3 slots with the STS chip-error flag clear (cper=0) means the phone scrambled that frame with the key for index+3, derived only from a 2-responder schedule.
What this establishes. The phone accepts N_Resp = 2, keeps its SaltedHash key derivation in step, and grows the round by one slot. Reserving a slot for a second anchor is a solved problem on the phone side.
What it does not. No second anchor ever transmitted, so a real Response_1 in slot 3 (POLL+2, STS index+2) has never been accepted or turned into a range. Final_Data reported nresp=1 throughout, expected rather than a failure: the phone emits a timestamp record only for a responder that replied.
Source: commits 9106aed (the probe) and 2da85c4 (collapsed onto the ULTRAWIDELOCK_NUM_RESPONDERS knob), PR #8. The raw serial capture was never committed.
Bench check: a real Response_1, and what CCC v4 requires (2026-08-21)#
On 2026-08-21 a second anchor transmitted. A satellite (apps/satellite, nRF5340 DK + DWM3000EVB) holding the session URSK joined from the air alone, decoded Pre-POLL, POLL and Final at cper=0, and transmitted Response_1 in slot 3 every round with about 3 ms of arm margin and no HPDWARN. The phone's Final_Data still reported nresp=1. A control run with the LOCK's own proven radio rebuilt as RESPONDER_INDEX=1 answered the same way: tx123 responses, zero records, session FAILED at the phase deadline.
Our slot and STS arithmetic is spec-conformant, so it is not the cause. Checked against Table 20-2 (ccc-v4.txt l.53806-53874), the responder-slot formula (l.54244-54246) and the STS increment list (l.55650-55668):
| Frame | CCC absolute | Relative to POLL | This tree | Site |
|---|---|---|---|---|
Response_l STS | slot 2+l, STS +2+l | +1+l | widx + 1u + RESPONDER_INDEX | ccc_shim_rx.c:585 |
Response_l RMARKER | slot 2+l | +1+l slots | poll_ip + RESP_SLOT + l*SLOT | ccc_shim_rx.c:1485 |
| Final | slot N+2, STS +N+2 | +N+1 | FINAL_SLOT_OFFSET = N+1 | cred_round_config.h:88 |
| Final_Data | slot N+3 | +N+2 | observed at POLL+4 for N=2 | bench |
What CCC requires of the report is narrower than "report every responder." On the Final_Data payload (l.54234-54252):
"All the ranging measurement time stamps for all responders, up to N_Responder, whose valid ranging responses have been received at the initiator. The initiator may additionally add the time stamp data of responders where no valid response has been received."
Shall for validated responders, may for the rest. So nresp=1 is compliant if and only if the phone did not treat the slot-3 frame as a valid response; padding is never obliged. Nor is there any capability by which an initiator may declare itself single-responder: the device's Ranging_Capability_RS carries only Selected_UWB_Config_Id and Selected_PulseShape_Combo (l.44869-44913), and its Ranging_Session_Setup_RS carries only STS_Index0, UWB_Time0, HOP_Mode_Key, SYNC_Code_Index (l.45361-45400). Number_Responder_Nodes is set unilaterally by the vehicle in Ranging_Session_Setup_RQ and cannot be negotiated down.
There is no addressing to get wrong. An SP3 RFRAME has no MAC header at all (l.55249-55252):
"the Auxiliary header is not relevant to SP3 packets [...] since SP3 packets do not get security processing due to the fact they do not carry MAC data frames (payload, MHR, and MFR)."
and identity is positional by design (l.55789-55792):
"SP3 is intended for use cases where the participants in the secure ranging exchange are known to each other such that information about the source and/or the destination are implicit in the knowledge of what STS is used for transmission and reception."
So a responder is identified by which slot it transmits in and which STS it uses, nothing else. Both were verified conformant above, which kills the benign explanation: there is no address field we could have failed to populate.
What the initiator can object to is enumerated in Table 20-7 (l.54536-54556): 0x0 success, 0x1 transaction overflow ("RESPONSE SP3 frame from this responder cannot be processed by the device"), 0x2 transaction expired ("No RESPONSE SP3 frame was received from this responder"), 0x3 incorrect frame ("The RESPONSE SP3 frame received from this responder was not correct"). Had the phone padded a record for responder 1, that byte would have named the reason. It padded nothing, which the spec permits, so the silence carries no diagnosis.
That leaves the 2026-08-21 run's conclusion on firmer ground: the phone's report path is single-responder in practice, whatever the spec permits. The residual alternative, that it never armed a receiver in slot 3, is the same statement from the other side.
The discriminator still worth running: have the LOCK listen in slot 3 and verify the satellite's Response_1 STS using the session keys it already holds. A peer holding the same keys validating that frame closes the last gap between "we transmitted something wrong" and "the phone does not listen there."
Roles, for the record (l.52707-52716). The PHONE is initiator and controller; the lock is the "responder-device"; each anchor is a "responder". So the phone builds and transmits the measurement report, and a lock cannot compel what goes into it.
Two clauses that matter for any two-anchor design. First, silence in a slot is ordinary (l.54128-54130):
"If any of the responders does not receive the POLL message in the current ranging round from the initiator, that responder shall not transmit during its dedicated response slot."
No retry counter, no round abort; a skipped round is merely "mitigated by employing the ranging round hopping strategy" (l.52862-52867). Second, per-round responder selection is the responder-device's call (l.54484-54491):
"It is up to the vehicle to determine which set of its responders it will involve in the ranging exchange in each ranging round."
Together those put block-parity alternation (second-anchor.md stage B') inside the sanctioned model, where stage B's un-enrolled second responder sat outside it: l.52730-52736 makes coordinating "which logical responders transmit and in which order" the responder-device's duty. Also noted there: one round is limited to 7 responders "due to the maximum time stamp value for the Final message", with N_Responder <= 10 fixed at setup.
Source: ccc-v4.txt, the CCC Digital Key Technical Specification v4.0.0 (CCC-TS-101), read locally. Line numbers index that text extraction, not the PDF's pages.
The stage B verdict is not safe: a 64-byte stash drops the two-record Final_Data#
Found 2026-08-21 reading scottjg/ultrawidelock at branch wip, a fork of this tree that built the same two-responder round on one reader with two radios. Its commit af95ec3f reports:
"the Pre-POLL stash was 64 bytes and a two-record Final_Data is 65 on the air. Every one of them was received cleanly and then dropped by a size gate, one byte over, which on the bench looked exactly like the phone refusing to send Final_Data once Response_1 went out."
This tree has the same defect, and the arithmetic checks out against our own constants. ccc_shim_rx.c:309 declares static uint8_t g_pp_stash[64], and ccc_shim_rx.c:910 drops any frame larger than it before the Final_Data ever reaches final_data_decode():
if (!ccc_shim_active() || datalength == 0u || datalength > sizeof(g_pp_stash)) { return;
An SP0 Final_Data on the air is CCC_MHR_LEN + CCC_FINAL_DATA_HDR_LEN + n * CCC_RESPONDER_LEN + CCC_SP0_MIC_LEN + 2 bytes of FCS, which the driver counts in datalength. From ccc_mac.h:21,25,27 and ccc_kdf.h:48 those are 23, 18, 7 and 8:
| Records | Size | Against a 64-byte stash |
|---|---|---|
| 1 (the validated 1:1 baseline) | 23 + 18 + 7 + 8 + 2 = 58 | fits, always has |
| 2 (a dual-responder round) | 23 + 18 + 14 + 8 + 2 = 65 | dropped, one byte over |
So the one frame that would prove the phone validated a second responder is the one frame this tree cannot receive, and it fails silently: no counter, no log line, an early return.
The host suite cannot catch it. tests/host/test_prepoll_round.c:101 sets fd.num_responders = 1u, so every Final_Data the suite has built is the 58-byte one; the gate is unreachable from the tests. The fork reports its fixture was also short the two FCS bytes the driver counts, which is why several thousand host checks passed over this on both sides.
What this does to the stage B verdict. It does not overturn it: our bench did decode Final_Data frames reporting nresp=1, and a dropped frame would have produced no Final_Data at all, so the two observations are not the same event. What it removes is the verdict's safety. A block where the phone did validate both responders would have been discarded without trace, so the run cannot distinguish "the phone never validated the satellite" from "the phone validated it in some blocks and we deleted the evidence." The control run's session failing at the phase deadline is most consistent with frames going missing. Stage B has to be re-run with the stash enlarged before its conclusion means anything.
The fork's bench is direct counter-evidence, at second hand. Commit 65c00313 reports two DWM3000EVBs about 25 cm apart on an ESP32-C6 both ranging the same block, 58 ranges on the primary and 661 on Responder_1 over one capture. If that holds, a phone does report a second responder's record and this line of investigation was chasing our own buffer. Not verified here: different phone, silicon and port, and the 58-versus-661 inversion is unexplained; a secondary that out-ranges the primary eleven to one wants an explanation before the number is leaned on.
A second defect in the same area, also from the fork. ccc_responder_ds_twr() (ccc_mac.c:330-342) selects the phone's record by array position, fd->responders[l], guarded by l >= fd->num_responders. CCC obliges the initiator to report only the responders it validated, in no guaranteed order, and each record carries its own responder_index tag (ccc_mac.h:73). If the phone validates only responder 1, it sends one record tagged 1, and this tree reads it as responder 0's, attributing the satellite's timestamp to the lock. The fork's fd_record_for() searches by tag, validates that the tags are in range and unique, and falls back to position only when they are not, the behaviour the 1:1 baseline always had. Worth taking whether or not the rest of the fork's architecture is.
The estimator's error budget: what can and cannot move a CCC distance (2026-08-21)#
Block-parity alternation (second-anchor.md stage B') produced the first authenticated DS-TWR distances a second anchor has computed from the phone's own round: 88 in one run, min 46 mm, median 159 mm, mean 335 mm, max 1323 mm. Those figures are the peer session's bench measurement, reported rather than reproduced.
Provenance. Those four figures are contaminated and must not be quoted as the CCC path's accuracy. Two causes, both identified 2026-08-21: the DK lacked clear line of sight at phone height, and some statistics were read out of a session still in progress, so approach samples sat inside the median. The arithmetic below concerns what the estimator can and cannot do, not those numbers, but the distribution it was aimed at was an artifact.
A constant cannot produce scatter. A hard floor at 46 mm with a mean (335 mm) more than twice the median (159 mm) is right-skewed, and no additive offset produces skew: an offset slides a distribution and leaves its shape alone. The run therefore contains at least two phenomena, whatever sets the centre and whatever makes the tail, so any single-cause explanation is wrong before its details are argued.
The estimator is exactly invariant to the reply/round split. Write the six timestamps as t1 poll TX, t2 poll RX, t3 response TX, t4 response RX, t5 final TX, t6 final RX, and the four intervals as R1 = t4-t1, r1 = t3-t2, R2 = t6-t3, r2 = t5-t4 (ds_twr.h:34-37). Delay the responder's Response TX by d. The phone's Final TX is scheduled off its own POLL, so t5 does not move, and the four intervals go R1+d, r1+d, R2-d, r2-d. The denominator is unchanged by inspection. The numerator changes by d[(R2-R1) + (r1-r2)], and because a true flight time T makes R1 = r1+2T and R2 = r2+2T, that bracket is (r2-r1) + (r1-r2) = 0. The d^2 terms cancel too, so the result is exact rather than first-order:
dtof/dd = 0, for every d and every reply asymmetry.
Checked numerically against ds_twr_tof_signed()'s own formula at T = 21, 213 and 640 ticks (0.1 m, 1 m, 3 m), at reply ratios 1:1, 1.66:1 and 41:1, and at d = 0, +-15872 and +100000 ticks: the returned ToF is the input T in all 36 cases.
Correction. An earlier claim to the peer session that the sensitivity was dtof = d * (r1-r2)/(r1+r2) came from transposing the two relations above (R1 = r2+2T instead of R1 = r1+2T). The sensitivity is zero, so the reply-asymmetry diagnostic proposed with it would measure nothing. The conclusion it supported, that CCC_RESP_ANT_DLY_HI32 cannot be biasing our own distance, survives and is stronger for not resting on a small-asymmetry argument.
CCC_RESP_ANT_DLY_HI32 is exonerated by construction, and the code comment beside it is right. That constant (ccc_shim_rx.c:657, 62 hi32 = 15872 DTU ticks) moves the Response RMARKER earlier so a peer computing a single-sided ToF, which assumes the responder replied exactly one nominal slot after the POLL, lands on the right answer. Its size confirms that reading: 15872 ticks x 4.6917 mm is 74.5 m of round trip, so 37.2 m one-way, matching the "constant ~+37 m high" the comment records measuring. Our own DS-TWR consumes the measured t3, not the scheduled one, so by the invariance above it never sees the shift.
Clock offset cannot explain the spread either. Let the responder's clock run at 1+e relative to the initiator's, which scales the two intervals the responder measures (r1, R2) and leaves the two the initiator reports (R1, r2) alone. Then
tof = T (2+2e)/(2+e) ~= T (1 + e/2)
so the error is T*e/2: proportional to the distance, independent of reply asymmetry, and tiny. At 1 m and 20 ppm it is 0.010 mm; at 3 m and 100 ppm, 0.150 mm; at 3 m and 1000 ppm, 1.50 mm. Numerics agree with the closed form to four decimals. This immunity is why CCC uses the asymmetric four-term estimator: the symmetric variant needs the reply times balanced, which a phone's schedule never guarantees.
Why the anchor bench needs a 19 m calibration and the CCC path does not. One difference fully accounts for it:
| anchor bench | CCC responder | |
|---|---|---|
| TX timestamps in the estimator | predicted (dx << 8), anchor_twr.c:409,547 | measured dwt_readtxtimestamp(), ccc_shim_rx.c:1611,1855 |
| lumped correction | - CONFIG_ANCHOR_ANT_DLY_DTU = 4092 ticks = 19.2 m, anchor_twr.c:636 | none |
The anchor initiator must put t5 inside the FINAL it is still transmitting, so it writes a predicted RMARKER; the prediction omits whatever the TX antenna-delay register contributes, and nothing in this tree programs that register (anchor_twr.c:377-382). The omission is a constant, and 4092 is that constant measured (2,229 samples, mean 1003.3 mm at a true 1000, examples/zephyr/anchor/Kconfig:56-73). The CCC path reads its t3 back from the chip after the fact and the phone reports its own measured timestamps, so the same term is never introduced and must not be subtracted.
Correction. An earlier claim to the peer that the CCC path was "missing" the anchor path's antenna-delay subtraction, and that it was worth fixing, was wrong: applying 4092 there would subtract 19.2 m from distances whose median is 159 mm, and an uncompensated 19.2 m offset cannot coexist with a 159 mm median. What remains genuinely uncompensated on the CCC path is the RF delay between the chip's timestamp reference plane and the antenna, at both ends. The clean run below bounds it: under 25 mm, and if anything negative rather than positive.
Precision is excellent; accuracy is conditional and not yet established. Tape the phone at a known distance and read medians only, the median being robust to the tail. At a nominal 2.0 m this produced four internally tight clusters hundreds of millimetres apart:
| Sessions | Medians (mm) | IQR (mm) | Offset from 2.0 m | Stated condition |
|---|---|---|---|---|
| s10-s12 | 1975 / 1989 / 1980 | 10-23 | -20 mm | phone handheld |
| s13-s16 | 1576 / 1581 / 1576 / 1576 | 14-33 | -420 mm | not established |
| s17-s18 | 2134 / 2130 | 19 | +130 mm | operator's body in the path |
Within a cluster the spread is ~15 mm, tighter than the 20.5 mm sigma stage A measured on the dedicated anchor bench where both ends are our own boards. Between clusters something uncontrolled moves the level by up to 420 mm, and it has not been identified. So the instrument is precise, the -20 mm of the first cluster shows no fixed offset and no scale error for that condition, and none of these is the CCC path's accuracy as such. The calibration thread stays closed with no correction term owed, as the predicted-versus-measured argument above predicts, but "nothing to calibrate" is a statement about one condition among at least three.
The tail was placement, not first-path physics. Leading-edge detection was argued to explain the right tail, first-path error being one-sided positive and so producing a hard floor with a long tail. Sound reasoning, wrong conclusion: the 28% of samples above 400 mm disappeared once the DK had clear line of sight at phone height, and later sessions span 136-272 mm with zero outliers. The signature fit, the cause was geometry. No CIR or fp_index investigation is needed.
Methodology rule. Never read a session's statistics until a later session has started. Reading them in flight leaves approach samples inside the median: one session read 1656 mm while running and 1975 mm from its last-8 stationary samples once complete, a ~320 mm apparent offset briefly believed and now retracted. Treat any figure in the repo dated before 2026-08-21 that came from a live session as suspect.
Unresolved: what moves the level, occlusion included. The -420 mm cluster was attributed to a person sitting between the DK and the phone. Withdrawn: the operator states the geometry never moved and that their body is in the path during the +130 mm cluster, not the -420 one. So the body-in-path group, on the only condition anyone has stated, reads long rather than short, and the -420 cluster has no established cause.
Both withdrawn attributions, a fixed offset then an occlusion penalty, failed because conditions changed between clusters and were established only afterwards. The handheld phone defeated the run: it moves with the person, so body position is never independent of phone position.
The controlled version: phone on a fixed surface, taped distance, two sessions differing only by a body stepping into the line, operator recording which is which. This gates stage B''. Two rounds per block read the difference between two anchors as geometry, and at a door one anchor sits behind a person nearly every time; if a body moves a range by even a fraction of what these clusters move, that assumption needs a number it does not have.
Still unverified. Whether two boards built at NUM_RESPONDERS=1 / RESPONDER_INDEX=0 and sharing one session's keys derive byte-identical per-block and per-slot material has not been checked in this tree. Block-parity alternation assumes they do, and the 88 authenticated ranges are consistent with it, but consistency across one satellite is not a proof for two.
STS index and the key ladder#
STS_Index0 : random in [0 .. 2^30−1], pinned to block 0 / round 0 / slot 0
STS_Index(i, s) = STS_Index0 + (i·N_Round + s) · N_Slot_per_Round s = Round_Idx(i)
within a round: Pre-POLL=base · POLL=base+1 · Response_l=base+2+l · Final=base+N_Resp+2 · Final_Data=base+N_Resp+3
URSK (32 B, from section 4)
├─ mUPSK1/mUPSK2 per session → AES-CCM* ENC-MIC64 over the Pre-POLL payload (static all session)
└─ mURSK per session
└─ URSK_KT per active round (keyed on that round's Pre-POLL STS index)
├─ dURSK → generates the STS via the 802.15.4z DRBG
└─ dUDSK → encrypts the Final_Data timestampsThe finding that made responders tractable is pre-poll recovery. mUPSK1 comes straight off the URSK and stays static for the session, so the responder decrypts the first pre-poll with no ranging-round state set up. Decrypt it, read Poll_STS_Index, subtract 1, derive URSK_KT, then dURSK and dUDSK, and the SP3 STS is armed for the poll one slot later: about 4 AES-CMAC chains between slot 0 and slot 1. Anchor every block on the in-band pre-poll Poll_STS_Index rather than the BLE time sync, and arm each slot before the ~2 ms KDF/decrypt cost hits. That turned "setup is fine but the radio is silent" into live distance reports.
Hopping (which round is active)#
Fully deterministic in the block index, so both sides can compute it with no radio contact:
h_i = ( (((i + HOP_Key) & 0xFFFF)² mod 65521) × (N_Round − O_k) ) >> 16 (65521 = 2^16 − 15)
f_i = h_i + O_k (O_k = round offset, ≠ 0)HOP_Key is the hop mode key from M4 (0 means no hopping). Block 0 is always unhopped, on rounds 0 and O_k, and the sequence takes over from block 1 onward. Test vector: with HOP_Key = 0xCC5DD79F and N_Round = 80, blocks 1 and 2 land on rounds 62 and 37. Three modes: none (fixed round), continuous (recompute h_i every block), and adaptive (stay put while things work, hop on interference; the phone announces the next block's flag and round inside Final_Data, and a missing Final_Data is assumed to be "hop"). Because it is deterministic, losing a Final_Data costs nothing; both sides still land in the same next round.
On the PHY side: HRP UWB high band, either channel 5 (6489.6 MHz) or channel 9 (7987.2 MHz), BPRF, with the preamble and pulse shape as negotiated in M2/M3/M4.
8. Session lifecycle and key lifetimes#
Suspend and resume: either side can suspend to save power. The phone usually suspends while the door sits unlocked and resumes when the reader flips from unlocked back to locked (or when it detects motion). A resume hands over a fresh UWB_Time0 and STS_Index0, a new grid without redoing M1-M4, and works only if the reader still holds a URSK that has not expired.
The URSK is dropped when any of these occur: the STS index reaches 2^31−1, the STS index is lost, a 12-hour TTL expires (counted from the first dURSK derivation at M4), or the BLE link drops. After that, either side reports URSK_Unavailable and a fresh access transaction is required, which is cheap because it can use the fast path. Ranging therefore dies once the phone leaves BLE range, or after about 12 hours.
9. Open problem: pinning the reader's clock#
The one implementation-defined seam from section 5: how the reader maps the BLE connection-event anchor into its own UWB clock. On a split-core SoC (BLE controller on the network core, host and UWB on the app core), this cannot be done in software alone. The app core cannot read the controller's clock or the network-core timer, and the ready-made radio-notification conversion path is fused off on this SoC family.
The approach is a physical bridge rather than cross-core clock math:
controller "event-start task" ──▶ network-core GPIOTE pin (one edge / conn event) ──▶ DW3110 time-latch
BLE "anchor report" (event counter N, anchor_µs(N)) ──HCI/IPC──▶ app core
correlate by connection-event counter: DW_time(edge_N) + L ↔ anchor_µs(N)L is a calibrated constant lead, covering radio ramp-up plus the latch offset. That pair is the (UWBVehicleTime, anchor) the section 5 formulas require, and because the connection-event counter is a shared protocol counter, it also aligns with the phone's DeviceEventCount. The DW3000 family provides dwt_config_ostr_mode() (one-shot timebase reset over the sync pin) as the primitive that defines the epoch. Two items remain open:
- The anchor-report enable must live in the network-core image. Enabling it only from the app side is the prime suspect for a silent failure where the events never appear.
- The larger gap is obtaining the peer's time sync. The phone's
UWB_Device_Timeand device event count are consumed inside a closed protocol library and never surface. The procedure 0 time sync does arrive in the clear before any session keys exist, so it can be snooped on the receive path before the library consumes it; the later BleSK-encrypted syncs cannot. Without either a snoop hook or an upstream API addition, the reader has itsUWBVehicleTimebut nothing to subtract it from.
10. Field guide: observations and their explanations#
| Observation | Reverse-engineered explanation |
|---|---|
| Phone never connects over BLE | Advert missing/wrong: 0xFFF2 absent, byte 7 bit 7 clear (no UWB build), or Dynamic Tag unresolvable (stale/changed reader identity). |
| BLE connects, tap-like unlock, no ranging | No common protocol version, or the 0x98 "URSK ready" trigger never sent → URSK_Unavailable. |
| M1 sent, phone answers "Setup Later" | Phone-side UWB busy/unavailable; wait for Initiate Ranging Session before resending M1. |
| M1-M4 complete, zero distance reports | Radio path: antenna, channel 5/9, missing time sync (wrong listen window), or a parameter mismatch → different SaltedHash → different STS (section 6). Watch for Secure Ranging Over UWB Radio Failed. |
| Ranging stops while unlocked, resumes at lock | Conformant suspend/resume for power saving (section 8), not a bug. |
| Everything dies after BLE drop / ~12 h | URSK lifetime rules (section 8); a fresh fast-path transaction is required. |
On instruments: a BLE sniffer alone yields everything through section 6 (discovery, versions, message IDs, suspend/resume) with no UWB gear. The clear-text half, the 0xFFF2 advert of this section and the Procedure-0 time sync of section 5, was decoded by a Wireshark dissector no longer in this repository. Resolving section 7 needs a UWB capture, or the radio's own per-frame diagnostics, which separates "setup negotiated but the STS is mismatched" from "STS lines up but there is no RF link." When distances vanish, check whether BLE is still trading setup and ranging control messages while the UWB side has gone silent. If so, the problem is in the radio/parameter/STS path, not the control stack.
11. Aliro 1.0: what it inherits, and the two-round block#
Aliro v1.0 (dated 2026-02-18) is the access-control profile of this same machinery. It cites CCC Digital Key 4.0.0 specifically (aliro-1.0.txt l.750) and delegates nearly everything: the MAC layer to CCC §20 (l.9555-9557), the ranging exchange sequence to CCC §20.5 (l.9596-9597), the PHY to CCC §21 (l.10011-10012), and the key ladder and STS to CCC §22.1/22.2 (l.10015-10016). Roles are preserved exactly: User Device is Initiator, Reader is Responder-device (Figure 12-2, l.9762-9767).
What it changes:
| Area | Aliro |
|---|---|
| Vendor OUI in the SP0 MHR | 0x4A191B, the CSA identifier (l.10006-10007) |
| Hopping | CCC's AES sequence is FORBIDDEN; only Aliro §17's default applies (l.7527-7528) |
| URSK root | not CCC's ladder -- offset 128 of derived_keys_fast / _volatile (l.3011-3013, 3055-3057) |
| URSK lifetime | 12 h TTL, and discarded when the BLE link drops (l.6272-6285) |
| Setup | four messages M1-M4, not CCC's two RQ/RS pairs (l.9748-10003) |
| UWB session id | low four octets of the Transaction Identifier (l.7509-7511) |
| New attribute | MAC Mode, attribute ID 15 (l.7589-7600) |
Number_Responder_Nodes is left alone: Aliro states no numeric constraint, so CCC's 1-255 range and 10-per-round cap govern. Its one arithmetic addition, N_Slot_per_Round >= N_Responder + 4 (l.9955-9959), independently corroborates the slot layout above: four overhead slots plus one per responder puts Response_l at slot 2+l.
The two-round block: Aliro's own answer to inside/outside#
The single architectural idea Aliro adds (§12.1.1, l.9571-9582):
"One or two ranging rounds out of all the ranging rounds per ranging block of a ranging session are used for the UWB ranging procedure. [...] Two ranging rounds per ranging block enable 'in front of' and 'behind the Reader' detection by the Reader. [...] A Reader MAY optionally support two ranging rounds per ranging block while a User Device SHALL support two ranging rounds per ranging block. [...] Additionally, the responder-device SHALL be responsible for mapping responders (at the responder-device) to the appropriate ranging rounds."
Mechanics: the two rounds sit at a session-constant non-zero offset O^k in rounds, chosen by the Reader at setup and signalled in the MAC Mode attribute. They run two hopping sequences related by f_i = h_i + O^k (l.9585-9591). N_Responder is per round, so a two-round block ranges up to 2N nodes. Round indices start at Round_Idx_1 = 0, Round_Idx_2 = O^k.
Three properties make this stronger than anything on the multi-responder path:
- Phone support is mandatory. "A User Device SHALL support two ranging rounds per ranging block." By contrast
Number_Responder_Nodes = 2only ever obliged the phone to reserve a slot, and the 2026-08-21 bench showed it will not report the occupant. - Both anchors measure inside the same block, separated by
O^k x T_Roundrather than a whole 192 ms block.ultrawidelock_fusion_evaldemands a same-round pair; this gets far closer to one than block-parity alternation can. - Neither anchor loses rate. Alternation halves each anchor's sample rate; two rounds per block does not.
Each of the two rounds is a complete exchange. Figure 12-1 (p.157 of the PDF; the text extraction drops it) draws the round as PP | P | R1 | R2 | R3 | F | FD and the block as six ranging rounds with two of them shaded, their positions moving block to block under the two hopping sequences. So a two-round block is not one exchange with extra responder slots, it is two full exchanges, each with its own Pre-POLL, POLL, responder slots, Final and Final_Data. That is why §12.1.2 subscripts everything by p (Hop_Flag_p, Round_Idx_p, p = 1 and p = 2) and why l.9709-9736 speaks of Final_Data1 and Final_Data2.
The consequence for a second anchor: in round 2 the satellite is responder 0 of its own round, and the phone sends it an ordinary single-responder Final_Data containing its record. It never depends on the phone reporting a second responder in one round, precisely the behaviour that failed on 2026-08-21.
The specific hazard (l.9709-9736): if either Final_Data goes unheard, the responder-device unconditionally assumes a hop and sets both Hop_Flag to 1. Two physical units must therefore share Final_Data reception state, not merely the URSK, or they will compute different next-round indices and desynchronise.
Not addressed by Aliro at all: AoA and antennas (zero hits), any distance threshold or proximity policy (distance, zero hits), relay attacks (zero hits). How a Reader turns a range into an access decision is out of scope. The only key-sharing language is non-normative §16.2.6 (l.11545-11551), which contemplates a URSK travelling "to an external UWB chip" over a protected channel, and does not require that chip to sit in the same enclosure.
Source: aliro-1.0.txt, Aliro Specification v1.0. Line numbers index that text extraction.
Credits#
- @kormax for ideas on ECP and UWB.
- @rednblkx for ideas on HomeKey.
- @scottjg for help with UWB chipset ideas.
Questions#
Open an issue on the repository for corrections or questions.