_bleio: LE Secure Connections numeric-comparison pairing - #11340
Conversation
Stock CircuitPython BLE peripherals can only do legacy "Just Works" pairing: nothing surfaces a passkey or numeric-comparison value to Python, and the espressif port additionally rejects *_WITH_MITM characteristic permissions outright. A peripheral with a display and a button (a lock, a medical device) therefore can't require an authenticated bond. Add numeric-comparison pairing to _bleio. NimBLE already ships LE Secure Connections crypto, so the espressif port is a config change rather than a crypto port; nordic, silabs and zephyr-cp get stubs. shared-bindings/_bleio/Connection: - authenticated: True when the link is MITM-protected (numeric comparison or passkey entry completed), not merely encrypted. Ports without an implementation always report False. - pairing_numeric_comparison: the pending 6-digit value, or None. Non-blocking - the peer drives the SM procedure. - confirm_pairing(accept): answer it. Raises ConnectionError / BluetoothError rather than silently doing nothing on misuse. espressif: - The adapter keeps the legacy Just Works defaults (NO_IO, sm_mitm = 0) until a characteristic or descriptor is constructed with a *_WITH_MITM permission, at which point bleio_adapter_enable_mitm_pairing() raises sm_io_cap to DISPLAY_YESNO and sm_mitm to 1. Existing headless peripherals are unaffected. sm_sc is now always 1 (negotiated per pairing, strictly better, and numeric comparison requires it). - Characteristic.c / Descriptor.c stop raising NotImplementedError for *_WITH_MITM and map the MITM / LESC-MITM / SIGNED modes to NimBLE's _AUTHEN flags. - Connection.c: a PAIR_WAITING_NUMCMP state holds the value from BLE_GAP_EVENT_PASSKEY_ACTION; confirm_pairing() injects the answer with ble_sm_inject_io(). A passkey action this device can't service (INPUT / OOB) terminates the link instead of stalling SM. nordic / silabs / zephyr-cp: get_authenticated() returns False; the other two entry points raise NotImplementedError.
Ports using devices/ble_hci/common-hal/_bleio (atmel-samd samd51, mimxrt10xx, raspberrypi, stm, broadcom, cxd56) were missing common_hal_bleio_connection_get_authenticated, common_hal_bleio_connection_get_pairing_numeric_comparison, and common_hal_bleio_connection_confirm_pairing, causing link failures. Add the same not-implemented stubs already used by nordic, silabs, and zephyr-cp.
tannewt
left a comment
There was a problem hiding this comment.
Cool! Thanks for working on this!
| (mp_obj_t)&bleio_connection_get_authenticated_obj); | ||
|
|
||
|
|
||
| //| pairing_numeric_comparison: Optional[int] |
There was a problem hiding this comment.
Where did this name come from? Seems like there may be a better name.
There was a problem hiding this comment.
The term "numeric comparison" comes straight from the Bluetooth spec. I added "pairing" for specificity, but I see now that it's unnecessary given it's already a property on the Connection class.
| //| Only implemented on the espressif port. | ||
| //| |
There was a problem hiding this comment.
Instead of comment, let's just raise NotImplementedError. It shouldn't be called anyway because the code will always be None when not implemented.
| MP_PROPERTY_GETTER(bleio_connection_pairing_numeric_comparison_obj, | ||
| (mp_obj_t)&bleio_connection_get_pairing_numeric_comparison_obj); |
There was a problem hiding this comment.
Would this be settable in the future for when we're the central? Or would we add a kwarg to the pair() function?
There was a problem hiding this comment.
I would personally lean toward authenticated staying a read-only property because making it settable would mean the same property is doing double duty as a request and a result:
- Assigning to it before a pairing attempt to mean "require this"
- Reading it after a pairing to mean "did this happen"
A pair(mitm=True) kwarg keeps the request and the result separate: you request it at call time, then read the result afterward via authenticated. It also mirrors how the peripheral side already opts in here, through characteristic permissions set at construction time rather than by mutating a status property. Central-initiated MITM would just be the same "declare intent up front" pattern, applied to pair() instead.
tannewt reviewed PR adafruit#11340 and asked for a few changes: - Rename pairing_numeric_comparison to numeric_comparison, since the pairing_ prefix was redundant on a Connection property already about pairing. Borrows the term directly from the Bluetooth spec's "Numeric Comparison" association model. - Drop the "Only implemented on the espressif port." line from confirm_pairing()'s docstring: numeric_comparison already reads None on ports without this feature, so a correct polling loop never calls confirm_pairing() there, and calling it anyway already raises NotImplementedError. - Stop passing a custom message to that NotImplementedError - the raise already makes it obvious via the function name. Matches the existing mp_raise_NotImplementedError(NULL) convention used elsewhere in the codebase, including twice already in zephyr-cp's own Connection.c. The NotImplementedError message removal also fixes a CI failure: dropping that translatable string frees up enough flash to get bluemicro840's ja locale build back under its 524 KB limit (it was overflowing by 16 bytes).
Motivation
Today a CircuitPython BLE peripheral can only do legacy Just Works pairing. Nothing surfaces a passkey or numeric-comparison value to Python, and the espressif port additionally raises
NotImplementedErrorfor any characteristic constructed with an*_WITH_MITMpermission. So a peripheral that has a display and a button — a lock, a medical device, anything handling data that shouldn't cross an unauthenticated link — has no way to require a man-in-the-middle-protected bond.This adds LE Secure Connections numeric comparison to
_bleio, implemented on the espressif port. NimBLE already ships the SC crypto (MYNEWT_VAL_BLE_SM_SC), so this is a configuration + event-plumbing change, not a crypto port.nordic,silabsandzephyr-cpget stubs (see Per-port status).New API —
shared-bindings/_bleio/Connectionauthenticated: boolTrueonce the link is MITM-protected (numeric comparison / passkey entry completed), as opposed to merely encrypted. Ports without an implementation always returnFalse.pairing_numeric_comparison: Optional[int]Noneotherwise. Non-blocking — the central drives the SM procedure, so unlikepair()this never blocks.confirm_pairing(accept: bool) -> Noneaccept=Falserejects and aborts pairing. RaisesConnectionErrorif the link dropped,_bleio.BluetoothErrorif nothing is pending.Numeric comparison is triggered automatically: constructing a
CharacteristicorDescriptorwithread_perm/write_perm==Attribute.LESC_ENCRYPT_WITH_MITMnow (a) maps to NimBLE's_AUTHENGATT flags instead of raising, and (b) tells the adapter to advertise numeric-comparison-capable IO. A central that then pairs to reach that characteristic gets the numeric-comparison flow; the peripheral pollspairing_numeric_comparison, shows the digits, and callsconfirm_pairing().Design: per-characteristic permission drives adapter SM config
The permission flags (layer 2, per-attribute enforcement) drive the adapter's SM configuration (layer 1, pairing negotiation), not the other way around:
common_hal_bleio_adapter_set_enabled()keeps the legacy defaults —sm_io_cap = NO_IO,sm_mitm = 0. Existing headless peripherals pair exactly as before.sm_sc = 1is now set unconditionally. It's negotiated per pairing (a peer that only does legacy pairing still works), it's strictly better crypto, and numeric comparison requires it.Characteristic/Descriptoris constructed with a*_WITH_MITMpermission,bleio_adapter_enable_mitm_pairing()raisesble_hs_cfg.sm_io_captoDISPLAY_YESNOandsm_mitmto1. This is a one-way, idempotent bump for the lifetime of the adapter.This avoids regressing headless espressif peripherals that pair with a central which negotiates MITM: an unconditional
DISPLAY_YESNOwould leave them with aPASSKEY_ACTIONthey can't answer and a 30 s SM timeout.espressif implementation
Characteristic.c/Descriptor.c: delete themp_raise_NotImplementedError("MITM security not supported")block; mapENC_WITH_MITM/LESC_ENC_WITH_MITM/SIGNED_*to_AUTHEN(NimBLE's GATT layer has no distinct LESC or signed flag).Connection.c: a newPAIR_WAITING_NUMCMPpairing state holds the value fromBLE_GAP_EVENT_PASSKEY_ACTION;confirm_pairing()injects the answer withble_sm_inject_io().BLE_GAP_EVENT_ENC_CHANGErecordsdesc.sec_state.authenticatedasConnection.authenticated. A passkey action this device can't service (INPUT/OOB) terminates the link withBLE_ERR_AUTH_FAILrather than stalling SM until its timeout.bleio_attribute_security_mode_requires_mitm()— small predicate inshared-module/_bleio/Attribute.c.Per-port status
get_authenticated()returnsFalse(SoftDevice LESC needs an app-side ECDH implementation that isn't currently vendored); the other two raiseNotImplementedError. Nordic is the natural next port — happy to sketch the plan.Not in scope / follow-ups
DISPLAY_ONLY/KEYBOARD_ONLY— only numeric comparison (DisplayYesNo) is handled; other IO capabilities are rejected.Connection.pair()is unchanged and doesn't drive numeric comparison — interactive pairing is inherently the non-blocking poll API.Testing
Peripheral-side usage looks like this — the only thing that opts a peripheral into numeric comparison is giving a characteristic the
LESC_ENCRYPT_WITH_MITMpermission:espressif_esp32s3_devkitc_1_n8r8;codeformat.py/codespell/extract_pyi.pyclean.pairing_numeric_comparisonmatches the phone's dialog →confirm_pairing(True)→authenticatedisTrue, MITM bond stored.authenticatedTrue, no new prompt.LESC_ENCRYPT_WITH_MITMcharacteristic: unreadable before the bond, readable after. Repeated connect/disconnect cycles crash-free.confirm_pairing(False)and abandoned-pairing paths: clean, no crash.Open questions for reviewers
Adapterproperty. Derivingsm_io_capfrom*_WITH_MITMcharacteristic perms is zero-config and safe-by-default, but it's a global side effect of constructing an attribute and it's sticky. Would an explicitadapter.io_capability = ...(or similar) be preferred, either instead or in addition (e.g. for a device that wants numeric comparison without an MITM characteristic)?shared-bindingsAPI, or should nordic be in this PR?confirm_pairing()raising vs. no-op on "nothing pending." Went with raising to match_bleionorms (__init__.craises"Already in progress"for the mirror case); the poll→confirm race is narrow and inside the exception envelope a real client already needs. Happy to switch to silent return if that's the house preference.Related
A separate issue: on this NimBLE build a client GATT procedure (
discover_remote_services()+ reads) running concurrently with an inbound SM pairing procedure on the same connection silently kills pairing —PASSKEY_ACTIONnever fires. Not caused by this change and worked around downstream by deferring the client read until after the bond, but worth an issue of its own.