tescmd 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. tescmd/__init__.py +3 -0
  2. tescmd/__main__.py +5 -0
  3. tescmd/_internal/__init__.py +0 -0
  4. tescmd/_internal/async_utils.py +25 -0
  5. tescmd/_internal/permissions.py +43 -0
  6. tescmd/_internal/vin.py +44 -0
  7. tescmd/api/__init__.py +1 -0
  8. tescmd/api/charging.py +102 -0
  9. tescmd/api/client.py +189 -0
  10. tescmd/api/command.py +540 -0
  11. tescmd/api/energy.py +146 -0
  12. tescmd/api/errors.py +76 -0
  13. tescmd/api/partner.py +40 -0
  14. tescmd/api/sharing.py +65 -0
  15. tescmd/api/signed_command.py +277 -0
  16. tescmd/api/user.py +38 -0
  17. tescmd/api/vehicle.py +150 -0
  18. tescmd/auth/__init__.py +1 -0
  19. tescmd/auth/oauth.py +312 -0
  20. tescmd/auth/server.py +108 -0
  21. tescmd/auth/token_store.py +273 -0
  22. tescmd/ble/__init__.py +0 -0
  23. tescmd/cache/__init__.py +6 -0
  24. tescmd/cache/keys.py +51 -0
  25. tescmd/cache/response_cache.py +213 -0
  26. tescmd/cli/__init__.py +0 -0
  27. tescmd/cli/_client.py +603 -0
  28. tescmd/cli/_options.py +126 -0
  29. tescmd/cli/auth.py +682 -0
  30. tescmd/cli/billing.py +240 -0
  31. tescmd/cli/cache.py +85 -0
  32. tescmd/cli/charge.py +610 -0
  33. tescmd/cli/climate.py +501 -0
  34. tescmd/cli/energy.py +385 -0
  35. tescmd/cli/key.py +611 -0
  36. tescmd/cli/main.py +601 -0
  37. tescmd/cli/media.py +146 -0
  38. tescmd/cli/nav.py +242 -0
  39. tescmd/cli/partner.py +112 -0
  40. tescmd/cli/raw.py +75 -0
  41. tescmd/cli/security.py +495 -0
  42. tescmd/cli/setup.py +786 -0
  43. tescmd/cli/sharing.py +188 -0
  44. tescmd/cli/software.py +81 -0
  45. tescmd/cli/status.py +106 -0
  46. tescmd/cli/trunk.py +240 -0
  47. tescmd/cli/user.py +145 -0
  48. tescmd/cli/vehicle.py +837 -0
  49. tescmd/config/__init__.py +0 -0
  50. tescmd/crypto/__init__.py +19 -0
  51. tescmd/crypto/ecdh.py +46 -0
  52. tescmd/crypto/keys.py +122 -0
  53. tescmd/deploy/__init__.py +0 -0
  54. tescmd/deploy/github_pages.py +268 -0
  55. tescmd/models/__init__.py +85 -0
  56. tescmd/models/auth.py +108 -0
  57. tescmd/models/command.py +18 -0
  58. tescmd/models/config.py +63 -0
  59. tescmd/models/energy.py +56 -0
  60. tescmd/models/sharing.py +26 -0
  61. tescmd/models/user.py +37 -0
  62. tescmd/models/vehicle.py +185 -0
  63. tescmd/output/__init__.py +5 -0
  64. tescmd/output/formatter.py +132 -0
  65. tescmd/output/json_output.py +83 -0
  66. tescmd/output/rich_output.py +809 -0
  67. tescmd/protocol/__init__.py +23 -0
  68. tescmd/protocol/commands.py +175 -0
  69. tescmd/protocol/encoder.py +122 -0
  70. tescmd/protocol/metadata.py +116 -0
  71. tescmd/protocol/payloads.py +621 -0
  72. tescmd/protocol/protobuf/__init__.py +6 -0
  73. tescmd/protocol/protobuf/messages.py +564 -0
  74. tescmd/protocol/session.py +318 -0
  75. tescmd/protocol/signer.py +84 -0
  76. tescmd/py.typed +0 -0
  77. tescmd-0.1.2.dist-info/METADATA +458 -0
  78. tescmd-0.1.2.dist-info/RECORD +81 -0
  79. tescmd-0.1.2.dist-info/WHEEL +4 -0
  80. tescmd-0.1.2.dist-info/entry_points.txt +2 -0
  81. tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,318 @@
1
+ """ECDH session management for the Vehicle Command Protocol.
2
+
3
+ Manages per-(VIN, domain) ECDH sessions with in-memory caching,
4
+ counter management, and automatic re-handshake on expiry.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import base64
11
+ import logging
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING
15
+
16
+ from tescmd.api.errors import (
17
+ KeyNotEnrolledError,
18
+ SessionError,
19
+ TeslaAPIError,
20
+ VehicleAsleepError,
21
+ )
22
+ from tescmd.crypto.ecdh import derive_session_key, get_uncompressed_public_key
23
+ from tescmd.protocol.encoder import build_session_info_request, encode_routable_message
24
+ from tescmd.protocol.protobuf.messages import (
25
+ FAULT_DESCRIPTIONS,
26
+ KEY_FAULTS,
27
+ TRANSIENT_FAULTS,
28
+ Domain,
29
+ MessageFault,
30
+ RoutableMessage,
31
+ SessionInfo,
32
+ )
33
+ from tescmd.protocol.signer import (
34
+ derive_session_info_key,
35
+ derive_signing_key,
36
+ verify_session_info_tag,
37
+ )
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ if TYPE_CHECKING:
42
+ from cryptography.hazmat.primitives.asymmetric import ec
43
+
44
+ from tescmd.api.client import TeslaFleetClient
45
+
46
+ # Default session TTL (5 minutes)
47
+ _SESSION_TTL = 300
48
+
49
+ # Retry config for transient handshake faults (BUSY, TIMEOUT, INTERNAL).
50
+ # The vehicle's VCSEC subsystem needs time to boot after waking.
51
+ _HANDSHAKE_MAX_RETRIES = 3
52
+ _HANDSHAKE_RETRY_DELAY = 3.0 # seconds between retries
53
+
54
+
55
+ @dataclass
56
+ class Session:
57
+ """An active ECDH session with a vehicle for a specific domain."""
58
+
59
+ vin: str
60
+ domain: Domain
61
+ shared_key: bytes
62
+ signing_key: bytes
63
+ session_info_key: bytes
64
+ epoch: bytes
65
+ counter: int
66
+ clock_offset: int # vehicle_clock - local_clock (seconds)
67
+ created_at: float
68
+ ttl: float = _SESSION_TTL
69
+
70
+ @property
71
+ def is_expired(self) -> bool:
72
+ return time.monotonic() - self.created_at > self.ttl
73
+
74
+ def next_counter(self) -> int:
75
+ """Return and increment the anti-replay counter."""
76
+ self.counter += 1
77
+ return self.counter
78
+
79
+
80
+ class SessionManager:
81
+ """Manages ECDH sessions per (VIN, domain).
82
+
83
+ Usage::
84
+
85
+ mgr = SessionManager(private_key, client)
86
+ session = await mgr.get_session(vin, Domain.DOMAIN_INFOTAINMENT)
87
+ # session.signing_key, session.epoch, session.counter, ...
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ private_key: ec.EllipticCurvePrivateKey,
93
+ client: TeslaFleetClient,
94
+ ) -> None:
95
+ self._private_key = private_key
96
+ self._client = client
97
+ self._client_public_key = get_uncompressed_public_key(private_key)
98
+ self._sessions: dict[tuple[str, Domain], Session] = {}
99
+
100
+ @property
101
+ def client_public_key(self) -> bytes:
102
+ return self._client_public_key
103
+
104
+ async def get_session(self, vin: str, domain: Domain) -> Session:
105
+ """Return a valid session, performing a handshake if needed."""
106
+ key = (vin, domain)
107
+ session = self._sessions.get(key)
108
+ if session is not None and not session.is_expired:
109
+ return session
110
+
111
+ session = await self._handshake(vin, domain)
112
+ self._sessions[key] = session
113
+ return session
114
+
115
+ def invalidate(self, vin: str, domain: Domain | None = None) -> None:
116
+ """Invalidate cached sessions for a VIN (optionally for a specific domain)."""
117
+ if domain is not None:
118
+ self._sessions.pop((vin, domain), None)
119
+ else:
120
+ for key in list(self._sessions):
121
+ if key[0] == vin:
122
+ del self._sessions[key]
123
+
124
+ async def _handshake(self, vin: str, domain: Domain) -> Session:
125
+ """Perform ECDH handshake with the vehicle.
126
+
127
+ 1. Build RoutableMessage with session_info_request
128
+ 2. POST to /api/1/vehicles/{vin}/signed_command
129
+ 3. Check for fault codes — retry transient faults (BUSY/TIMEOUT/INTERNAL)
130
+ 4. Parse vehicle's SessionInfo response
131
+ 5. ECDH → derive shared key
132
+ 6. Validate response HMAC
133
+ 7. Store session
134
+ """
135
+ last_fault: MessageFault | None = None
136
+
137
+ for attempt in range(1, _HANDSHAKE_MAX_RETRIES + 1):
138
+ # HTTP 408 from the signed_command endpoint means the vehicle's
139
+ # subsystem (VCSEC/Infotainment) didn't respond within the API
140
+ # server's timeout window — NOT necessarily that the vehicle is
141
+ # asleep. Retry it like a transient protobuf fault.
142
+ try:
143
+ response_msg = await self._send_handshake(vin, domain)
144
+ except VehicleAsleepError:
145
+ if attempt < _HANDSHAKE_MAX_RETRIES:
146
+ logger.debug(
147
+ "Handshake HTTP 408 for %s (%s), retry %d/%d",
148
+ vin,
149
+ domain.name,
150
+ attempt,
151
+ _HANDSHAKE_MAX_RETRIES,
152
+ )
153
+ await asyncio.sleep(_HANDSHAKE_RETRY_DELAY)
154
+ continue
155
+ # Exhausted retries — re-raise so auto_wake() can handle
156
+ # genuinely asleep vehicles.
157
+ raise
158
+
159
+ # Check for vehicle-side fault before looking at session_info.
160
+ fault = response_msg.signed_message_fault
161
+ logger.debug(
162
+ "Handshake parsed for %s (%s): fault=%s, has_session_info=%s, "
163
+ "has_protobuf_msg=%s, has_signature=%s",
164
+ vin,
165
+ domain.name,
166
+ fault.name,
167
+ bool(response_msg.session_info),
168
+ bool(response_msg.protobuf_message_as_bytes),
169
+ response_msg.signature_data is not None,
170
+ )
171
+ if fault != MessageFault.ERROR_NONE:
172
+ desc = FAULT_DESCRIPTIONS.get(fault, fault.name)
173
+ last_fault = fault
174
+
175
+ # Key not recognized → permanent error, don't retry.
176
+ if fault in KEY_FAULTS:
177
+ raise KeyNotEnrolledError(
178
+ f"Vehicle rejected handshake: {desc}",
179
+ status_code=422,
180
+ )
181
+
182
+ # Transient fault → retry after delay.
183
+ if fault in TRANSIENT_FAULTS and attempt < _HANDSHAKE_MAX_RETRIES:
184
+ logger.debug(
185
+ "Handshake fault %s for %s (%s), retry %d/%d",
186
+ fault.name,
187
+ vin,
188
+ domain.name,
189
+ attempt,
190
+ _HANDSHAKE_MAX_RETRIES,
191
+ )
192
+ await asyncio.sleep(_HANDSHAKE_RETRY_DELAY)
193
+ continue
194
+
195
+ # Non-transient or exhausted retries.
196
+ raise SessionError(f"Vehicle rejected handshake for {vin} ({domain.name}): {desc}")
197
+
198
+ if not response_msg.session_info:
199
+ raise SessionError(
200
+ f"No session_info in handshake response for {vin} ({domain.name})"
201
+ )
202
+
203
+ return self._build_session(vin, domain, response_msg)
204
+
205
+ # Should not be reached, but handle exhausted retries with last fault.
206
+ desc = FAULT_DESCRIPTIONS.get(last_fault, str(last_fault)) if last_fault else "unknown"
207
+ raise SessionError(
208
+ f"Handshake failed after {_HANDSHAKE_MAX_RETRIES} attempts "
209
+ f"for {vin} ({domain.name}): {desc}"
210
+ )
211
+
212
+ async def _send_handshake(self, vin: str, domain: Domain) -> RoutableMessage:
213
+ """Send a session_info_request and return the parsed response."""
214
+ request_msg = build_session_info_request(
215
+ domain=domain,
216
+ client_public_key=self._client_public_key,
217
+ )
218
+ encoded = encode_routable_message(request_msg)
219
+ logger.debug(
220
+ "Sending handshake for %s (%s): %d bytes encoded, pub_key=%d bytes",
221
+ vin,
222
+ domain.name,
223
+ len(encoded),
224
+ len(self._client_public_key),
225
+ )
226
+
227
+ try:
228
+ response_data = await self._client.post(
229
+ f"/api/1/vehicles/{vin}/signed_command",
230
+ json={"routable_message": encoded},
231
+ )
232
+ except TeslaAPIError:
233
+ # Let domain-specific errors (VehicleAsleepError, RateLimitError,
234
+ # etc.) propagate so callers like auto_wake() can handle them.
235
+ raise
236
+ except Exception as exc:
237
+ raise SessionError(
238
+ f"Session handshake failed for {vin} ({domain.name}): {exc}"
239
+ ) from exc
240
+
241
+ response_b64: str = response_data.get("response", "")
242
+ if not response_b64:
243
+ logger.debug(
244
+ "Empty handshake response for %s (%s): %r",
245
+ vin,
246
+ domain.name,
247
+ response_data,
248
+ )
249
+ raise SessionError(f"Empty session handshake response for {vin} ({domain.name})")
250
+
251
+ try:
252
+ response_bytes = base64.b64decode(response_b64)
253
+ except Exception as exc:
254
+ raise SessionError(f"Failed to decode handshake response: {exc}") from exc
255
+
256
+ logger.debug(
257
+ "Handshake response for %s (%s): %d bytes, hex=%s",
258
+ vin,
259
+ domain.name,
260
+ len(response_bytes),
261
+ response_bytes.hex(),
262
+ )
263
+
264
+ try:
265
+ return RoutableMessage.parse(response_bytes)
266
+ except Exception as exc:
267
+ raise SessionError(f"Failed to parse handshake response: {exc}") from exc
268
+
269
+ def _build_session(self, vin: str, domain: Domain, response_msg: RoutableMessage) -> Session:
270
+ """Derive keys and build a Session from a successful handshake response."""
271
+ session_info = SessionInfo.parse(response_msg.session_info)
272
+ if not session_info.public_key:
273
+ raise SessionError(f"No vehicle public key in session info for {vin} ({domain.name})")
274
+
275
+ # ECDH key derivation
276
+ try:
277
+ shared_key = derive_session_key(self._private_key, session_info.public_key)
278
+ except Exception as exc:
279
+ raise SessionError(
280
+ f"ECDH key derivation failed for {vin} ({domain.name}): {exc}"
281
+ ) from exc
282
+
283
+ # Derive sub-keys
284
+ signing_key = derive_signing_key(shared_key)
285
+ session_info_key = derive_session_info_key(shared_key)
286
+
287
+ # Verify session info HMAC tag (if present in response)
288
+ sig = response_msg.signature_data
289
+ si_tag = sig.session_info_tag if sig else None
290
+ if (
291
+ si_tag
292
+ and si_tag.tag
293
+ and not verify_session_info_tag(
294
+ session_info_key,
295
+ response_msg.session_info,
296
+ si_tag.tag,
297
+ )
298
+ ):
299
+ raise SessionError(
300
+ f"Session info HMAC verification failed for {vin} ({domain.name}). "
301
+ "The vehicle response may have been tampered with."
302
+ )
303
+
304
+ # Calculate clock offset
305
+ local_time = int(time.time())
306
+ clock_offset = session_info.clock_time - local_time if session_info.clock_time else 0
307
+
308
+ return Session(
309
+ vin=vin,
310
+ domain=domain,
311
+ shared_key=shared_key,
312
+ signing_key=signing_key,
313
+ session_info_key=session_info_key,
314
+ epoch=session_info.epoch,
315
+ counter=session_info.counter,
316
+ clock_offset=clock_offset,
317
+ created_at=time.monotonic(),
318
+ )
@@ -0,0 +1,84 @@
1
+ """HMAC-SHA256 command signing for the Vehicle Command Protocol.
2
+
3
+ Signing flow (for the REST/Fleet API path):
4
+
5
+ 1. Serialize metadata as TLV: ``encode_metadata(epoch, expires_at, counter)``
6
+ 2. Derive signing key: ``K' = HMAC-SHA256(K, b"authenticated command")``
7
+ 3. Compute tag: ``HMAC-SHA256(K', metadata_bytes || 0xFF || payload_bytes)``
8
+ 4. For VCSEC domain: truncate tag to 17 bytes.
9
+ 5. Attach to ``RoutableMessage.signature_data.HMAC_PersonalizedData.tag``
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import hmac
16
+
17
+ from tescmd.protocol.protobuf.messages import Domain
18
+
19
+ # Derivation labels (from Tesla vehicle-command specification)
20
+ _LABEL_AUTHENTICATED_COMMAND = b"authenticated command"
21
+ _LABEL_SESSION_INFO = b"session info"
22
+
23
+ # VCSEC domain uses a truncated 17-byte tag
24
+ _VCSEC_TAG_LENGTH = 17
25
+
26
+
27
+ def derive_signing_key(session_key: bytes) -> bytes:
28
+ """Derive the command signing key: ``HMAC-SHA256(K, "authenticated command")``."""
29
+ return hmac.new(session_key, _LABEL_AUTHENTICATED_COMMAND, hashlib.sha256).digest()
30
+
31
+
32
+ def derive_session_info_key(session_key: bytes) -> bytes:
33
+ """Derive the session info verification key: ``HMAC-SHA256(K, "session info")``."""
34
+ return hmac.new(session_key, _LABEL_SESSION_INFO, hashlib.sha256).digest()
35
+
36
+
37
+ def compute_hmac_tag(
38
+ signing_key: bytes,
39
+ metadata_bytes: bytes,
40
+ payload_bytes: bytes,
41
+ *,
42
+ domain: Domain = Domain.DOMAIN_INFOTAINMENT,
43
+ ) -> bytes:
44
+ """Compute the HMAC-SHA256 authentication tag.
45
+
46
+ Parameters
47
+ ----------
48
+ signing_key:
49
+ The derived signing key from :func:`derive_signing_key`.
50
+ metadata_bytes:
51
+ TLV-encoded metadata (epoch, expires_at, counter, flags).
52
+ payload_bytes:
53
+ The serialized protobuf command payload.
54
+ domain:
55
+ The routing domain — VCSEC tags are truncated to 17 bytes.
56
+
57
+ Returns
58
+ -------
59
+ bytes
60
+ The HMAC tag (32 bytes for Infotainment, 17 bytes for VCSEC).
61
+ """
62
+ # The Go SDK streams metadata entries into the hash, then Checksum()
63
+ # writes a bare TAG_END byte (0xFF) before the payload — no length byte.
64
+ # m.Context.Write([]byte{byte(signatures.Tag_TAG_END)}) // just 0xFF
65
+ # m.Context.Write(message)
66
+ msg = metadata_bytes + b"\xff" + payload_bytes
67
+ tag = hmac.new(signing_key, msg, hashlib.sha256).digest()
68
+
69
+ if domain == Domain.DOMAIN_VEHICLE_SECURITY:
70
+ return tag[:_VCSEC_TAG_LENGTH]
71
+ return tag
72
+
73
+
74
+ def verify_session_info_tag(
75
+ session_info_key: bytes,
76
+ session_info_bytes: bytes,
77
+ expected_tag: bytes,
78
+ ) -> bool:
79
+ """Verify the HMAC tag on a SessionInfo response.
80
+
81
+ Returns True if the tag is valid.
82
+ """
83
+ computed = hmac.new(session_info_key, session_info_bytes, hashlib.sha256).digest()
84
+ return hmac.compare_digest(computed, expected_tag)
tescmd/py.typed ADDED
File without changes