A T-Deck on MeshOS or wadamesh pairs over Bluetooth like any companion
Those firmwares guard only the UART write characteristic, so the refusal arrives on the handshake's first write, which meshcore's send swallows. The empty handshake read as "not a MeshCore companion", and the PIN pairing, the stale-bond repair and the macOS Passkey wait never ran. The write is now wrapped to keep its refusal, and an empty handshake behind one raises it, so both firmware shapes take the same pairing path. bleak no longer gets the PIN on Windows either: its WinRT pair() is Just Works only, and after a mistyped PIN it left Windows holding an unauthenticated bond that the next, correct attempt reused and was refused over. Verified against a T-Deck on MeshOS 1.3.0: a planted Just Works bond heals in one run, and an unbonded connect without a PIN now says the device needs one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
307e767570
commit
854b7fd765
2 files changed
+150
-10
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user