Skip to content

_bleio: LE Secure Connections numeric-comparison pairing - #11340

Open
mmabey wants to merge 3 commits into
adafruit:mainfrom
mmabey:mabey/esp32-lesc-numcmp
Open

_bleio: LE Secure Connections numeric-comparison pairing#11340
mmabey wants to merge 3 commits into
adafruit:mainfrom
mmabey:mabey/esp32-lesc-numcmp

Conversation

@mmabey

@mmabey mmabey commented Sep 10, 2026

Copy link
Copy Markdown

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 NotImplementedError for any characteristic constructed with an *_WITH_MITM permission. 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, silabs and zephyr-cp get stubs (see Per-port status).

New API — shared-bindings/_bleio/Connection

Member
authenticated: bool True once the link is MITM-protected (numeric comparison / passkey entry completed), as opposed to merely encrypted. Ports without an implementation always return False.
pairing_numeric_comparison: Optional[int] The pending 6-digit value (0–999999) while the peer is waiting for the user to confirm it; None otherwise. Non-blocking — the central drives the SM procedure, so unlike pair() this never blocks.
confirm_pairing(accept: bool) -> None Answer a pending request. accept=False rejects and aborts pairing. Raises ConnectionError if the link dropped, _bleio.BluetoothError if nothing is pending.

Numeric comparison is triggered automatically: constructing a Characteristic or Descriptor with read_perm/write_perm == Attribute.LESC_ENCRYPT_WITH_MITM now (a) maps to NimBLE's _AUTHEN GATT 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 polls pairing_numeric_comparison, shows the digits, and calls confirm_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 = 1 is 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.
  • The first time a Characteristic/Descriptor is constructed with a *_WITH_MITM permission, bleio_adapter_enable_mitm_pairing() raises ble_hs_cfg.sm_io_cap to DISPLAY_YESNO and sm_mitm to 1. 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_YESNO would leave them with a PASSKEY_ACTION they can't answer and a 30 s SM timeout.

espressif implementation

  • Characteristic.c / Descriptor.c: delete the mp_raise_NotImplementedError("MITM security not supported") block; map ENC_WITH_MITM / LESC_ENC_WITH_MITM / SIGNED_* to _AUTHEN (NimBLE's GATT layer has no distinct LESC or signed flag).
  • Connection.c: a new PAIR_WAITING_NUMCMP pairing state holds the value from BLE_GAP_EVENT_PASSKEY_ACTION; confirm_pairing() injects the answer with ble_sm_inject_io(). BLE_GAP_EVENT_ENC_CHANGE records desc.sec_state.authenticated as Connection.authenticated. A passkey action this device can't service (INPUT / OOB) terminates the link with BLE_ERR_AUTH_FAIL rather than stalling SM until its timeout.
  • bleio_attribute_security_mode_requires_mitm() — small predicate in shared-module/_bleio/Attribute.c.

Per-port status

Port
espressif Full implementation.
nordic Stub. get_authenticated() returns False (SoftDevice LESC needs an app-side ECDH implementation that isn't currently vendored); the other two raise NotImplementedError. Nordic is the natural next port — happy to sketch the plan.
silabs / zephyr-cp Stub, same shape as nordic.

Not in scope / follow-ups

  • Nordic (and the other ports') implementations — see above.
  • Passkey entry / DISPLAY_ONLY / KEYBOARD_ONLY — only numeric comparison (DisplayYesNo) is handled; other IO capabilities are rejected.
  • The blocking 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_MITM permission:

import time
import _bleio

# Requires a numeric-comparison-authenticated bond to read. Constructing it with
# this perm is the whole opt-in; the adapter starts offering numeric comparison.
service = _bleio.Service(_bleio.UUID("f0000100-1111-2222-3333-444455556666"))
secret = _bleio.Characteristic.add_to_service(
    service,
    _bleio.UUID("f0000101-1111-2222-3333-444455556666"),
    properties=_bleio.Characteristic.READ,
    read_perm=_bleio.Attribute.LESC_ENCRYPT_WITH_MITM,
    max_length=4,
    initial_value=b"\xde\xad\xbe\xef",
)

name = b"fancy-smart-lock"
adv = bytes((2, 0x01, 0x06, len(name) + 1, 0x09)) + name  # Flags + Complete Local Name

while True:
    _bleio.adapter.start_advertising(adv, connectable=True)
    while not _bleio.adapter.connected:
        time.sleep(0.1)
    _bleio.adapter.stop_advertising()
    connection = _bleio.adapter.connections[0]

    # The central hits `secret`, which needs an authenticated link, so it starts
    # LE Secure Connections pairing. Service the numeric-comparison prompt:
    while connection.connected and not connection.authenticated:
        code = connection.pairing_numeric_comparison
        if code is not None:
            print(f"pair? {code:06d}")          # show on a display; button = accept/reject
            connection.confirm_pairing(accept=True)
        time.sleep(0.1)

    if connection.connected:
        print("authenticated:", connection.authenticated)   # -> True; `secret` now readable

    while connection.connected:
        time.sleep(0.1)
  • Built for espressif_esp32s3_devkitc_1_n8r8; codeformat.py / codespell / extract_pyi.py clean.
  • On hardware (ESP32-S3), against an Android central:
    • Fresh pair: 6-digit value from pairing_numeric_comparison matches the phone's dialog → confirm_pairing(True)authenticated is True, MITM bond stored.
    • Bonded reconnect after a power cycle: re-encrypts from the stored LTK, authenticated True, no new prompt.
    • LESC_ENCRYPT_WITH_MITM characteristic: 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

  1. Perm-derived adapter IO capability vs. an explicit Adapter property. Deriving sm_io_cap from *_WITH_MITM characteristic 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 explicit adapter.io_capability = ... (or similar) be preferred, either instead or in addition (e.g. for a device that wants numeric comparison without an MITM characteristic)?
  2. Same PR or fast-follow for nordic? Bundling nordic pulls in an ECDH submodule and a much larger review surface. Is "espressif + honest stubs + tracked follow-up issue" acceptable for landing the shared-bindings API, or should nordic be in this PR?
  3. confirm_pairing() raising vs. no-op on "nothing pending." Went with raising to match _bleio norms (__init__.c raises "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_ACTION never 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.

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 tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool! Thanks for working on this!

Comment thread shared-bindings/_bleio/Connection.c Outdated
(mp_obj_t)&bleio_connection_get_authenticated_obj);


//| pairing_numeric_comparison: Optional[int]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where did this name come from? Seems like there may be a better name.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread shared-bindings/_bleio/Connection.c Outdated
Comment on lines +225 to +226
//| Only implemented on the espressif port.
//|

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of comment, let's just raise NotImplementedError. It shouldn't be called anyway because the code will always be None when not implemented.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Comment thread ports/zephyr-cp/common-hal/_bleio/Connection.c Outdated
Comment thread shared-bindings/_bleio/Connection.c Outdated
Comment on lines +216 to +217
MP_PROPERTY_GETTER(bleio_connection_pairing_numeric_comparison_obj,
(mp_obj_t)&bleio_connection_get_pairing_numeric_comparison_obj);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this be settable in the future for when we're the central? Or would we add a kwarg to the pair() function?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).
@mmabey
mmabey marked this pull request as ready for review September 11, 2026 21:27
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