Skip to content

Commit f1822c8

Browse files
authored
feat(ble): allow private_key=False to disable command signing (#137)
* feat(ble): allow explicit null private_key to disable signing A passive BLE listener that only decodes broadcasts has no reason to generate or load a key it will never use. Commands.__init__ (and VehicleBluetooth/vehicles.create*'s key argument) now distinguish an omitted key from an explicit None via a sentinel default: omitting it keeps today's fallback-to-parent-or-raise behaviour, while passing None explicitly disables signing. Any operation that needs a signed session raises a clear SigningDisabled instead of failing deep in the crypto path. * no-mistakes(review): Raise SigningDisabled up front in pair() when private_key is None * no-mistakes(document): docs: correct AGENTS.md choke-point claim after pair() guard fix * fix(ble): use False, not None, to disable command signing An explicit `private_key=None` used to fall through to the parent's key and raise `ValueError("No private key.")` when there was none. The sentinel design in this PR changed that: `None` became the opt-out, so a caller who wrote `private_key=None` meaning "I haven't got one" silently got a vehicle that could not sign instead of the error that told them so. That is a behaviour change on a published, security-adjacent library, not an additive one. Drop `KeyOmitted`/`KEY_OMITTED` and restore `None` as the default. `False` is now the explicit "signing is disabled" value - a value no current caller can already be passing, so opting out has to be deliberate, and `None` keeps its existing meaning for both omitted and explicit-`None` callers. Because `False` and `None` are both falsy, the constructor branches on identity (`is False` / `is not None`); a truthiness check would collapse the two states and reintroduce the bug in a new form. `Vehicles.createBluetooth` had no `key` parameter at all, so the opt-out was unreachable from the Fleet-parented factory; it gains one as keyword-only (no positional shift), with the matching `NotImplementedError` overrides in Teslemetry/Tessie kept in step with their "parameters match the Fleet API Bluetooth factory" contract. The `SigningDisabled` guards at `_handshake` and in `pair()`'s fast path are unchanged.
1 parent f00612e commit f1822c8

9 files changed

Lines changed: 269 additions & 14 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Commands (vehicle/commands.py) - protobuf-based signed command implementation (A
7878
`Vehicles` (vehicle/vehicles.py) is a `dict[str, Vehicle]` with factory methods:
7979
- `createFleet(vin)``VehicleFleet`
8080
- `createSigned(vin)``VehicleSigned`
81-
- `createBluetooth(vin, confirmation="ack", keepalive_interval=..., raise_unconfirmed=False, *, verify_commands=None, optimistic=None)``VehicleBluetooth`
81+
- `createBluetooth(vin, confirmation="ack", keepalive_interval=..., raise_unconfirmed=False, *, verify_commands=None, optimistic=None, key=None)``VehicleBluetooth`
8282

8383
Teslemetry/Tessie override `Vehicles` with their own vehicle classes (`TeslemetryVehicle`, `TessieVehicle`) extending `VehicleFleet` with service-specific commands (e.g., `closure()`, `seat_heater()` for Teslemetry; `wake()`, `lock()` for Tessie).
8484

@@ -153,6 +153,7 @@ Keep the `tesla-protocol` floor at `>=0.5.0`; earlier releases have generated `.
153153
- **`VehicleAction`/`GetVehicleData` proto coverage is locked by test, not just by convention**: `tests/test_proto_coverage_lock.py` walks both descriptors and fails if any field has no wrapper (`commands.py`) or reader (`bluetooth.py`) and isn't on one of its two small, reasoned allowlists — keep that test in sync with any future `tesla-protocol` bump rather than special-casing new fields elsewhere. The only fields deliberately left unwrapped today are the 7-field push-style subscription/streaming family (`createStreamSession`/`streamMessage`/`vehicleDataSubscription`/`vehicleDataAck`/`vitalsSubscription`/`vitalsAck`/`cancelVehicleDataSubscription`, which need a public lifecycle/iterator API atop the private `_stream_sinks` routing above) and `getVehicleImageState` (needs chunked binary-transfer paging). CarServer's `GetVehicleState` sub-state is exposed as `legacy_vehicle_state()` (`bluetooth.py`), matching the `VehicleData.legacy_vehicle_state` reply field name, to avoid confusion with `vehicle_state()` (VCSEC `VehicleStatus`, a different message/domain). `set_rate_tariff`/`add_managed_charging_site` (`commands.py`) take `tesla_protocol` message types directly for their deeply-nested arguments rather than a parallel flattened dataclass API.
154154
- **Energy-gateway authorized-client pairing has security- and protocol-specific constraints**: use RSA for LAN TEDapi v1r, treat `PENDING_VERIFICATION_TIMEOUT` as terminal, and account for presence-free key removal. The authoritative pairing, retry, encoding, and removal guidance is in `docs/energy_local_control.md`; enum values and API contracts live in `const.py` and the relevant method docstrings.
155155
- **`register_client()` (`teslemetry/teslemetry.py`) is Teslemetry-only OAuth Dynamic Client Registration (RFC 7591)**: a module-level function, not a `Teslemetry` instance method, since registration precedes having a `client_id` or access token — callers pass a bare `aiohttp.ClientSession`. It always registers a new client (no dedup/caching) and raises `TeslemetryRegistrationError` (`exceptions.py`) on transport failure, a non-2xx response, a non-JSON body, or a response missing a usable `client_id`; a non-dict-but-valid-JSON body (list/scalar) is treated as the same malformed-response error rather than raising an uncaught `AttributeError`. Fleet API and Tessie have no equivalent — don't add one speculatively. See `docs/teslemetry.md`'s "OAuth Dynamic Client Registration" section and `tests/test_teslemetry_register_client.py`.
156+
- **`False`, not `None`, is the "signing is disabled" value for `Commands.__init__`'s `private_key` (and `VehicleBluetooth.__init__`/`Vehicles.createBluetooth`/`VehiclesBluetooth.create`/`createBluetooth`'s `key`)**: `None` — the default and an explicit `None` — keeps its long-standing meaning of falling back to the parent's key, raising `ValueError("No private key.")` if it has none; `False` disables signing for a passive BLE listener that only observes broadcasts. `None` is deliberately *not* the opt-out: a caller already passing `private_key=None` to mean "I haven't got one" must keep getting that `ValueError`, not a silently unsignable vehicle. Because `False` and `None` are both falsy, every branch on this argument must test **identity** (`is False`/`is not None`) — a truthiness check (`if private_key:`) collapses the two states and reintroduces the bug. `self.private_key` is `EllipticCurvePrivateKey | None`, its `None` meaning signing-disabled — `_handshake` (reached by `_command`, i.e. every signed command, and by `_ensure_handshake`, used by signed reads) raises `SigningDisabled` (`exceptions.py`) up front rather than failing deep in the signing/crypto path. `pair()`'s fast path never calls `_handshake` (it builds and sends its own whitelist request directly), so it carries its own identical guard at the top instead — `_handshake` is not a single choke point every signed-session entry point routes through; each entry point that doesn't call it needs its own `self.private_key is None` check. Tests: `tests/test_ble_null_key.py`.
156157

157158
## Maintaining this file
158159

docs/bluetooth_vehicles.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,26 @@ the `unsubscribe()` closure returned at registration.
574574
Callback exceptions are logged and do not stop later listeners or normal
575575
message routing; `KeyboardInterrupt` and `SystemExit` still propagate.
576576

577+
### Passive listening without a private key
578+
579+
A vehicle that only decodes broadcasts and never sends a command has no
580+
reason to hold a signing key. Pass `key=False` to `vehicles.create` (or
581+
`VehicleBluetooth` directly) to construct without one:
582+
583+
```python
584+
vehicle = tesla_bluetooth.vehicles.create("<vin>", key=False)
585+
```
586+
587+
`key=None` - the default, and an explicit `None` - still falls back to the
588+
parent's private key exactly as before, raising `ValueError("No private
589+
key.")` if the parent has none. Only `False` disables signing, so a caller
590+
already passing `key=None` to mean "I haven't got one" keeps getting that
591+
error rather than silently ending up with a vehicle that cannot sign.
592+
593+
Reads and listeners that don't need a signed session still work; any command
594+
that does raises `SigningDisabled` naming that signing was explicitly disabled
595+
for this vehicle.
596+
577597
### Connection-status events
578598

579599
Use `listen_connection_status(callback)` to receive BLE session transitions

tesla_fleet_api/exceptions.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,23 @@ class LibraryError(Exception):
408408
"""Errors related to this library."""
409409

410410

411+
class SigningDisabled(LibraryError):
412+
"""A signed operation was attempted on a vehicle constructed with signing explicitly disabled.
413+
414+
Pass ``private_key=False`` only for a passive listener that never sends a
415+
command; construct with a real key (or leave the argument at ``None`` to
416+
inherit the parent's) to issue signed commands.
417+
"""
418+
419+
def __init__(self) -> None:
420+
super().__init__(
421+
"This vehicle was constructed with private_key=False, explicitly "
422+
"disabling command signing. It can only observe unsolicited "
423+
"broadcasts (the listen_* methods); any signed command or read "
424+
"needs a real private_key."
425+
)
426+
427+
411428
class SignedCommandRequired(TeslaFleetError):
412429
"""The requested action requires a signed command; the unsigned cloud API cannot actuate it.
413430

tesla_fleet_api/tesla/vehicle/bluetooth.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import warnings
88
from collections import deque
99
from random import randbytes
10-
from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar
10+
from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, TypeVar
1111

1212
import bleak
1313
from bleak.backends.characteristic import BleakGATTCharacteristic
@@ -24,6 +24,7 @@
2424
BluetoothTimeout,
2525
BluetoothTransportError,
2626
BluetoothUnconfirmedCommand,
27+
SigningDisabled,
2728
TeslaFleetError,
2829
WhitelistOperationStatus,
2930
)
@@ -512,7 +513,7 @@ def __init__(
512513
self,
513514
parent: BluetoothParentT,
514515
vin: str,
515-
key: ec.EllipticCurvePrivateKey | None = None,
516+
key: ec.EllipticCurvePrivateKey | Literal[False] | None = None,
516517
device: BLEDevice | None = None,
517518
confirmation: BluetoothConfirmation | bool = "ack",
518519
keepalive_interval: float | None = DEFAULT_KEEPALIVE_INTERVAL,
@@ -529,6 +530,11 @@ def __init__(
529530
``confirmation``, overriding any value passed there (a ``True``
530531
``optimistic`` wins over a ``True`` ``verify_commands`` if both are
531532
somehow passed, matching the old dominance order).
533+
534+
Passing ``key=False`` explicitly disables command signing, for a
535+
passive listener that only observes broadcasts via the ``listen_*``
536+
methods and never sends a command. ``key=None`` (the default, and an
537+
explicit ``None``) keeps the usual fallback to the parent's key.
532538
"""
533539
super().__init__(parent, vin, key)
534540
if isinstance(confirmation, bool):
@@ -1519,6 +1525,9 @@ async def pair(
15191525
if poll_interval <= 0:
15201526
raise ValueError("poll_interval must be greater than 0")
15211527

1528+
if self.private_key is None:
1529+
raise SigningDisabled()
1530+
15221531
request = UnsignedMessage(
15231532
WhitelistOperation=WhitelistOperation(
15241533
addKeyToWhitelistAndAddPermissions=PermissionChange(

tesla_fleet_api/tesla/vehicle/commands.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
NotOnWhitelistFault,
3030
SessionInfoAuthenticationFault,
3131
SignedCommandResponseReplayed,
32+
SigningDisabled,
3233
TeslaFleetError,
3334
# TeslaFleetMessageFaultInvalidSignature,
3435
TeslaFleetMessageFaultIncorrectEpoch,
@@ -428,7 +429,7 @@ def aes_gcm_personalized(self) -> AES_GCM_Personalized_Signature_Data:
428429
class Commands(ABC, Vehicle[CommandParentT], Generic[CommandParentT]):
429430
"""Class describing the Tesla Fleet API vehicle endpoints and commands for a specific vehicle with command signing."""
430431

431-
private_key: ec.EllipticCurvePrivateKey
432+
private_key: ec.EllipticCurvePrivateKey | None
432433
_public_key: bytes
433434
_from_destination: bytes
434435
_sessions: dict[int, Session[CommandParentT]]
@@ -440,9 +441,19 @@ def __init__(
440441
self,
441442
parent: CommandParentT,
442443
vin: str,
443-
private_key: ec.EllipticCurvePrivateKey | None = None,
444+
private_key: ec.EllipticCurvePrivateKey | Literal[False] | None = None,
444445
public_key: bytes | None = None,
445446
):
447+
"""Initialize with a signing key, or ``private_key=False`` to disable signing.
448+
449+
``None`` (the default, and an explicit ``None``) keeps the long-standing
450+
behaviour: fall back to the parent's key, raising ``ValueError`` if it
451+
has none. Passing ``private_key=False`` explicitly disables signing for
452+
this vehicle - for a passive BLE listener that only observes broadcasts
453+
and never sends a command. ``False`` is used rather than ``None`` so
454+
that no caller who already passes ``private_key=None`` meaning "I
455+
haven't got one" silently gets a vehicle that cannot sign.
456+
"""
446457
super().__init__(parent, vin)
447458

448459
self._from_destination = randbytes(16)
@@ -453,19 +464,30 @@ def __init__(
453464
Domain.DOMAIN_INFOTAINMENT: Session(self, Domain.DOMAIN_INFOTAINMENT),
454465
}
455466

456-
if private_key:
467+
# Identity checks, not truthiness: ``False`` and ``None`` are both
468+
# falsy, and collapsing them would make an explicit ``None`` silently
469+
# disable signing instead of falling back to the parent's key.
470+
if private_key is False:
471+
self.private_key = None
472+
elif private_key is not None:
457473
self.private_key = private_key
458-
elif parent.private_key:
474+
elif parent.private_key is not None:
459475
self.private_key = parent.private_key
460476
else:
461477
raise ValueError("No private key.")
462478

463-
self._public_key = public_key or self.private_key.public_key().public_bytes(
464-
encoding=Encoding.X962, format=PublicFormat.UncompressedPoint
479+
self._public_key = public_key or (
480+
self.private_key.public_key().public_bytes(
481+
encoding=Encoding.X962, format=PublicFormat.UncompressedPoint
482+
)
483+
if self.private_key is not None
484+
else b""
465485
)
466486

467487
def shared_key(self, vehicleKey: bytes) -> bytes:
468488
"""Derive the 16-byte shared key used for signed-command session encryption."""
489+
if self.private_key is None:
490+
raise SigningDisabled()
469491
exchange = self.private_key.exchange(
470492
ec.ECDH(),
471493
ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), vehicleKey),
@@ -1088,6 +1110,8 @@ async def handshakeInfotainment(self) -> None:
10881110

10891111
async def _handshake(self, domain: Domain) -> bool:
10901112
"""Perform a handshake with the vehicle."""
1113+
if self.private_key is None:
1114+
raise SigningDisabled()
10911115

10921116
LOGGER.debug(f"Handshake with domain {Domain.Name(domain)}")
10931117
msg = RoutableMessage(

tesla_fleet_api/tesla/vehicle/vehicles.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from __future__ import annotations
2-
from typing import TYPE_CHECKING, Any, Generic, TypeVar
2+
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar
33

44
from bleak.backends.device import BLEDevice
55
from cryptography.hazmat.primitives.asymmetric import ec
@@ -53,6 +53,7 @@ def createBluetooth(
5353
raise_unconfirmed: bool = False,
5454
*,
5555
verify_commands: bool | None = None,
56+
key: ec.EllipticCurvePrivateKey | Literal[False] | None = None,
5657
) -> VehicleBluetooth[FleetParentT]:
5758
"""Creates a bluetooth vehicle that uses command protocol.
5859
@@ -67,10 +68,13 @@ def createBluetooth(
6768
success. ``verify_commands``/``optimistic`` are deprecated aliases for
6869
``confirmation="verify"``/``confirmation="optimistic"``. See
6970
``VehicleBluetooth``'s docstring for the full ladder.
71+
``key=False`` explicitly disables signing, for a passive listener;
72+
``key=None`` (the default) keeps the usual parent-key fallback.
7073
"""
7174
vehicle = self.Bluetooth(
7275
self._parent,
7376
vin,
77+
key,
7478
confirmation=confirmation,
7579
keepalive_interval=keepalive_interval,
7680
optimistic=optimistic,
@@ -101,7 +105,7 @@ def __init__(self, parent: BluetoothClientT):
101105
def create(
102106
self,
103107
vin: str,
104-
key: ec.EllipticCurvePrivateKey | None = None,
108+
key: ec.EllipticCurvePrivateKey | Literal[False] | None = None,
105109
device: BLEDevice | None = None,
106110
confirmation: BluetoothConfirmation | bool = "ack",
107111
keepalive_interval: float | None = DEFAULT_KEEPALIVE_INTERVAL,
@@ -123,6 +127,8 @@ def create(
123127
success. ``verify_commands``/``optimistic`` are deprecated aliases for
124128
``confirmation="verify"``/``confirmation="optimistic"``. See
125129
``VehicleBluetooth``'s docstring for the full ladder.
130+
``key=False`` explicitly disables signing, for a passive listener;
131+
``key=None`` (the default) keeps the usual parent-key fallback.
126132
"""
127133
return self.createBluetooth(
128134
vin,
@@ -138,7 +144,7 @@ def create(
138144
def createBluetooth(
139145
self,
140146
vin: str,
141-
key: ec.EllipticCurvePrivateKey | None = None,
147+
key: ec.EllipticCurvePrivateKey | Literal[False] | None = None,
142148
device: BLEDevice | None = None,
143149
confirmation: BluetoothConfirmation | bool = "ack",
144150
keepalive_interval: float | None = DEFAULT_KEEPALIVE_INTERVAL,
@@ -160,6 +166,8 @@ def createBluetooth(
160166
success. ``verify_commands``/``optimistic`` are deprecated aliases for
161167
``confirmation="verify"``/``confirmation="optimistic"``. See
162168
``VehicleBluetooth``'s docstring for the full ladder.
169+
``key=False`` explicitly disables signing, for a passive listener;
170+
``key=None`` (the default) keeps the usual parent-key fallback.
163171
"""
164172
vehicle = self.Bluetooth(
165173
self._parent,

tesla_fleet_api/teslemetry/vehicle.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any
3+
from typing import TYPE_CHECKING, Any, Literal
4+
5+
from cryptography.hazmat.primitives.asymmetric import ec
46

57
from tesla_fleet_api.const import (
68
BluetoothConfirmation,
@@ -673,6 +675,7 @@ def createBluetooth(
673675
raise_unconfirmed: bool = False,
674676
*,
675677
verify_commands: bool | None = None,
678+
key: ec.EllipticCurvePrivateKey | Literal[False] | None = None,
676679
) -> Any:
677680
"""Not supported; parameters match the Fleet API Bluetooth factory."""
678681
raise NotImplementedError("Teslemetry cannot use local Bluetooth")

tesla_fleet_api/tessie/vehicle.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
2-
from typing import TYPE_CHECKING, Any
2+
from typing import TYPE_CHECKING, Any, Literal
3+
4+
from cryptography.hazmat.primitives.asymmetric import ec
35

46
from tesla_fleet_api.const import BluetoothConfirmation, Method
57
from tesla_fleet_api.tesla.vehicle.vehicles import Vehicles
@@ -1222,6 +1224,7 @@ def createBluetooth(
12221224
raise_unconfirmed: bool = False,
12231225
*,
12241226
verify_commands: bool | None = None,
1227+
key: ec.EllipticCurvePrivateKey | Literal[False] | None = None,
12251228
) -> Any:
12261229
"""Not supported; parameters match the Fleet API Bluetooth factory."""
12271230
raise NotImplementedError("Tessie cannot use local Bluetooth")

0 commit comments

Comments
 (0)