tesla-fleet-api 0.4.0 → 0.6.0

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 (215) hide show
  1. package/README.md +269 -1
  2. package/dist/_virtual/_rolldown/runtime.cjs +10 -0
  3. package/dist/bluetooth/adapters/shared.cjs +38 -0
  4. package/dist/bluetooth/adapters/shared.mjs +36 -0
  5. package/dist/bluetooth/async.cjs +121 -0
  6. package/dist/bluetooth/async.d.cts +10 -0
  7. package/dist/bluetooth/async.d.mts +10 -0
  8. package/dist/bluetooth/async.mjs +116 -0
  9. package/dist/bluetooth/broadcast.cjs +153 -0
  10. package/dist/bluetooth/broadcast.d.cts +59 -0
  11. package/dist/bluetooth/broadcast.d.mts +59 -0
  12. package/dist/bluetooth/broadcast.mjs +151 -0
  13. package/dist/bluetooth/constants.cjs +96 -0
  14. package/dist/bluetooth/constants.d.cts +68 -0
  15. package/dist/bluetooth/constants.d.mts +68 -0
  16. package/dist/bluetooth/constants.mjs +70 -0
  17. package/dist/bluetooth/framing.cjs +125 -0
  18. package/dist/bluetooth/framing.d.cts +45 -0
  19. package/dist/bluetooth/framing.d.mts +45 -0
  20. package/dist/bluetooth/framing.mjs +122 -0
  21. package/dist/bluetooth/homey.cjs +165 -0
  22. package/dist/bluetooth/homey.d.cts +84 -0
  23. package/dist/bluetooth/homey.d.mts +84 -0
  24. package/dist/bluetooth/homey.mjs +159 -0
  25. package/dist/bluetooth/names.cjs +24 -0
  26. package/dist/bluetooth/names.d.cts +10 -0
  27. package/dist/bluetooth/names.d.mts +10 -0
  28. package/dist/bluetooth/names.mjs +20 -0
  29. package/dist/bluetooth/noble.cjs +178 -0
  30. package/dist/bluetooth/noble.d.cts +58 -0
  31. package/dist/bluetooth/noble.d.mts +58 -0
  32. package/dist/bluetooth/noble.mjs +177 -0
  33. package/dist/bluetooth/streamglue.cjs +157 -0
  34. package/dist/bluetooth/streamglue.d.cts +37 -0
  35. package/dist/bluetooth/streamglue.d.mts +37 -0
  36. package/dist/bluetooth/streamglue.mjs +154 -0
  37. package/dist/bluetooth/teslabluetooth.cjs +67 -0
  38. package/dist/bluetooth/teslabluetooth.d.cts +41 -0
  39. package/dist/bluetooth/teslabluetooth.d.mts +41 -0
  40. package/dist/bluetooth/teslabluetooth.mjs +66 -0
  41. package/dist/bluetooth/transport.cjs +10 -0
  42. package/dist/bluetooth/transport.d.cts +97 -0
  43. package/dist/bluetooth/transport.d.mts +97 -0
  44. package/dist/bluetooth/transport.mjs +10 -0
  45. package/dist/bluetooth/vehiclebluetooth.cjs +1272 -0
  46. package/dist/bluetooth/vehiclebluetooth.d.cts +495 -0
  47. package/dist/bluetooth/vehiclebluetooth.d.mts +495 -0
  48. package/dist/bluetooth/vehiclebluetooth.mjs +1270 -0
  49. package/dist/bluetooth/verify.cjs +93 -0
  50. package/dist/bluetooth/verify.d.cts +35 -0
  51. package/dist/bluetooth/verify.d.mts +35 -0
  52. package/dist/bluetooth/verify.mjs +91 -0
  53. package/dist/bluetooth.cjs +52 -0
  54. package/dist/bluetooth.d.cts +10 -0
  55. package/dist/bluetooth.d.mts +10 -0
  56. package/dist/bluetooth.mjs +9 -0
  57. package/dist/commands.cjs +1080 -85
  58. package/dist/commands.d.cts +585 -54
  59. package/dist/commands.d.mts +583 -51
  60. package/dist/commands.mjs +1084 -93
  61. package/dist/const.cjs +22 -0
  62. package/dist/const.d.cts +21 -0
  63. package/dist/const.d.mts +21 -0
  64. package/dist/const.mjs +20 -1
  65. package/dist/energy.cjs +24 -19
  66. package/dist/energy.d.cts +21 -15
  67. package/dist/energy.d.mts +21 -15
  68. package/dist/energy.mjs +24 -19
  69. package/dist/energyisland.cjs +38 -0
  70. package/dist/energyisland.mjs +37 -0
  71. package/dist/energyparsers.cjs +118 -0
  72. package/dist/energyparsers.d.cts +39 -0
  73. package/dist/energyparsers.d.mts +39 -0
  74. package/dist/energyparsers.mjs +116 -0
  75. package/dist/energyspecific.cjs +375 -17
  76. package/dist/energyspecific.d.cts +199 -19
  77. package/dist/energyspecific.d.mts +197 -16
  78. package/dist/energyspecific.mjs +370 -18
  79. package/dist/exceptions.cjs +3 -2
  80. package/dist/exceptions.d.cts +3 -2
  81. package/dist/exceptions.d.mts +3 -2
  82. package/dist/exceptions.mjs +3 -2
  83. package/dist/funnel.cjs +297 -0
  84. package/dist/funnel.d.cts +118 -0
  85. package/dist/funnel.d.mts +118 -0
  86. package/dist/funnel.mjs +292 -0
  87. package/dist/index.cjs +61 -0
  88. package/dist/index.d.cts +22 -8
  89. package/dist/index.d.mts +22 -8
  90. package/dist/index.mjs +19 -6
  91. package/dist/log.cjs +75 -0
  92. package/dist/log.d.cts +4 -0
  93. package/dist/log.d.mts +4 -0
  94. package/dist/log.mjs +71 -0
  95. package/dist/oauth.cjs +143 -0
  96. package/dist/oauth.d.cts +69 -0
  97. package/dist/oauth.d.mts +69 -0
  98. package/dist/oauth.mjs +143 -0
  99. package/dist/powerwall/client.cjs +818 -0
  100. package/dist/powerwall/client.d.cts +297 -0
  101. package/dist/powerwall/client.d.mts +297 -0
  102. package/dist/powerwall/client.mjs +807 -0
  103. package/dist/powerwall/components.cjs +188 -0
  104. package/dist/powerwall/components.d.cts +45 -0
  105. package/dist/powerwall/components.d.mts +45 -0
  106. package/dist/powerwall/components.mjs +182 -0
  107. package/dist/powerwall/energysite.cjs +216 -0
  108. package/dist/powerwall/energysite.d.cts +104 -0
  109. package/dist/powerwall/energysite.d.mts +104 -0
  110. package/dist/powerwall/energysite.mjs +214 -0
  111. package/dist/powerwall/exceptions.cjs +59 -0
  112. package/dist/powerwall/exceptions.d.cts +38 -0
  113. package/dist/powerwall/exceptions.d.mts +38 -0
  114. package/dist/powerwall/exceptions.mjs +51 -0
  115. package/dist/powerwall/gateway.cjs +102 -0
  116. package/dist/powerwall/gateway.d.cts +57 -0
  117. package/dist/powerwall/gateway.d.mts +57 -0
  118. package/dist/powerwall/gateway.mjs +101 -0
  119. package/dist/powerwall/merge.cjs +163 -0
  120. package/dist/powerwall/merge.d.cts +63 -0
  121. package/dist/powerwall/merge.d.mts +63 -0
  122. package/dist/powerwall/merge.mjs +159 -0
  123. package/dist/powerwall/models.d.cts +95 -0
  124. package/dist/powerwall/models.d.mts +95 -0
  125. package/dist/powerwall/queries.cjs +17 -0
  126. package/dist/powerwall/queries.mjs +10 -0
  127. package/dist/powerwall/transport.cjs +232 -0
  128. package/dist/powerwall/transport.mjs +227 -0
  129. package/dist/powerwall.cjs +47 -0
  130. package/dist/powerwall.d.cts +9 -0
  131. package/dist/powerwall.d.mts +9 -0
  132. package/dist/powerwall.mjs +8 -0
  133. package/dist/router/base.cjs +203 -0
  134. package/dist/router/base.d.cts +112 -0
  135. package/dist/router/base.d.mts +112 -0
  136. package/dist/router/base.mjs +200 -0
  137. package/dist/router/energysite.cjs +65 -0
  138. package/dist/router/energysite.d.cts +47 -0
  139. package/dist/router/energysite.d.mts +47 -0
  140. package/dist/router/energysite.mjs +60 -0
  141. package/dist/router.cjs +13 -0
  142. package/dist/router.d.cts +3 -1
  143. package/dist/router.d.mts +3 -1
  144. package/dist/router.mjs +3 -1
  145. package/dist/signing/crypto.cjs +28 -0
  146. package/dist/signing/crypto.mjs +26 -1
  147. package/dist/signing/energy.cjs +123 -0
  148. package/dist/signing/energy.d.cts +45 -0
  149. package/dist/signing/energy.d.mts +45 -0
  150. package/dist/signing/energy.mjs +120 -0
  151. package/dist/signing/jws.cjs +229 -0
  152. package/dist/signing/jws.d.cts +45 -0
  153. package/dist/signing/jws.d.mts +45 -0
  154. package/dist/signing/jws.mjs +211 -0
  155. package/dist/signing/keys.cjs +191 -0
  156. package/dist/signing/keys.d.cts +21 -0
  157. package/dist/signing/keys.d.mts +21 -0
  158. package/dist/signing/keys.mjs +182 -0
  159. package/dist/signing/session.cjs +104 -30
  160. package/dist/signing/session.d.cts +60 -23
  161. package/dist/signing/session.d.mts +60 -23
  162. package/dist/signing/session.mjs +105 -31
  163. package/dist/tariff.cjs +27 -0
  164. package/dist/tariff.d.cts +11 -0
  165. package/dist/tariff.d.mts +11 -0
  166. package/dist/tariff.mjs +27 -1
  167. package/dist/teslafleetapi.cjs +256 -55
  168. package/dist/teslafleetapi.d.cts +110 -14
  169. package/dist/teslafleetapi.d.mts +110 -14
  170. package/dist/teslafleetapi.mjs +258 -57
  171. package/dist/teslemetry.cjs +152 -5
  172. package/dist/teslemetry.d.cts +95 -5
  173. package/dist/teslemetry.d.mts +93 -3
  174. package/dist/teslemetry.mjs +151 -6
  175. package/dist/teslemetryenergy.cjs +287 -0
  176. package/dist/teslemetryenergy.d.cts +154 -0
  177. package/dist/teslemetryenergy.d.mts +154 -0
  178. package/dist/teslemetryenergy.mjs +282 -0
  179. package/dist/teslemetryvehicle.cjs +407 -0
  180. package/dist/teslemetryvehicle.d.cts +238 -0
  181. package/dist/teslemetryvehicle.d.mts +238 -0
  182. package/dist/teslemetryvehicle.mjs +405 -0
  183. package/dist/tessie.cjs +42 -5
  184. package/dist/tessie.d.cts +51 -5
  185. package/dist/tessie.d.mts +49 -3
  186. package/dist/tessie.mjs +42 -5
  187. package/dist/tessievehicle.cjs +514 -0
  188. package/dist/tessievehicle.d.cts +333 -0
  189. package/dist/tessievehicle.d.mts +333 -0
  190. package/dist/tessievehicle.mjs +513 -0
  191. package/dist/types/live_status.d.cts +16 -16
  192. package/dist/types/live_status.d.mts +16 -16
  193. package/dist/types/responses.d.cts +5 -1
  194. package/dist/types/responses.d.mts +5 -1
  195. package/dist/types/vehicle.d.cts +7 -1
  196. package/dist/types/vehicle.d.mts +7 -1
  197. package/dist/types.d.cts +3 -3
  198. package/dist/types.d.mts +3 -3
  199. package/dist/util.cjs +41 -0
  200. package/dist/util.d.cts +18 -0
  201. package/dist/util.d.mts +18 -0
  202. package/dist/util.mjs +40 -0
  203. package/dist/vehicle.cjs +79 -36
  204. package/dist/vehicle.d.cts +42 -26
  205. package/dist/vehicle.d.mts +42 -26
  206. package/dist/vehicle.mjs +80 -37
  207. package/dist/vehiclesigned.cjs +20 -5
  208. package/dist/vehiclesigned.d.cts +14 -5
  209. package/dist/vehiclesigned.d.mts +14 -5
  210. package/dist/vehiclesigned.mjs +20 -5
  211. package/dist/vehiclespecific.cjs +126 -15
  212. package/dist/vehiclespecific.d.cts +82 -18
  213. package/dist/vehiclespecific.d.mts +82 -18
  214. package/dist/vehiclespecific.mjs +126 -15
  215. package/package.json +56 -3
@@ -1,5 +1,5 @@
1
1
  import { Domain } from "@teslemetry/tesla-protocol/command/universal_message";
2
- import { HMACPersonalizedSignatureData, SessionInfo } from "@teslemetry/tesla-protocol/command/signatures";
2
+ import { AESGCMPersonalizedSignatureData, HMACPersonalizedSignatureData, SessionInfo } from "@teslemetry/tesla-protocol/command/signatures";
3
3
  /** Key material derived (but not yet trusted/committed) for a candidate vehicle public key. */
4
4
  export interface SessionKeys {
5
5
  sharedKey: Buffer;
@@ -11,59 +11,96 @@ export interface SessionKeys {
11
11
  * anti-replay counter/epoch handed out by the vehicle's handshake reply and
12
12
  * the HMAC keys derived from it.
13
13
  *
14
- * Deriving key material (`keysFor`) is split from committing it (`commit`)
15
- * because the caller must authenticate a `SessionInfo` reply - by verifying
16
- * its `session_info_tag` against the very keys derived here - *before*
17
- * trusting anything in it, including the vehicle public key the shared key
18
- * itself is derived from. See `Commands.validateAndUpdateSession`.
14
+ * The vehicle's key is trusted once, at first contact, then pinned for the life
15
+ * of the session, as in Tesla's Go SDK. ECDH is symmetric, so anyone who saw the
16
+ * client's public key can tag a `SessionInfo` naming a key of their own: a tag
17
+ * proves nothing about a key derived from the same reply. `trustedKeys` is the
18
+ * only way a reply reaches key material, so once pinned it is verified with the
19
+ * stored keys, and nothing clears the pin. A re-keyed vehicle needs a new instance.
20
+ *
21
+ * First contact itself is trust on first use. Deriving keys (`keysFor`) is still
22
+ * split from committing them (`commit`), so a reply that fails its tag or carries
23
+ * a whitelist rejection pins nothing. See `Commands.validate_msg`.
19
24
  *
20
25
  * A `Session` is not itself thread-safe; `withLock` serializes the
21
26
  * message-build step (which mutates `counter`) so concurrent commands on the
22
27
  * same domain cannot race on the same counter value.
28
+ *
29
+ * The derived keys are native private fields, read through accessors, so a
30
+ * `JSON.stringify` or structured log of a session cannot reach them.
23
31
  */
24
32
  export declare class Session {
33
+ #private;
25
34
  private readonly deriveSharedKey;
26
35
  readonly domain: Domain;
27
36
  counter: number;
28
37
  epoch?: Buffer;
29
38
  delta?: number;
30
- sharedKey?: Buffer;
31
- hmacKey?: Buffer;
32
- sessionInfoKey?: Buffer;
33
- publicKey?: Buffer;
34
39
  /** Clock time of the last *authenticated* SessionInfo committed for the current epoch, guarding against replay/rollback. */
35
40
  private lastAuthenticatedClockTime?;
41
+ /** `(requestHash, counter)` pairs seen in this epoch, oldest first. */
42
+ private responseCounters;
36
43
  private queue;
37
44
  constructor(domain: Domain, deriveSharedKey: (vehiclePublicKey: Buffer) => Buffer);
45
+ /** The vehicle key pinned at first contact. There is deliberately no setter. */
46
+ get publicKey(): Buffer | undefined;
47
+ get sharedKey(): Buffer | undefined;
48
+ get hmacKey(): Buffer | undefined;
49
+ get sessionInfoKey(): Buffer | undefined;
38
50
  /** Whether the handshake has completed and commands can be signed. */
39
51
  get ready(): boolean;
40
52
  /**
41
53
  * Derive the shared key and its two HMAC sub-keys for a candidate vehicle
42
54
  * public key, without touching any session state. Pure and side-effect
43
55
  * free so a caller can verify authenticity first and discard the result
44
- * if verification fails.
56
+ * if verification fails. Verifying a reply goes through `trustedKeys`.
45
57
  */
46
58
  keysFor(vehiclePublicKey: Buffer): SessionKeys;
47
59
  /**
48
- * Commit an already-authenticated `SessionInfo` plus the `keysFor` result
49
- * derived from its public key. Per the vehicle-command protocol, the
50
- * anti-replay counter must never roll back within the same epoch, and a
51
- * clock time that regresses within the same epoch marks the reply as
52
- * stale/replayed - returns `false` (without mutating any state) in that
53
- * case so the caller can reject it.
60
+ * The keys to verify a reply naming `vehiclePublicKey` with: derived from it
61
+ * at first contact, and the stored keys from then on, never derived again.
62
+ * @throws SessionInfoAuthenticationFault when the session is pinned to another key.
63
+ */
64
+ trustedKeys(vehiclePublicKey: Buffer): SessionKeys;
65
+ /**
66
+ * Commit an already-authenticated `SessionInfo` plus its `trustedKeys`
67
+ * result. Keys are adopted at first contact only: after that, `keys` is
68
+ * ignored and a different public key is refused. Per the vehicle-command
69
+ * protocol, the anti-replay counter must never roll back within the same
70
+ * epoch, and a clock time that regresses within the same epoch marks the
71
+ * reply as stale/replayed - returns `false` (without mutating any state) in
72
+ * that case so the caller can reject it.
73
+ * @throws SessionInfoAuthenticationFault, changing nothing, for a different public key, or a clock or counter that could not sign a single command.
54
74
  */
55
75
  commit(sessionInfo: SessionInfo, keys: SessionKeys): boolean;
56
76
  /**
57
- * Advance the anti-replay counter and return fresh signature metadata for
58
- * an HMAC-personalized command. The command expires 10 seconds from now,
59
- * corrected for clock drift against the vehicle (`delta`).
77
+ * Record an authenticated response's counter for the request it answers.
78
+ * @returns `false` if that pair was already seen, marking the response a replay.
79
+ */
80
+ record_response_counter(requestHash: Uint8Array, counter: number): boolean;
81
+ /**
82
+ * Advance the anti-replay counter for a command valid for `lifetimeSeconds`,
83
+ * corrected for clock drift against the vehicle (`delta`). The clock keeps
84
+ * running after `commit`, so its limit is checked here too; a session past it
85
+ * recovers when the vehicle starts a new epoch.
86
+ * @throws SessionInfoAuthenticationFault, leaving the counter alone, when either value would overflow its wire field.
87
+ */
88
+ private nextSignature;
89
+ /**
90
+ * Fresh signature metadata for an HMAC-personalized command, which expires
91
+ * 10 seconds from now.
60
92
  */
61
93
  hmacPersonalized(): HMACPersonalizedSignatureData;
94
+ /**
95
+ * Fresh signature metadata for an AES-GCM-personalized command, with a
96
+ * random nonce, which expires 30 seconds from now.
97
+ */
98
+ aesGcmPersonalized(): AESGCMPersonalizedSignatureData;
62
99
  /**
63
100
  * Run `fn` with exclusive access to this session, queuing behind any
64
- * in-flight caller. Held only for as long as `fn` takes - callers should
65
- * keep it scoped to the synchronous message-build step, not a full
66
- * network round trip.
101
+ * in-flight caller. Not reentrant: `fn` must not call `withLock` on this
102
+ * session. Infotainment holds it for the message build only, and VCSEC for
103
+ * the whole dispatch (see `Commands.dispatch`).
67
104
  */
68
105
  withLock<T>(fn: () => T | Promise<T>): Promise<T>;
69
106
  }
@@ -1,5 +1,5 @@
1
1
  import { Domain } from "@teslemetry/tesla-protocol/command/universal_message";
2
- import { HMACPersonalizedSignatureData, SessionInfo } from "@teslemetry/tesla-protocol/command/signatures";
2
+ import { AESGCMPersonalizedSignatureData, HMACPersonalizedSignatureData, SessionInfo } from "@teslemetry/tesla-protocol/command/signatures";
3
3
  /** Key material derived (but not yet trusted/committed) for a candidate vehicle public key. */
4
4
  export interface SessionKeys {
5
5
  sharedKey: Buffer;
@@ -11,59 +11,96 @@ export interface SessionKeys {
11
11
  * anti-replay counter/epoch handed out by the vehicle's handshake reply and
12
12
  * the HMAC keys derived from it.
13
13
  *
14
- * Deriving key material (`keysFor`) is split from committing it (`commit`)
15
- * because the caller must authenticate a `SessionInfo` reply - by verifying
16
- * its `session_info_tag` against the very keys derived here - *before*
17
- * trusting anything in it, including the vehicle public key the shared key
18
- * itself is derived from. See `Commands.validateAndUpdateSession`.
14
+ * The vehicle's key is trusted once, at first contact, then pinned for the life
15
+ * of the session, as in Tesla's Go SDK. ECDH is symmetric, so anyone who saw the
16
+ * client's public key can tag a `SessionInfo` naming a key of their own: a tag
17
+ * proves nothing about a key derived from the same reply. `trustedKeys` is the
18
+ * only way a reply reaches key material, so once pinned it is verified with the
19
+ * stored keys, and nothing clears the pin. A re-keyed vehicle needs a new instance.
20
+ *
21
+ * First contact itself is trust on first use. Deriving keys (`keysFor`) is still
22
+ * split from committing them (`commit`), so a reply that fails its tag or carries
23
+ * a whitelist rejection pins nothing. See `Commands.validate_msg`.
19
24
  *
20
25
  * A `Session` is not itself thread-safe; `withLock` serializes the
21
26
  * message-build step (which mutates `counter`) so concurrent commands on the
22
27
  * same domain cannot race on the same counter value.
28
+ *
29
+ * The derived keys are native private fields, read through accessors, so a
30
+ * `JSON.stringify` or structured log of a session cannot reach them.
23
31
  */
24
32
  export declare class Session {
33
+ #private;
25
34
  private readonly deriveSharedKey;
26
35
  readonly domain: Domain;
27
36
  counter: number;
28
37
  epoch?: Buffer;
29
38
  delta?: number;
30
- sharedKey?: Buffer;
31
- hmacKey?: Buffer;
32
- sessionInfoKey?: Buffer;
33
- publicKey?: Buffer;
34
39
  /** Clock time of the last *authenticated* SessionInfo committed for the current epoch, guarding against replay/rollback. */
35
40
  private lastAuthenticatedClockTime?;
41
+ /** `(requestHash, counter)` pairs seen in this epoch, oldest first. */
42
+ private responseCounters;
36
43
  private queue;
37
44
  constructor(domain: Domain, deriveSharedKey: (vehiclePublicKey: Buffer) => Buffer);
45
+ /** The vehicle key pinned at first contact. There is deliberately no setter. */
46
+ get publicKey(): Buffer | undefined;
47
+ get sharedKey(): Buffer | undefined;
48
+ get hmacKey(): Buffer | undefined;
49
+ get sessionInfoKey(): Buffer | undefined;
38
50
  /** Whether the handshake has completed and commands can be signed. */
39
51
  get ready(): boolean;
40
52
  /**
41
53
  * Derive the shared key and its two HMAC sub-keys for a candidate vehicle
42
54
  * public key, without touching any session state. Pure and side-effect
43
55
  * free so a caller can verify authenticity first and discard the result
44
- * if verification fails.
56
+ * if verification fails. Verifying a reply goes through `trustedKeys`.
45
57
  */
46
58
  keysFor(vehiclePublicKey: Buffer): SessionKeys;
47
59
  /**
48
- * Commit an already-authenticated `SessionInfo` plus the `keysFor` result
49
- * derived from its public key. Per the vehicle-command protocol, the
50
- * anti-replay counter must never roll back within the same epoch, and a
51
- * clock time that regresses within the same epoch marks the reply as
52
- * stale/replayed - returns `false` (without mutating any state) in that
53
- * case so the caller can reject it.
60
+ * The keys to verify a reply naming `vehiclePublicKey` with: derived from it
61
+ * at first contact, and the stored keys from then on, never derived again.
62
+ * @throws SessionInfoAuthenticationFault when the session is pinned to another key.
63
+ */
64
+ trustedKeys(vehiclePublicKey: Buffer): SessionKeys;
65
+ /**
66
+ * Commit an already-authenticated `SessionInfo` plus its `trustedKeys`
67
+ * result. Keys are adopted at first contact only: after that, `keys` is
68
+ * ignored and a different public key is refused. Per the vehicle-command
69
+ * protocol, the anti-replay counter must never roll back within the same
70
+ * epoch, and a clock time that regresses within the same epoch marks the
71
+ * reply as stale/replayed - returns `false` (without mutating any state) in
72
+ * that case so the caller can reject it.
73
+ * @throws SessionInfoAuthenticationFault, changing nothing, for a different public key, or a clock or counter that could not sign a single command.
54
74
  */
55
75
  commit(sessionInfo: SessionInfo, keys: SessionKeys): boolean;
56
76
  /**
57
- * Advance the anti-replay counter and return fresh signature metadata for
58
- * an HMAC-personalized command. The command expires 10 seconds from now,
59
- * corrected for clock drift against the vehicle (`delta`).
77
+ * Record an authenticated response's counter for the request it answers.
78
+ * @returns `false` if that pair was already seen, marking the response a replay.
79
+ */
80
+ record_response_counter(requestHash: Uint8Array, counter: number): boolean;
81
+ /**
82
+ * Advance the anti-replay counter for a command valid for `lifetimeSeconds`,
83
+ * corrected for clock drift against the vehicle (`delta`). The clock keeps
84
+ * running after `commit`, so its limit is checked here too; a session past it
85
+ * recovers when the vehicle starts a new epoch.
86
+ * @throws SessionInfoAuthenticationFault, leaving the counter alone, when either value would overflow its wire field.
87
+ */
88
+ private nextSignature;
89
+ /**
90
+ * Fresh signature metadata for an HMAC-personalized command, which expires
91
+ * 10 seconds from now.
60
92
  */
61
93
  hmacPersonalized(): HMACPersonalizedSignatureData;
94
+ /**
95
+ * Fresh signature metadata for an AES-GCM-personalized command, with a
96
+ * random nonce, which expires 30 seconds from now.
97
+ */
98
+ aesGcmPersonalized(): AESGCMPersonalizedSignatureData;
62
99
  /**
63
100
  * Run `fn` with exclusive access to this session, queuing behind any
64
- * in-flight caller. Held only for as long as `fn` takes - callers should
65
- * keep it scoped to the synchronous message-build step, not a full
66
- * network round trip.
101
+ * in-flight caller. Not reentrant: `fn` must not call `withLock` on this
102
+ * session. Infotainment holds it for the message build only, and VCSEC for
103
+ * the whole dispatch (see `Commands.dispatch`).
67
104
  */
68
105
  withLock<T>(fn: () => T | Promise<T>): Promise<T>;
69
106
  }
@@ -1,20 +1,36 @@
1
+ import { SessionInfoAuthenticationFault } from "../exceptions.mjs";
1
2
  import crypto from "crypto";
2
- import { HMACPersonalizedSignatureData } from "@teslemetry/tesla-protocol/command/signatures";
3
+ import { AESGCMPersonalizedSignatureData, HMACPersonalizedSignatureData } from "@teslemetry/tesla-protocol/command/signatures";
3
4
  // src/signing/session.ts
5
+ /** Wire limit of the `counter` and `expires_at` fields. */
6
+ const UINT32_MAX = 4294967295;
7
+ /** Seconds a signed command stays valid. BLE can be slow, so AES-GCM allows longer. */
8
+ const HMAC_LIFETIME_S = 10;
9
+ const AES_GCM_LIFETIME_S = 30;
10
+ /** Response counters remembered per session, which bounds how far back a replay is caught. */
11
+ const RESPONSE_COUNTER_CACHE_SIZE = 256;
4
12
  /**
5
13
  * Per-domain (VCSEC / Infotainment) signed-command session: tracks the
6
14
  * anti-replay counter/epoch handed out by the vehicle's handshake reply and
7
15
  * the HMAC keys derived from it.
8
16
  *
9
- * Deriving key material (`keysFor`) is split from committing it (`commit`)
10
- * because the caller must authenticate a `SessionInfo` reply - by verifying
11
- * its `session_info_tag` against the very keys derived here - *before*
12
- * trusting anything in it, including the vehicle public key the shared key
13
- * itself is derived from. See `Commands.validateAndUpdateSession`.
17
+ * The vehicle's key is trusted once, at first contact, then pinned for the life
18
+ * of the session, as in Tesla's Go SDK. ECDH is symmetric, so anyone who saw the
19
+ * client's public key can tag a `SessionInfo` naming a key of their own: a tag
20
+ * proves nothing about a key derived from the same reply. `trustedKeys` is the
21
+ * only way a reply reaches key material, so once pinned it is verified with the
22
+ * stored keys, and nothing clears the pin. A re-keyed vehicle needs a new instance.
23
+ *
24
+ * First contact itself is trust on first use. Deriving keys (`keysFor`) is still
25
+ * split from committing them (`commit`), so a reply that fails its tag or carries
26
+ * a whitelist rejection pins nothing. See `Commands.validate_msg`.
14
27
  *
15
28
  * A `Session` is not itself thread-safe; `withLock` serializes the
16
29
  * message-build step (which mutates `counter`) so concurrent commands on the
17
30
  * same domain cannot race on the same counter value.
31
+ *
32
+ * The derived keys are native private fields, read through accessors, so a
33
+ * `JSON.stringify` or structured log of a session cannot reach them.
18
34
  */
19
35
  var Session = class {
20
36
  deriveSharedKey;
@@ -22,26 +38,39 @@ var Session = class {
22
38
  counter = 0;
23
39
  epoch;
24
40
  delta;
25
- sharedKey;
26
- hmacKey;
27
- sessionInfoKey;
28
- publicKey;
41
+ #publicKey;
42
+ #keys;
29
43
  /** Clock time of the last *authenticated* SessionInfo committed for the current epoch, guarding against replay/rollback. */
30
44
  lastAuthenticatedClockTime;
45
+ /** `(requestHash, counter)` pairs seen in this epoch, oldest first. */
46
+ responseCounters = /* @__PURE__ */ new Set();
31
47
  queue = Promise.resolve();
32
48
  constructor(domain, deriveSharedKey) {
33
49
  this.deriveSharedKey = deriveSharedKey;
34
50
  this.domain = domain;
35
51
  }
52
+ /** The vehicle key pinned at first contact. There is deliberately no setter. */
53
+ get publicKey() {
54
+ return this.#publicKey;
55
+ }
56
+ get sharedKey() {
57
+ return this.#keys?.sharedKey;
58
+ }
59
+ get hmacKey() {
60
+ return this.#keys?.hmacKey;
61
+ }
62
+ get sessionInfoKey() {
63
+ return this.#keys?.sessionInfoKey;
64
+ }
36
65
  /** Whether the handshake has completed and commands can be signed. */
37
66
  get ready() {
38
- return this.epoch !== void 0 && this.hmacKey !== void 0 && this.delta !== void 0;
67
+ return this.epoch !== void 0 && this.#keys !== void 0 && this.delta !== void 0;
39
68
  }
40
69
  /**
41
70
  * Derive the shared key and its two HMAC sub-keys for a candidate vehicle
42
71
  * public key, without touching any session state. Pure and side-effect
43
72
  * free so a caller can verify authenticity first and discard the result
44
- * if verification fails.
73
+ * if verification fails. Verifying a reply goes through `trustedKeys`.
45
74
  */
46
75
  keysFor(vehiclePublicKey) {
47
76
  const sharedKey = this.deriveSharedKey(vehiclePublicKey);
@@ -52,46 +81,91 @@ var Session = class {
52
81
  };
53
82
  }
54
83
  /**
55
- * Commit an already-authenticated `SessionInfo` plus the `keysFor` result
56
- * derived from its public key. Per the vehicle-command protocol, the
57
- * anti-replay counter must never roll back within the same epoch, and a
58
- * clock time that regresses within the same epoch marks the reply as
59
- * stale/replayed - returns `false` (without mutating any state) in that
60
- * case so the caller can reject it.
84
+ * The keys to verify a reply naming `vehiclePublicKey` with: derived from it
85
+ * at first contact, and the stored keys from then on, never derived again.
86
+ * @throws SessionInfoAuthenticationFault when the session is pinned to another key.
87
+ */
88
+ trustedKeys(vehiclePublicKey) {
89
+ if (this.#publicKey === void 0 || this.#keys === void 0) return this.keysFor(vehiclePublicKey);
90
+ if (!this.#publicKey.equals(vehiclePublicKey)) throw new SessionInfoAuthenticationFault("Session info reply names a vehicle public key that has changed since first contact; discarded. If the vehicle was really re-keyed, build a new instance to trust it.");
91
+ return this.#keys;
92
+ }
93
+ /**
94
+ * Commit an already-authenticated `SessionInfo` plus its `trustedKeys`
95
+ * result. Keys are adopted at first contact only: after that, `keys` is
96
+ * ignored and a different public key is refused. Per the vehicle-command
97
+ * protocol, the anti-replay counter must never roll back within the same
98
+ * epoch, and a clock time that regresses within the same epoch marks the
99
+ * reply as stale/replayed - returns `false` (without mutating any state) in
100
+ * that case so the caller can reject it.
101
+ * @throws SessionInfoAuthenticationFault, changing nothing, for a different public key, or a clock or counter that could not sign a single command.
61
102
  */
62
103
  commit(sessionInfo, keys) {
104
+ const trusted = this.#keys === void 0 ? keys : this.trustedKeys(Buffer.from(sessionInfo.publicKey));
63
105
  const incomingEpoch = Buffer.from(sessionInfo.epoch);
64
106
  const sameEpoch = this.epoch !== void 0 && this.epoch.equals(incomingEpoch);
65
107
  if (sameEpoch && this.lastAuthenticatedClockTime !== void 0 && sessionInfo.clockTime < this.lastAuthenticatedClockTime) return false;
108
+ if (sessionInfo.clockTime + AES_GCM_LIFETIME_S > UINT32_MAX || sessionInfo.counter >= UINT32_MAX) throw new SessionInfoAuthenticationFault("Session info reply has a clock or counter no command could be signed with; discarded.");
66
109
  this.counter = sameEpoch ? Math.max(this.counter, sessionInfo.counter) : sessionInfo.counter;
67
110
  this.epoch = incomingEpoch;
68
111
  this.delta = Math.floor(Date.now() / 1e3) - sessionInfo.clockTime;
69
112
  this.lastAuthenticatedClockTime = sessionInfo.clockTime;
70
- this.publicKey = Buffer.from(sessionInfo.publicKey);
71
- this.sharedKey = keys.sharedKey;
72
- this.hmacKey = keys.hmacKey;
73
- this.sessionInfoKey = keys.sessionInfoKey;
113
+ this.#publicKey = Buffer.from(sessionInfo.publicKey);
114
+ this.#keys = trusted;
115
+ if (!sameEpoch) this.responseCounters.clear();
74
116
  return true;
75
117
  }
76
118
  /**
77
- * Advance the anti-replay counter and return fresh signature metadata for
78
- * an HMAC-personalized command. The command expires 10 seconds from now,
79
- * corrected for clock drift against the vehicle (`delta`).
119
+ * Record an authenticated response's counter for the request it answers.
120
+ * @returns `false` if that pair was already seen, marking the response a replay.
80
121
  */
81
- hmacPersonalized() {
122
+ record_response_counter(requestHash, counter) {
123
+ const identity = `${Buffer.from(requestHash).toString("hex")}:${counter}`;
124
+ if (this.responseCounters.has(identity)) return false;
125
+ this.responseCounters.add(identity);
126
+ if (this.responseCounters.size > RESPONSE_COUNTER_CACHE_SIZE) this.responseCounters.delete(this.responseCounters.values().next().value);
127
+ return true;
128
+ }
129
+ /**
130
+ * Advance the anti-replay counter for a command valid for `lifetimeSeconds`,
131
+ * corrected for clock drift against the vehicle (`delta`). The clock keeps
132
+ * running after `commit`, so its limit is checked here too; a session past it
133
+ * recovers when the vehicle starts a new epoch.
134
+ * @throws SessionInfoAuthenticationFault, leaving the counter alone, when either value would overflow its wire field.
135
+ */
136
+ nextSignature(lifetimeSeconds) {
82
137
  if (!this.ready || !this.epoch || this.delta === void 0) throw new Error("Session is not ready: perform the handshake first.");
138
+ const expiresAt = Math.floor(Date.now() / 1e3) - this.delta + lifetimeSeconds;
139
+ if (expiresAt > UINT32_MAX || this.counter >= UINT32_MAX) throw new SessionInfoAuthenticationFault("Session has reached the limit of the vehicle clock or counter, so nothing was signed.");
83
140
  this.counter++;
84
- return HMACPersonalizedSignatureData.create({
141
+ return {
85
142
  epoch: this.epoch,
86
143
  counter: this.counter,
87
- expiresAt: Math.floor(Date.now() / 1e3) - this.delta + 10
144
+ expiresAt
145
+ };
146
+ }
147
+ /**
148
+ * Fresh signature metadata for an HMAC-personalized command, which expires
149
+ * 10 seconds from now.
150
+ */
151
+ hmacPersonalized() {
152
+ return HMACPersonalizedSignatureData.create(this.nextSignature(HMAC_LIFETIME_S));
153
+ }
154
+ /**
155
+ * Fresh signature metadata for an AES-GCM-personalized command, with a
156
+ * random nonce, which expires 30 seconds from now.
157
+ */
158
+ aesGcmPersonalized() {
159
+ return AESGCMPersonalizedSignatureData.create({
160
+ ...this.nextSignature(AES_GCM_LIFETIME_S),
161
+ nonce: crypto.randomBytes(12)
88
162
  });
89
163
  }
90
164
  /**
91
165
  * Run `fn` with exclusive access to this session, queuing behind any
92
- * in-flight caller. Held only for as long as `fn` takes - callers should
93
- * keep it scoped to the synchronous message-build step, not a full
94
- * network round trip.
166
+ * in-flight caller. Not reentrant: `fn` must not call `withLock` on this
167
+ * session. Infotainment holds it for the message build only, and VCSEC for
168
+ * the whole dispatch (see `Commands.dispatch`).
95
169
  */
96
170
  async withLock(fn) {
97
171
  const previous = this.queue;
package/dist/tariff.cjs CHANGED
@@ -1,5 +1,31 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_exceptions = require("./exceptions.cjs");
2
3
  // src/tariff.ts
4
+ function isObject(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+ /**
8
+ * Extract the `tariff_content_v2` object from a raw API response.
9
+ *
10
+ * Accepts the `site_info` read envelope (`{ response: { tariff_content_v2 } }`), the
11
+ * `time_of_use_settings` write envelope (`{ tou_settings: { tariff_content_v2 } }`), or the bare
12
+ * `tariff_content_v2` object itself. Throws {@link InvalidResponse} for a null body, an
13
+ * unrecognized envelope, or an extracted object without the minimal Tariff V2 shape (`seasons`
14
+ * and `energy_charges`) - checked identically on every path, since a malformed body (including an
15
+ * empty `{}` nested under a recognized envelope) is not the same as "no tariff configured".
16
+ */
17
+ function unwrapTariffV2(response) {
18
+ if (response == null) throw new require_exceptions.InvalidResponse("tariff response body was null");
19
+ if (!isObject(response)) throw new require_exceptions.InvalidResponse(String(response));
20
+ let candidate;
21
+ if ("tariff_content_v2" in response) candidate = response.tariff_content_v2;
22
+ else if ("response" in response || "tou_settings" in response) {
23
+ const inner = "response" in response ? response.response : response.tou_settings;
24
+ candidate = isObject(inner) ? inner.tariff_content_v2 : void 0;
25
+ } else candidate = response;
26
+ if (isObject(candidate) && "seasons" in candidate && "energy_charges" in candidate) return candidate;
27
+ throw new require_exceptions.InvalidResponse(response);
28
+ }
3
29
  const MINUTES_PER_DAY = 1440;
4
30
  function formatParts(date, timeZone) {
5
31
  const parts = new Intl.DateTimeFormat("en-US", {
@@ -290,3 +316,4 @@ function getTariffPeriods(tariff, now, opts) {
290
316
  return resolution;
291
317
  }
292
318
  exports.getTariffPeriods = getTariffPeriods;
319
+ exports.unwrapTariffV2 = unwrapTariffV2;
package/dist/tariff.d.cts CHANGED
@@ -18,6 +18,17 @@ export interface TariffResolution {
18
18
  currency: string | null;
19
19
  upcoming: TariffPeriod[] | null;
20
20
  }
21
+ /**
22
+ * Extract the `tariff_content_v2` object from a raw API response.
23
+ *
24
+ * Accepts the `site_info` read envelope (`{ response: { tariff_content_v2 } }`), the
25
+ * `time_of_use_settings` write envelope (`{ tou_settings: { tariff_content_v2 } }`), or the bare
26
+ * `tariff_content_v2` object itself. Throws {@link InvalidResponse} for a null body, an
27
+ * unrecognized envelope, or an extracted object without the minimal Tariff V2 shape (`seasons`
28
+ * and `energy_charges`) - checked identically on every path, since a malformed body (including an
29
+ * empty `{}` nested under a recognized envelope) is not the same as "no tariff configured".
30
+ */
31
+ export declare function unwrapTariffV2(response: unknown): TariffContentV2;
21
32
  /**
22
33
  * Resolves the current buy/sell time-of-use rate for a Tariff V2 object at a given instant.
23
34
  * `tariff` carries no timezone; the caller must supply the site's IANA zone (e.g. from
package/dist/tariff.d.mts CHANGED
@@ -18,6 +18,17 @@ export interface TariffResolution {
18
18
  currency: string | null;
19
19
  upcoming: TariffPeriod[] | null;
20
20
  }
21
+ /**
22
+ * Extract the `tariff_content_v2` object from a raw API response.
23
+ *
24
+ * Accepts the `site_info` read envelope (`{ response: { tariff_content_v2 } }`), the
25
+ * `time_of_use_settings` write envelope (`{ tou_settings: { tariff_content_v2 } }`), or the bare
26
+ * `tariff_content_v2` object itself. Throws {@link InvalidResponse} for a null body, an
27
+ * unrecognized envelope, or an extracted object without the minimal Tariff V2 shape (`seasons`
28
+ * and `energy_charges`) - checked identically on every path, since a malformed body (including an
29
+ * empty `{}` nested under a recognized envelope) is not the same as "no tariff configured".
30
+ */
31
+ export declare function unwrapTariffV2(response: unknown): TariffContentV2;
21
32
  /**
22
33
  * Resolves the current buy/sell time-of-use rate for a Tariff V2 object at a given instant.
23
34
  * `tariff` carries no timezone; the caller must supply the site's IANA zone (e.g. from
package/dist/tariff.mjs CHANGED
@@ -1,4 +1,30 @@
1
+ import { InvalidResponse } from "./exceptions.mjs";
1
2
  // src/tariff.ts
3
+ function isObject(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ /**
7
+ * Extract the `tariff_content_v2` object from a raw API response.
8
+ *
9
+ * Accepts the `site_info` read envelope (`{ response: { tariff_content_v2 } }`), the
10
+ * `time_of_use_settings` write envelope (`{ tou_settings: { tariff_content_v2 } }`), or the bare
11
+ * `tariff_content_v2` object itself. Throws {@link InvalidResponse} for a null body, an
12
+ * unrecognized envelope, or an extracted object without the minimal Tariff V2 shape (`seasons`
13
+ * and `energy_charges`) - checked identically on every path, since a malformed body (including an
14
+ * empty `{}` nested under a recognized envelope) is not the same as "no tariff configured".
15
+ */
16
+ function unwrapTariffV2(response) {
17
+ if (response == null) throw new InvalidResponse("tariff response body was null");
18
+ if (!isObject(response)) throw new InvalidResponse(String(response));
19
+ let candidate;
20
+ if ("tariff_content_v2" in response) candidate = response.tariff_content_v2;
21
+ else if ("response" in response || "tou_settings" in response) {
22
+ const inner = "response" in response ? response.response : response.tou_settings;
23
+ candidate = isObject(inner) ? inner.tariff_content_v2 : void 0;
24
+ } else candidate = response;
25
+ if (isObject(candidate) && "seasons" in candidate && "energy_charges" in candidate) return candidate;
26
+ throw new InvalidResponse(response);
27
+ }
2
28
  const MINUTES_PER_DAY = 1440;
3
29
  function formatParts(date, timeZone) {
4
30
  const parts = new Intl.DateTimeFormat("en-US", {
@@ -288,4 +314,4 @@ function getTariffPeriods(tariff, now, opts) {
288
314
  if (opts.horizonHours) resolution.upcoming = buildUpcoming(tariff.seasons, tariff.energy_charges, sellSeasons, tariff.sell_tariff?.energy_charges, !!tariff.sell_tariff, wallClock, opts.horizonHours * 60, toDate);
289
315
  return resolution;
290
316
  }
291
- export { getTariffPeriods };
317
+ export { getTariffPeriods, unwrapTariffV2 };