Skip to content

smp: wait for key distribution to be flushed before completing pairing - #967

Open
TheArkadiuszGrzelka wants to merge 1 commit into
google:mainfrom
TheArkadiuszGrzelka:smp-drain-key-distribution
Open

smp: wait for key distribution to be flushed before completing pairing#967
TheArkadiuszGrzelka wants to merge 1 commit into
google:mainfrom
TheArkadiuszGrzelka:smp-drain-key-distribution

Conversation

@TheArkadiuszGrzelka

Copy link
Copy Markdown
Contributor

Problem

Session.pair() resolves as soon as the pairing procedure is done, which happens before the key distribution PDUs the initiator has just queued have been acknowledged by the controller. distribute_keys() only enqueues them into the connection's DataPacketQueue, and Session.on_pairing() sets pairing_result right after. An application that disconnects as soon as await connection.pair() returns therefore races its own last key PDU, and Connection.__aexit__ does exactly that.

Observed on a bench with an external LE Audio controller and a Zephyr 4.4 broadcast sink (SC + MITM, the sink asking for the initiator's IRK only and distributing nothing itself). Peer-side SMP trace:

smp_pairing_req: req: io_capability 0x03, auth_req 0x0D, init_key_dist 0x03, resp_key_dist 0x03
smp_pairing_req: rsp: io_capability 0x03, auth_req 0x09, init_key_dist 0x02, resp_key_dist 0x00
bt_smp_encrypt_change: encrypt 0x01 hci status 0x00
smp_ident_info:                       <- Identity Information (0x08)
... 30 ms gap ...
bt_smp_disconnected
smp_pairing_complete: got status 0x8
bt_keys_clear: ...
Disconnected: reason 0x13

Identity Address Information (0x09) never arrives, so the peer reports pairing status 0x08 and clears the half distributed key, while the initiator keeps its LTK and prints success. Every later connection then fails to encrypt with PIN_OR_KEY_MISSING.

run_pair in apps/auracast.py hits this on every run. apps/pair.py avoids the same race today only by sleeping POST_PAIRING_DELAY before disconnecting.

Change

  • Connection.drain(), mirroring the existing _IsoLink.drain(), so a caller can wait for the controller to complete what was queued for a connection.
  • Session.on_pairing() awaits it before resolving pairing_result. When the queue is already drained the event is set, so the await does not yield and adds no latency to the common path.

Testing

  • New tests/self_test.py::test_self_smp_waits_for_key_distribution withholds the controller's packet completions, asserts the packets really are still in flight, and asserts that pairing is not reported as complete until they are acknowledged. It fails on main and passes with this change.
  • pytest tests: 934 passed, 1 skipped, 6 failed. The 6 failures are in tests/transport_test.py::test_open_transport_with_metadata (android-netsim spec parsing) and fail identically on unmodified main.
  • black -S --check, ruff check and mypy clean on the touched files.
  • The hardware scenario above has not been re-run against the patched host yet, so the confirmation here is the unit test plus the peer-side trace that identified the race.

`Session.pair()` resolved as soon as the pairing procedure itself was done,
which is before the key distribution PDUs the initiator has just queued
have been acknowledged by the controller. An application that disconnects
right after `await connection.pair()` therefore races its own last key PDU.

Observed against an LE Audio sink (Zephyr 4.4, SC + MITM, the peer asking
for the initiator's IRK and distributing nothing itself): the sink receives
Identity Information, never Identity Address Information, so it reports
pairing status 0x08 and clears the half distributed key. The initiator
keeps its LTK and prints success, and every later connection fails to
encrypt with PIN_OR_KEY_MISSING. `run_pair` in apps/auracast.py does
exactly that disconnect, and apps/pair.py avoids the same race today only
with a one second sleep before disconnecting.

Wait for the connection's data packet queue to drain before resolving the
pairing result, and add `Connection.drain()` for that, mirroring the
existing `_IsoLink.drain()`.
@TheArkadiuszGrzelka

Copy link
Copy Markdown
Contributor Author

Verified on hardware since opening this, so the last line of the description is now out of date.

Bench: an external LE Audio controller over HCI UART, and a Zephyr 4.4 broadcast sink on nRF5340 (BASS + PACS, SC and MITM required). With this change, auracast pair and a separate later invocation of auracast assist against the same sink work as they should: the second process encrypts with the LTK stored by the first and reads the BASS receive state, where before it failed with PIN_OR_KEY_MISSING because the sink had discarded the incomplete bond.

One note for anyone reproducing it: the sink has to start out unbonded. Re-running pair against a peer that already holds a bond aborts with a disconnection on this sink, with and without the patch alike, so that path says nothing either way.

@barbibulle

Copy link
Copy Markdown
Collaborator

Thanks for this. I will take a look shortly and try it out on a few controllers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants