Skip to content

Commit 8d03ce7

Browse files
committed
feat(tesla): raise PrivateKeyError for unusable existing key files
Home Assistant has to catch OSError/ValueError/TypeError/AssertionError at multiple sites to handle an unreadable, malformed, encrypted, or wrong-type key file. Give get_private_key/get_rsa_private_key one typed exception with a reason instead. Claude-Session: https://claude.ai/code/session_01D7girHWFLWBu78by9NL3qE
1 parent f4c773c commit 8d03ce7

4 files changed

Lines changed: 189 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,14 @@ asyncio.run(main())
172172
For more detailed examples, see [Bluetooth for Vehicles](docs/bluetooth_vehicles.md).
173173

174174
`get_private_key(path)` loads an existing EC private key or creates a new
175-
unencrypted PEM key file. Newly created key files are created owner-readable
176-
and owner-writable only (`0600`) from the start, with no write-then-chmod
177-
window, and concurrent creators fall back to reading the file that won the
178-
create race.
175+
unencrypted PEM key file, and `get_rsa_private_key(path)` does the same for an
176+
RSA key. Newly created key files are created owner-readable and
177+
owner-writable only (`0600`) from the start, with no write-then-chmod window,
178+
and concurrent creators fall back to reading the file that won the create
179+
race. If an existing key file can't be read, isn't valid PEM, is
180+
password-encrypted, or is the wrong key type, both raise `PrivateKeyError`
181+
(a `TeslaFleetError`) with a `reason` of `"unreadable"`, `"malformed"`,
182+
`"encrypted"`, or `"wrong_type"`.
179183

180184
`VehicleBluetooth` keeps a held BLE connection alive during idle periods by
181185
default with a passive GATT read about every 20 seconds. Pass

‎tesla_fleet_api/exceptions.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,23 @@ class DeviceUnexpectedResponse(TeslaFleetError):
404404
status = 540
405405

406406

407+
class PrivateKeyError(TeslaFleetError):
408+
"""An existing private key file could not be loaded as a usable key.
409+
410+
Raised by ``Tesla.get_private_key``/``get_rsa_private_key`` only for a
411+
known-existing key file's read/parse failure - key generation and the
412+
O_EXCL create-race fallback keep raising their original exceptions.
413+
``reason`` is one of ``"unreadable"`` (I/O failure), ``"malformed"`` (not
414+
valid PEM), ``"encrypted"`` (PEM requires a passphrase), or
415+
``"wrong_type"`` (loaded key is not the expected type).
416+
"""
417+
418+
def __init__(self, reason: str, message: str) -> None:
419+
self.reason = reason
420+
self.message = message
421+
super().__init__()
422+
423+
407424
class LibraryError(Exception):
408425
"""Errors related to this library."""
409426

‎tesla_fleet_api/tesla/tesla.py‎

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
import sys
88
import time
99
from os.path import exists
10+
from typing import TypeVar
1011
import aiofiles
1112

1213
from tesla_fleet_api.const import LOGGER
14+
from tesla_fleet_api.exceptions import PrivateKeyError
1315
from tesla_fleet_api.tesla.charging import Charging
1416
from tesla_fleet_api.tesla.energysite import EnergySites
1517
from tesla_fleet_api.tesla.partner import Partner
@@ -214,6 +216,46 @@ async def _load_pem_private_key(
214216
await asyncio.sleep(_KEY_READ_RETRY_INTERVAL)
215217

216218

219+
_KeyT = TypeVar("_KeyT", ec.EllipticCurvePrivateKey, rsa.RSAPrivateKey)
220+
221+
222+
async def _load_existing_private_key(
223+
path: str,
224+
expected_type: type[_KeyT],
225+
unsafe_skip_rsa_key_validation: bool = False,
226+
) -> _KeyT:
227+
"""Read and parse a key file already known to exist, raising ``PrivateKeyError`` for every failure shape.
228+
229+
Only covers a known-existing file's read/parse - key generation and the
230+
O_EXCL create-race fallback are separate call sites that keep raising
231+
their original exceptions.
232+
"""
233+
try:
234+
value = await _load_pem_private_key(
235+
path,
236+
retry_invalid=True,
237+
unsafe_skip_rsa_key_validation=unsafe_skip_rsa_key_validation,
238+
)
239+
except OSError as err:
240+
raise PrivateKeyError(
241+
"unreadable", f"Could not read private key file at {path}"
242+
) from err
243+
except TypeError as err:
244+
raise PrivateKeyError(
245+
"encrypted", f"Private key file at {path} is encrypted"
246+
) from err
247+
except ValueError as err:
248+
raise PrivateKeyError(
249+
"malformed", f"Private key file at {path} is not a valid PEM private key"
250+
) from err
251+
if not isinstance(value, expected_type):
252+
raise PrivateKeyError(
253+
"wrong_type",
254+
f"Private key file at {path} is not a {expected_type.__name__}",
255+
)
256+
return value
257+
258+
217259
class Tesla:
218260
"""Base class describing interactions with Tesla products."""
219261

@@ -258,15 +300,7 @@ async def get_private_key(
258300
self.private_key = value
259301
return self.private_key
260302

261-
try:
262-
value = await _load_pem_private_key(path, retry_invalid=True)
263-
except FileNotFoundError:
264-
raise FileNotFoundError(f"Private key file not found at {path}")
265-
except PermissionError:
266-
raise PermissionError(f"Permission denied when trying to read {path}")
267-
268-
if not isinstance(value, ec.EllipticCurvePrivateKey):
269-
raise AssertionError("Loaded key is not an EllipticCurvePrivateKey")
303+
value = await _load_existing_private_key(path, ec.EllipticCurvePrivateKey)
270304
self.private_key = value
271305
return self.private_key
272306

@@ -350,13 +384,9 @@ async def get_rsa_private_key(
350384
self.rsa_private_key = value
351385
return self.rsa_private_key
352386

353-
value = await _load_pem_private_key(
354-
path,
355-
retry_invalid=True,
356-
unsafe_skip_rsa_key_validation=skip_rsa_key_validation,
387+
value = await _load_existing_private_key(
388+
path, rsa.RSAPrivateKey, skip_rsa_key_validation
357389
)
358-
if not isinstance(value, rsa.RSAPrivateKey):
359-
raise AssertionError("Loaded key is not an RSAPrivateKey")
360390
self.rsa_private_key = value
361391
return self.rsa_private_key
362392

‎tests/test_tesla_private_key.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from cryptography.hazmat.primitives import serialization
2020
from cryptography.hazmat.primitives.asymmetric import ec, rsa
2121

22+
from tesla_fleet_api.exceptions import PrivateKeyError
2223
from tesla_fleet_api.tesla.tesla import Tesla
2324

2425

@@ -715,3 +716,121 @@ async def test_defaults_unchanged_for_existing_rsa_key_read(self) -> None:
715716
read_back = await Tesla().get_rsa_private_key(path, key_size=1024)
716717

717718
self.assertEqual(_rsa_pem(read_back), _rsa_pem(created))
719+
720+
721+
class PrivateKeyErrorTests(IsolatedAsyncioTestCase):
722+
"""An existing-but-unusable key file must raise ``PrivateKeyError`` with the right reason."""
723+
724+
async def test_ec_loader_unreadable(self) -> None:
725+
with tempfile.TemporaryDirectory() as tmp_dir:
726+
path = str(Path(tmp_dir) / "private_key.pem")
727+
os.mkdir(path)
728+
729+
with self.assertRaises(PrivateKeyError) as ctx:
730+
await Tesla().get_private_key(path)
731+
732+
self.assertEqual(ctx.exception.reason, "unreadable")
733+
self.assertIsInstance(ctx.exception.__cause__, OSError)
734+
self.assertIn(path, ctx.exception.message)
735+
736+
async def test_ec_loader_malformed(self) -> None:
737+
with tempfile.TemporaryDirectory() as tmp_dir:
738+
path = str(Path(tmp_dir) / "private_key.pem")
739+
Path(path).write_bytes(b"not a pem file")
740+
741+
with self.assertRaises(PrivateKeyError) as ctx:
742+
await Tesla().get_private_key(path)
743+
744+
self.assertEqual(ctx.exception.reason, "malformed")
745+
self.assertIsInstance(ctx.exception.__cause__, ValueError)
746+
self.assertIn(path, ctx.exception.message)
747+
748+
async def test_ec_loader_encrypted(self) -> None:
749+
with tempfile.TemporaryDirectory() as tmp_dir:
750+
path = str(Path(tmp_dir) / "private_key.pem")
751+
key = ec.generate_private_key(ec.SECP256R1())
752+
pem = key.private_bytes(
753+
encoding=serialization.Encoding.PEM,
754+
format=serialization.PrivateFormat.TraditionalOpenSSL,
755+
encryption_algorithm=serialization.BestAvailableEncryption(
756+
b"correct horse battery staple"
757+
),
758+
)
759+
Path(path).write_bytes(pem)
760+
761+
with self.assertRaises(PrivateKeyError) as ctx:
762+
await Tesla().get_private_key(path)
763+
764+
self.assertEqual(ctx.exception.reason, "encrypted")
765+
self.assertIsInstance(ctx.exception.__cause__, TypeError)
766+
self.assertIn(path, ctx.exception.message)
767+
768+
async def test_ec_loader_wrong_type(self) -> None:
769+
with tempfile.TemporaryDirectory() as tmp_dir:
770+
path = str(Path(tmp_dir) / "private_key.pem")
771+
key = rsa.generate_private_key(public_exponent=65537, key_size=1024)
772+
Path(path).write_bytes(_rsa_pem(key))
773+
774+
with self.assertRaises(PrivateKeyError) as ctx:
775+
await Tesla().get_private_key(path)
776+
777+
self.assertEqual(ctx.exception.reason, "wrong_type")
778+
self.assertIsNone(ctx.exception.__cause__)
779+
self.assertIn(path, ctx.exception.message)
780+
781+
async def test_rsa_loader_unreadable(self) -> None:
782+
with tempfile.TemporaryDirectory() as tmp_dir:
783+
path = str(Path(tmp_dir) / "tedapi_rsa_private.pem")
784+
os.mkdir(path)
785+
786+
with self.assertRaises(PrivateKeyError) as ctx:
787+
await Tesla().get_rsa_private_key(path, key_size=1024)
788+
789+
self.assertEqual(ctx.exception.reason, "unreadable")
790+
self.assertIsInstance(ctx.exception.__cause__, OSError)
791+
self.assertIn(path, ctx.exception.message)
792+
793+
async def test_rsa_loader_malformed(self) -> None:
794+
with tempfile.TemporaryDirectory() as tmp_dir:
795+
path = str(Path(tmp_dir) / "tedapi_rsa_private.pem")
796+
Path(path).write_bytes(b"not a pem file")
797+
798+
with self.assertRaises(PrivateKeyError) as ctx:
799+
await Tesla().get_rsa_private_key(path, key_size=1024)
800+
801+
self.assertEqual(ctx.exception.reason, "malformed")
802+
self.assertIsInstance(ctx.exception.__cause__, ValueError)
803+
self.assertIn(path, ctx.exception.message)
804+
805+
async def test_rsa_loader_encrypted(self) -> None:
806+
with tempfile.TemporaryDirectory() as tmp_dir:
807+
path = str(Path(tmp_dir) / "tedapi_rsa_private.pem")
808+
key = rsa.generate_private_key(public_exponent=65537, key_size=1024)
809+
pem = key.private_bytes(
810+
encoding=serialization.Encoding.PEM,
811+
format=serialization.PrivateFormat.TraditionalOpenSSL,
812+
encryption_algorithm=serialization.BestAvailableEncryption(
813+
b"correct horse battery staple"
814+
),
815+
)
816+
Path(path).write_bytes(pem)
817+
818+
with self.assertRaises(PrivateKeyError) as ctx:
819+
await Tesla().get_rsa_private_key(path, key_size=1024)
820+
821+
self.assertEqual(ctx.exception.reason, "encrypted")
822+
self.assertIsInstance(ctx.exception.__cause__, TypeError)
823+
self.assertIn(path, ctx.exception.message)
824+
825+
async def test_rsa_loader_wrong_type(self) -> None:
826+
with tempfile.TemporaryDirectory() as tmp_dir:
827+
path = str(Path(tmp_dir) / "tedapi_rsa_private.pem")
828+
key = ec.generate_private_key(ec.SECP256R1())
829+
Path(path).write_bytes(_ec_pem(key))
830+
831+
with self.assertRaises(PrivateKeyError) as ctx:
832+
await Tesla().get_rsa_private_key(path, key_size=1024)
833+
834+
self.assertEqual(ctx.exception.reason, "wrong_type")
835+
self.assertIsNone(ctx.exception.__cause__)
836+
self.assertIn(path, ctx.exception.message)

0 commit comments

Comments
 (0)