diff --git a/meshterm/core/connection.py b/meshterm/core/connection.py index f05c211..c7bd78a 100644 --- a/meshterm/core/connection.py +++ b/meshterm/core/connection.py @@ -799,6 +799,55 @@ def _hold_disconnects_while_connecting(connection) -> _ConnectingClients: # noq return seen +class _WriteRefusals(list): + """Every exception a ``BLEConnection`` write raised, oldest first.""" + + def refusal(self) -> BaseException | None: + """The first write the peripheral refused for want of a bond, else ``None``.""" + return next((exc for exc in self if _is_ble_auth_error(exc)), None) + + +def _record_write_refusals(connection) -> _WriteRefusals: # noqa: ANN001 + """Keep what a refused write said, which meshcore's ``send`` logs and then forgets. + + Stock companion firmware guards *both* halves of its UART service with ENC+MITM, so an + unbonded host is refused at the notify subscribe — raised out of ``connect()``, where + :meth:`MeshCoreDevice._open_ble` turns it into a pairing. The standalone T-Deck firmwares + (MeshOS, wadamesh) guard only the **write** characteristic: the subscribe succeeds, and + the refusal ("Insufficient Encryption" unbonded, "Insufficient Authentication" over a + Just Works bond) arrives on the identity handshake's first write. ``BLEConnection.send`` + catches that, logs "BLE write failed", and returns ``False``; the handshake then comes + back empty and the device read as "not a MeshCore companion" — with no PIN pairing, no + stale-bond repair, and no macOS Passkey wait, because nothing downstream ever saw an + authentication error. Verified against a T-Deck on MeshOS 1.3.0. + + So the write is wrapped to record the exception on its way through (it still propagates + to ``send``, which behaves as before), and :meth:`MeshCoreDevice._connect_owned_ble` + raises the recorded refusal when the handshake came back empty. Both firmwares then take + the same pairing path. + + Args: + connection: The ``meshcore.BLEConnection`` about to connect. + + Returns: + The record of write failures, empty until one happens. + """ + seen = _WriteRefusals() + write = getattr(connection, "_write_locked", None) + if write is None: # a meshcore without the hook: the handshake reads as before + return seen + + async def _write_locked(data) -> None: # noqa: ANN001 - bytes-like, as meshcore sends + try: + await write(data) + except Exception as exc: + seen.append(exc) + raise + + connection._write_locked = _write_locked + return seen + + def _held_client(connection, seen: _ConnectingClients): # noqa: ANN001, ANN202 """The bleak client to keep for teardown: the connection's, else the last one it lost.""" client = getattr(connection, "client", None) @@ -1939,7 +1988,7 @@ class MeshCoreDevice(Device): """ from meshcore import BLEConnection - # Withhold the PIN on macOS. Handed one, ``BLEConnection.connect`` calls bleak's + # bleak never gets the PIN, on any platform. On macOS: handed one, ``BLEConnection.connect`` calls bleak's # ``client.pair()``, and CoreBluetooth has no pairing API at all — the macOS # backend raises ``NotImplementedError`` outright — whereupon the library # disconnects and re-raises, so supplying a *correct* PIN is what breaks the @@ -1949,15 +1998,21 @@ class MeshCoreDevice(Device): # the unbonded subscribe below is answered with "Insufficient Authentication", # and macOS reacts by running Passkey Entry and prompting for the code itself. # Saying nothing here is therefore what *lets* a PIN-protected companion bond; - # the OS keeps the bond, and later connections need no PIN. Windows and Linux do - # expose explicit pairing, and keep it (see :meth:`_pair_ble_windows`). + # the OS keeps the bond, and later connections need no PIN (a firmware that guards + # only the write gets there too, via :func:`_record_write_refusals`). # Linux is the same story from the other side: bleak's BlueZ ``pair()`` ignores the # PIN and pairs through whatever system agent there is (a desktop dialog, or nothing # at all), so MeshTerm pairs there itself beforehand (see :meth:`_pair_ble_bluez`). # Handing bleak the PIN would only start a second pairing we can't answer. - pin = None if sys.platform == "darwin" or sys.platform.startswith("linux") else self._pin - connection = BLEConnection(address=self._address, device=self._ble_device, pin=pin) + # And Windows: bleak's WinRT ``pair()`` is hardcoded to CONFIRM_ONLY + # ("Just Works") and never sends a PIN, so after a PIN pairing of our own that failed + # (a mistyped PIN), handing bleak the PIN made it bond *without* one — and Windows + # kept that unauthenticated bond. The next attempt, with the right PIN, then reused + # it and was refused, until the bond was removed by hand. Pairing is ours to run on + # every platform that has an API for it (see :meth:`_pair_ble`). + connection = BLEConnection(address=self._address, device=self._ble_device, pin=None) seen = _hold_disconnects_while_connecting(connection) + refused = _record_write_refusals(connection) mc = mesh_core( connection, default_timeout=self._connect_timeout, @@ -1979,6 +2034,11 @@ class MeshCoreDevice(Device): if started is None: await MeshCoreDevice._discard_meshcore(mc) await self._release_ble_client() + # An empty handshake whose write was refused for want of a bond is a pairing + # problem, not a stranger on the air (see :func:`_record_write_refusals`). + refusal = refused.refusal() + if refusal is not None: + raise refusal return None return mc diff --git a/tests/test_ble_pairing.py b/tests/test_ble_pairing.py index 61930a9..b8b659c 100644 --- a/tests/test_ble_pairing.py +++ b/tests/test_ble_pairing.py @@ -50,8 +50,14 @@ class _FakeTransport: self.pin = pin self.link_open = False self.disconnect_calls = 0 + #: What the peripheral answers a write with: ``None`` accepts it, an exception refuses. + self.write_answer: BaseException | None = None _FakeTransport.last = self + async def _write_locked(self, data) -> None: + if self.write_answer is not None: + raise self.write_answer + async def disconnect(self) -> None: self.disconnect_calls += 1 self.link_open = False @@ -75,8 +81,10 @@ class _FakeMeshCore: ``outcomes`` drives each successive ``connect()``: an exception instance is raised (with the link already up, as the real GATT failure leaves it), ``"none"`` returns ``None`` - (transport up, no identity reply), ``"slow"`` hangs for a caller to cancel, and anything - else is returned as a successful handshake. + (transport up, no identity reply), ``"slow"`` hangs for a caller to cancel, a + ``("refused", exc)`` pair has the handshake's write refused with ``exc`` — swallowed, as + meshcore's ``send`` does, leaving the handshake empty — and anything else is returned as + a successful handshake. """ #: Every instance built during a test, in order (a retry builds a second one). @@ -100,6 +108,13 @@ class _FakeMeshCore: # The GATT subscribe fails here — after the link is up and *before* the manager # records it. Nothing downstream will close it unless someone reaches the cx. raise outcome + if isinstance(outcome, tuple) and outcome[0] == "refused": + self.cx.write_answer = outcome[1] + try: # meshcore's send: log the failure, return False, and carry on + await self.cx._write_locked(b"") + except Exception: + pass + return None if outcome == "slow": await asyncio.sleep(30) # a handshake the caller's wait_for will cancel self.connection_manager._is_connected = True @@ -204,8 +219,7 @@ def test_a_good_connect_hands_the_client_over_still_open() -> None: def test_the_connection_is_built_from_this_device_s_own_endpoint( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Address, discovered ``BLEDevice`` and PIN all reach the connection we construct.""" - # Pinned off macOS, where the PIN is deliberately withheld (see the test below). + """Address and discovered ``BLEDevice`` reach the connection we construct; the PIN doesn't.""" monkeypatch.setattr(sys, "platform", "win32") _FakeMeshCore.reset("ok") sentinel = object() @@ -220,7 +234,7 @@ def test_the_connection_is_built_from_this_device_s_own_endpoint( asyncio.run(dev._connect_owned_ble(_FakeMeshCore)) cx = _FakeTransport.last - assert (cx.address, cx.device, cx.pin) == (_ADDR, sentinel, "000000") + assert (cx.address, cx.device, cx.pin) == (_ADDR, sentinel, None) client = _FakeMeshCore.built[0] # auto_reconnect stays off: MeshTerm drives reconnection itself. assert (client.default_timeout, client.auto_reconnect) == (7.5, False) @@ -261,6 +275,72 @@ def test_the_pin_is_withheld_from_bleak_on_linux(monkeypatch: pytest.MonkeyPatch assert dev._pin == "000000" +def test_the_pin_is_withheld_from_bleak_on_windows(monkeypatch: pytest.MonkeyPatch) -> None: + """bleak's WinRT ``pair()`` is Just Works only, so a PIN handed to it bonds *without* one. + + After our own PIN pairing failed on a mistyped PIN, that fallback left Windows holding an + unauthenticated bond, and the next attempt — right PIN — reused it and was refused. + """ + monkeypatch.setattr(sys, "platform", "win32") + _FakeMeshCore.reset("ok") + dev = MeshCoreDevice(transport="ble", address=_ADDR, pin="000000") + + asyncio.run(dev._connect_owned_ble(_FakeMeshCore)) + + assert _FakeTransport.last.pin is None + assert dev._pin == "000000" + + +def test_a_refused_handshake_write_is_a_pairing_problem() -> None: + """A firmware that guards only the write (MeshOS, wadamesh on a T-Deck) is still paired. + + The subscribe succeeds there, and the refusal lands on the handshake's first write, which + meshcore's ``send`` swallows. The empty handshake must surface as that refusal — not as + "not a companion" — so the pairing, the stale-bond repair and the macOS wait all run. + """ + refusal = _AuthError("GATT Protocol Error: Insufficient Encryption") + _FakeMeshCore.reset(("refused", refusal)) + dev = _device() + + with pytest.raises(_AuthError) as caught: + asyncio.run(dev._connect_owned_ble(_FakeMeshCore)) + + assert caught.value is refusal + assert _FakeMeshCore.built[0].cx.link_open is False # released before it was raised + + +def test_a_write_failing_for_another_reason_still_reads_as_no_answer() -> None: + """Only a refusal for want of a bond is promoted; a dropped write stays "no answer".""" + _FakeMeshCore.reset(("refused", OSError("device unreachable"))) + dev = _device() + + assert asyncio.run(dev._connect_owned_ble(_FakeMeshCore)) is None + + +def test_a_refused_write_heals_through_the_pin_repair(monkeypatch: pytest.MonkeyPatch) -> None: + """End to end on Windows: refused write → unpair and pair with the PIN → connected. + + The T-Deck case exactly: a Just Works bond is trusted on the fast path, the handshake's + write is refused over it, and the repair that stock firmware reaches from the subscribe + must be reached from the write too. + """ + monkeypatch.setattr(sys, "platform", "win32") + forced: list[bool] = [] + + async def _pair(self, *, force: bool) -> bool: + forced.append(force) + return True + + monkeypatch.setattr(MeshCoreDevice, "_pair_ble", _pair) + _FakeMeshCore.reset(("refused", _AuthError("Insufficient Authentication")), "ok") + dev = _device(pin="000000") + + client = asyncio.run(dev._create_ble(_FakeMeshCore)) + + assert client is _FakeMeshCore.built[1] + assert forced == [False, True] # the fast path, then the repair + + def test_a_cancelled_handshake_still_closes_the_link() -> None: """A probe's ``wait_for`` expiring mid-handshake must not leak what it cancelled.