Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ requires = ["setuptools>=77.0"]

[project]
name = "tesla_fleet_api"
version = "1.11.0"
version = "1.11.1"
license = "Apache-2.0"
description = "Tesla Fleet API library for Python"
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion tesla_fleet_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tesla Fleet API"""

__author__ = "hello@teslemetry.com"
__version__ = "1.11.0"
__version__ = "1.11.1"

from tesla_fleet_api.const import Region, is_valid_region
from tesla_fleet_api.funnel import (
Expand Down
6 changes: 5 additions & 1 deletion tesla_fleet_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,11 @@ class SessionInfoAuthenticationFault(TeslaFleetError):
Raised when the reply's ``session_info_tag`` HMAC does not verify, is
absent, the echoed ``request_uuid`` does not match the outstanding
request it claims to answer, or its clock time regresses within the same
epoch. The session's prior state is left unmodified.
epoch. Also raised when the reply carries an empty ``publicKey`` (so no
shared key can be derived to verify a tag) with a status other than
``SESSION_INFO_STATUS_KEY_NOT_ON_WHITELIST``, which raises
``NotOnWhitelistFault`` instead. The session's prior state is left
unmodified.
"""

message = "Session info reply failed authentication and was discarded."
Expand Down
23 changes: 22 additions & 1 deletion tesla_fleet_api/tesla/vehicle/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,12 @@ def _authenticate_session_info(
replayed against a newer request. Only once that tag checks out do we
act on anything the message claims, including its own whitelist
status, and even then ``Session.commit`` still refuses a clock time
that regresses within the same epoch.
that regresses within the same epoch. The one exception is an empty
public key: no shared key can be derived from it to verify a tag, so
a key-not-on-whitelist status - the only real-world reply that omits
the key, since no session exists yet for an unpaired key - is
accepted unauthenticated; any other status paired with an empty key
is malformed and rejected outright.

VCSEC typically leaves the wire-level ``request_uuid`` field empty on
real hardware (memory constraints) - its absence must never be
Expand All @@ -558,6 +563,22 @@ def _authenticate_session_info(

session = self._sessions[msg.from_destination.domain]
info = SessionInfo.FromString(msg.session_info)

# A key-not-on-whitelist reply carries no publicKey (no session exists
# for an unpaired key), so it cannot be HMAC-verified; accept that one
# status unauthenticated rather than deriving keys from an empty
# point. Any other status with an empty key is malformed, not this
# known case, so it still raises rather than being silently accepted.
if not info.publicKey:
if (
info.status
== Session_Info_Status.SESSION_INFO_STATUS_KEY_NOT_ON_WHITELIST
):
raise NotOnWhitelistFault
raise SessionInfoAuthenticationFault(
"Session info reply has no public key."
)

shared_key, hmac_key, session_info_key = session.keys_for(info.publicKey)

tag = msg.signature_data.session_info_tag.tag
Expand Down
39 changes: 39 additions & 0 deletions tests/test_session_info_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,45 @@ def test_authenticated_whitelist_rejection_still_raises_not_on_whitelist(
self.commands.validate_msg(reply, self.request_uuid)
self.assertFalse(self.commands._sessions[self.domain].ready)

def test_empty_public_key_whitelist_rejection_raises_not_on_whitelist(self) -> None:
# Real-world VCSEC reply for an unpaired key: no session exists to
# derive a shared key from, so publicKey is empty. Must not attempt
# key derivation (which previously raised a raw ValueError) and must
# still surface as NotOnWhitelistFault, unauthenticated.
info = self._session_info(
publicKey=b"",
status=Session_Info_Status.SESSION_INFO_STATUS_KEY_NOT_ON_WHITELIST,
)
signature_data = SignatureData(
session_info_tag=HMAC_Signature_Data(tag=b"\x00" * 32)
)
reply = RoutableMessage(
from_destination=Destination(domain=self.domain),
session_info=info.SerializeToString(),
request_uuid=self.request_uuid,
signature_data=signature_data,
)
with self.assertRaises(NotOnWhitelistFault):
self.commands.validate_msg(reply, self.request_uuid)
self.assertFalse(self.commands._sessions[self.domain].ready)

def test_empty_public_key_with_other_status_raises_typed_fault(self) -> None:
info = self._session_info(
publicKey=b"", status=Session_Info_Status.SESSION_INFO_STATUS_OK
)
signature_data = SignatureData(
session_info_tag=HMAC_Signature_Data(tag=b"\x00" * 32)
)
reply = RoutableMessage(
from_destination=Destination(domain=self.domain),
session_info=info.SerializeToString(),
request_uuid=self.request_uuid,
signature_data=signature_data,
)
with self.assertRaises(SessionInfoAuthenticationFault):
self.commands.validate_msg(reply, self.request_uuid)
self.assertFalse(self.commands._sessions[self.domain].ready)


class CounterAndClockMonotonicityTests(IsolatedAsyncioTestCase):
"""Covers commands.py's Session.commit: clamp within an epoch, refuse a
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading