Skip to content

Commit e49f1e0

Browse files
authored
fix(ble): guard empty publicKey in session-info authentication (#138)
* fix(ble): raise NotOnWhitelistFault instead of crashing on empty publicKey A KEY_NOT_ON_WHITELIST session_info reply carries no publicKey, since no session exists yet for an unpaired key. _authenticate_session_info derived shared keys from that key before checking status, raising an uncaught ValueError from cryptography instead of NotOnWhitelistFault - breaking the approve-key-in-vehicle pairing flow for every first-time BLE pairing. Check for an empty publicKey before key derivation: a whitelist-rejection status is accepted unauthenticated (no shared key can be derived to verify it), and any other status with an empty key raises a typed SessionInfoAuthenticationFault instead of the raw ValueError. * no-mistakes(document): Fix stale SessionInfoAuthenticationFault docstring for empty-publicKey case
1 parent f1822c8 commit e49f1e0

6 files changed

Lines changed: 69 additions & 5 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ requires = ["setuptools>=77.0"]
44

55
[project]
66
name = "tesla_fleet_api"
7-
version = "1.11.0"
7+
version = "1.11.1"
88
license = "Apache-2.0"
99
description = "Tesla Fleet API library for Python"
1010
readme = "README.md"

tesla_fleet_api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Tesla Fleet API"""
22

33
__author__ = "hello@teslemetry.com"
4-
__version__ = "1.11.0"
4+
__version__ = "1.11.1"
55

66
from tesla_fleet_api.const import Region, is_valid_region
77
from tesla_fleet_api.funnel import (

tesla_fleet_api/exceptions.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,11 @@ class SessionInfoAuthenticationFault(TeslaFleetError):
480480
Raised when the reply's ``session_info_tag`` HMAC does not verify, is
481481
absent, the echoed ``request_uuid`` does not match the outstanding
482482
request it claims to answer, or its clock time regresses within the same
483-
epoch. The session's prior state is left unmodified.
483+
epoch. Also raised when the reply carries an empty ``publicKey`` (so no
484+
shared key can be derived to verify a tag) with a status other than
485+
``SESSION_INFO_STATUS_KEY_NOT_ON_WHITELIST``, which raises
486+
``NotOnWhitelistFault`` instead. The session's prior state is left
487+
unmodified.
484488
"""
485489

486490
message = "Session info reply failed authentication and was discarded."

tesla_fleet_api/tesla/vehicle/commands.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,12 @@ def _authenticate_session_info(
544544
replayed against a newer request. Only once that tag checks out do we
545545
act on anything the message claims, including its own whitelist
546546
status, and even then ``Session.commit`` still refuses a clock time
547-
that regresses within the same epoch.
547+
that regresses within the same epoch. The one exception is an empty
548+
public key: no shared key can be derived from it to verify a tag, so
549+
a key-not-on-whitelist status - the only real-world reply that omits
550+
the key, since no session exists yet for an unpaired key - is
551+
accepted unauthenticated; any other status paired with an empty key
552+
is malformed and rejected outright.
548553
549554
VCSEC typically leaves the wire-level ``request_uuid`` field empty on
550555
real hardware (memory constraints) - its absence must never be
@@ -558,6 +563,22 @@ def _authenticate_session_info(
558563

559564
session = self._sessions[msg.from_destination.domain]
560565
info = SessionInfo.FromString(msg.session_info)
566+
567+
# A key-not-on-whitelist reply carries no publicKey (no session exists
568+
# for an unpaired key), so it cannot be HMAC-verified; accept that one
569+
# status unauthenticated rather than deriving keys from an empty
570+
# point. Any other status with an empty key is malformed, not this
571+
# known case, so it still raises rather than being silently accepted.
572+
if not info.publicKey:
573+
if (
574+
info.status
575+
== Session_Info_Status.SESSION_INFO_STATUS_KEY_NOT_ON_WHITELIST
576+
):
577+
raise NotOnWhitelistFault
578+
raise SessionInfoAuthenticationFault(
579+
"Session info reply has no public key."
580+
)
581+
561582
shared_key, hmac_key, session_info_key = session.keys_for(info.publicKey)
562583

563584
tag = msg.signature_data.session_info_tag.tag

tests/test_session_info_authentication.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,45 @@ def test_authenticated_whitelist_rejection_still_raises_not_on_whitelist(
338338
self.commands.validate_msg(reply, self.request_uuid)
339339
self.assertFalse(self.commands._sessions[self.domain].ready)
340340

341+
def test_empty_public_key_whitelist_rejection_raises_not_on_whitelist(self) -> None:
342+
# Real-world VCSEC reply for an unpaired key: no session exists to
343+
# derive a shared key from, so publicKey is empty. Must not attempt
344+
# key derivation (which previously raised a raw ValueError) and must
345+
# still surface as NotOnWhitelistFault, unauthenticated.
346+
info = self._session_info(
347+
publicKey=b"",
348+
status=Session_Info_Status.SESSION_INFO_STATUS_KEY_NOT_ON_WHITELIST,
349+
)
350+
signature_data = SignatureData(
351+
session_info_tag=HMAC_Signature_Data(tag=b"\x00" * 32)
352+
)
353+
reply = RoutableMessage(
354+
from_destination=Destination(domain=self.domain),
355+
session_info=info.SerializeToString(),
356+
request_uuid=self.request_uuid,
357+
signature_data=signature_data,
358+
)
359+
with self.assertRaises(NotOnWhitelistFault):
360+
self.commands.validate_msg(reply, self.request_uuid)
361+
self.assertFalse(self.commands._sessions[self.domain].ready)
362+
363+
def test_empty_public_key_with_other_status_raises_typed_fault(self) -> None:
364+
info = self._session_info(
365+
publicKey=b"", status=Session_Info_Status.SESSION_INFO_STATUS_OK
366+
)
367+
signature_data = SignatureData(
368+
session_info_tag=HMAC_Signature_Data(tag=b"\x00" * 32)
369+
)
370+
reply = RoutableMessage(
371+
from_destination=Destination(domain=self.domain),
372+
session_info=info.SerializeToString(),
373+
request_uuid=self.request_uuid,
374+
signature_data=signature_data,
375+
)
376+
with self.assertRaises(SessionInfoAuthenticationFault):
377+
self.commands.validate_msg(reply, self.request_uuid)
378+
self.assertFalse(self.commands._sessions[self.domain].ready)
379+
341380

342381
class CounterAndClockMonotonicityTests(IsolatedAsyncioTestCase):
343382
"""Covers commands.py's Session.commit: clamp within an epoch, refuse a

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)