Deriving the ranging key
Status: complete and hardware-validated. Every step below has since run against a live iPhone: the transaction completes, the derived ranging key is correct, and the ranging setup it feeds ends in an approach unlock. This page describes the design and why each piece exists; the traps hit proving it are in docs/esp32-gotchas.md §4 and §5.
Prerequisite reading: porting-esp32.md (roadmap and retrospective) and protocol-research.md (the reverse-engineered protocol notes; this doc stays at that same disclosure level and cites no external specifications).
Where Phase 2 left off
The BLE transport and reader scaffold were already green on silicon: advertising the service (0xFFF2), serving the SPSM/version GATT read, bringing up the L2CAP CoC server on the SPSM, and coexisting with the UWB responder on one ESP32-S3. Components: ports/esp32/components/aliro_ble (transport) and .../aliro_reader (the session/transaction scaffold, with the crypto handshake seam still stubbed).
The boundary finding (why this phase existed at all)
Phase 3 is the BLE credential-auth transaction that yields the 32-byte URSK, the ranging root (see protocol-research.md §4). Before this phase it was the one layer the project had never reimplemented:
- Already reimplemented and already ported (via
modules/woz_uwb, compiled
unchanged): the entire UWB side — the ranging engine, the ranging-setup message exchange, and the STS key ladder from the URSK down (ccc_derive_mupsk1/2, ccc_derive_mursk, ccc_derive_ursk_kt, ccc_derive_dursk/dudsk, ccc_derive_salted_hash in modules/woz_uwb/src/ccc).
- The gap: Initiate Access → auth exchange → secure channel → URSK derivation. In
the reference design a closed vendor library handles this (protocol-research.md §9 calls it "a closed protocol library"), and every crypto function in this tree took the URSK as an input rather than deriving it.
That library is an ARM binary and cannot link on the Xtensa S3. There was no way around it: a standalone ESP32 reader had to derive the URSK itself. Everything downstream of the URSK was already done, and pure UWB bench testing via the canned-URSK path in main.c still needs none of it.
What Phase 3 built (per protocol-research.md §4)
- Transport / framing: L2CAP CoC (already up), one APDU per SDU.
- Flow: a standard path (ephemeral ECDH + mutual signatures) and a fast path off
a cached long-term key, both producing the 160-byte derived block with the URSK at offset 128 (docs/protocol-research.md §4).
- Crypto suite (all available in ESP-IDF's mbedTLS-PSA): NIST P-256; ECDH; ECDSA
P-256 with SHA-256; SHA-256; AES-256-GCM; and the two-stage KDF (a single-block X9.63-style step then HKDF-SHA-256) that expands the derived block.
- Handoff: the derived URSK + the negotiated ranging parameters land on the
existing engine entry point woz_uwb_start_aliro(cfg) (modules/woz_uwb/src/facade/woz_uwb_facade.h): session_id, channel, sync_code_index, slot_duration_rstu, block_duration_ms, slot_per_round, sts_index0, uwb_time_us, ursk[32].
Provisioning dependency
Auth cannot complete without a provisioned identity: a reader identifier, a reader P-256 key pair, the reader-group key material, and the credential-issuer public keys that validate the phone's credential. Without a reader identity and issuer keys matching a credential already in the phone's wallet, no handshake completes, however correct the code. Phase 4 supplies these over Matter; step 3.4 below supplies a fixed dev identity so the transaction is drivable at a bench before that.
Phase 3 steps
Each step below is implemented, host-KAT'd where it is host-testable, and confirmed on silicon against a live phone.
- 3.1 — the key schedule.
aliro_crypto
(ports/esp32/components/aliro_crypto): a portable SHA-256 / HMAC / HKDF / X9.63-KDF core (compiled identically on host and target) plus an mbedTLS-PSA backend for AES-256-GCM and P-256 ECDH/ECDSA. On top of that, the key schedule:
- stage 1
Z = SHA-256( ecdh_shared(32) ‖ 0x00000001 ‖ txid(16) )(single-block
X9.63), then stage 2 block160 = HKDF-SHA256(salt = transcript, IKM = Z, info = devicePubX(32), L = 160), with the URSK = block[128:160] and the two directional session keys split from the low segments;
- the
Kpersistent/ cryptogram-key single-block derivations (same HKDF, different
salt / label);
- the AES-256-GCM secure channel: 12-byte nonce = 8-byte big-endian direction
(0 = seal, 1 = open) ‖ 4-byte big-endian per-direction counter, separate non-wrapping counters.
Host KATs (ports/esp32/test/test_aliro_crypto.c, in run.sh) pass against FIPS-180-4, RFC 4231, RFC 5869, a GCM spec vector, and cross-check the schedule wiring; the whole component also builds and links into the firmware.
The salt transcript (aliro_salt_build) was the last provisional piece and is now resolved: the two open sub-fields turned out to be the reader's signing public key X (not the device ephemeral key — that was the bug) and an interface byte distinguishing BLE from NFC. See the gotchas log §4.3. The AUTH1 tag does not decrypt until the salt is byte-exact, so a green AUTH1 is the proof.
- 3.2 — the wire codec.
aliro_apdu
(modules/woz_aliro/src/aliro_apdu.{c,h}): single-byte-tag BER-TLV plus the AUTH0/AUTH1 command builders, the ECDSA authentication-data transcript (the exact signed bytes, with the reader/device usage domain separators), the AUTH0/AUTH1 response parsers, the EXCHANGE command + the zero-length 98 00 URSK-ready trigger, and the 4-byte L2CAP envelope ([type&0x3F][opcode][len_be16]). Host KAT: ports/esp32/test/test_aliro_apdu.c in run.sh. aliro_reader now drives the transaction (AUTH0 → AUTH1 → EXCHANGE), running ECDH + the key schedule to derive the URSK, replying via aliro_ble_send, with heavy diagnostic logging.
- 3.3 — superseded by 3.5. On a completed handshake the
reader originally started the responder with canned ranging parameters (woz_uwb_start_aliro). 3.5 replaces that canned hand-off with the real M1-M4 negotiation, so the params are now negotiated with the peer rather than fixed.
- 3.4 — the provisioning seam. The provisioning seam
(modules/woz_aliro/src/aliro_prov.c + modules/woz_aliro/include/aliro_prov.h, with the storage backend in ports/esp32/components/aliro_reader/aliro_prov_nvs.c): the reader identity (a stable reader identifier + P-256 signing key) and a credential trust store, NVS-backed with a clearly-marked, fixed dev identity fallback so the transaction is drivable at bench before Phase-4 Matter provisioning writes a real identity. Split like aliro_crypto: the dev default + blob (de)serialisation + trust logic are portable and host-KAT'd (ports/esp32/test/test_aliro_prov.c in run.sh); the NVS load/store is target-only. The reader now loads identity+trust at start (replacing the per-boot random dev key, which changed the reader identity every reboot), signs with the provisioned key, and gates on a trust check after verifying the device signature: a raw-key allowlist (the interim seam where real issuer-chain validation plugs in), with a dev-open policy (accept + loud warning) only while the dev identity has no anchors. Two bench console commands drive it: aliro-prov (show identity + trust store + last-presented credential) and aliro-trust (persist the last-presented credential key as trusted). Phase-4 Matter SetAliroReaderConfig writes the same NVS blob to supply a real identity + issuer trust.
- 3.5 — the ranging setup. The post-auth Aliro UWB
ranging-setup (M1-M4), wiring the reader to the engine's reader adapter/session (modules/woz_uwb/src/aliro aliro_uwb_adapter + aliro_uwb_session), which were compiled into the ESP32 image but had no caller. New modules/woz_aliro/src/aliro_ranging.{c,h}: on a completed credential-auth it creates a reader session bound to the derived URSK, emits M1 over the L2CAP channel, routes inbound M2/M4 into aliro_uwb_session_message_handle, and lets the engine negotiate the parameters and start the DW3000 responder itself (cherry_ccc_shim maps the CCC session-start onto woz_uwb_start_aliro, so the reader no longer calls it directly and the canned k_ranging table is gone). The engine's transmit callback frames straight to aliro_ble_send; the whole lifecycle (create/feed/teardown + the transmit/event callbacks) is synchronous on the BLE-host task, and the DW3000 is single-session. The woz_uwb component now exposes aliro/include (the aliro_uwb_adapter/ + cherry/ headers) publicly for the reader. This is a real engine-integration, not host-testable: it compiles and links now, and only fully verifies against a phone at bench.
What verification actually means here
Three different kinds of evidence back this phase, and they are not interchangeable:
- Host KATs (
ports/esp32/test/run.sh) pin the key schedule against published
vectors, the wire codec byte-for-byte, and the provisioning logic. The crypto core compiles host-identical to target, which is what lets a host result say anything about on-target behavior.
verify_port.shproves the firmware builds and links with the STS substitution
seam intact.
- A live iPhone is the only thing that proves interop, and it is the bar this phase
now clears. Everything before that bar built and linked cleanly while still being wrong: the salt transcript, the GCM counter start, the ranging channel split, and the session id were each a clean build that a phone rejected. Build success proves nothing about this layer.
Phase 4 supplies the missing piece for a real deployment. Until a controller writes a reader identity through SetAliroReaderConfig, the reader runs on its fixed dev identity with a dev-open trust policy: it accepts the presented credential and logs a warning. That is a bench seam, not a security control.
The heavy diagnostic logging at each step is deliberate — it is what made the bring-up tractable. Note that on the ranging hot path it later had to be throttled, because a blocking log line inside a 2 ms deadline is itself a real-time bug.