From 0cd6d15524faf05b3f254bfabda5b32cf9194eb6 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 23 Jul 2026 21:38:14 +1000 Subject: [PATCH] Sign take_drivenote and upcoming_calendar_entries instead of falling through unsigned Both were inherited from VehicleSpecific and dispatched over the plaintext /command/... REST endpoint, silently bypassing /signed_command on vehicles that require the vehicle-command protocol. Override them as signed Infotainment VehicleActions (takeDrivenoteAction / uiSetUpcomingCalendarEntries), matching python-tesla-fleet-api, with a regression test asserting each goes out over the signed _send path carrying the right protobuf field. --- AGENTS.md | 2 +- src/commands.ts | 31 ++++++++++++++++++++++++++----- test/commands.test.ts | 38 +++++++++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0d23b0d..8ce076f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - Tesla protobuf types (`RoutableMessage`, `vcsec`, `car_server`, `signatures`, `keys`, ...) come from the published `@teslemetry/tesla-protocol` package (subpaths like `@teslemetry/tesla-protocol/command/signatures`), not a bundled `src/pb2`. It is ts-proto generated: plain-object messages with `Type.create(partial)` / `Type.encode(msg).finish()` / `Type.decode(bytes)`, not the old `google-protobuf` `.setX()`/`.serializeBinary()` style. - `tsconfig.json` must keep `"module": "NodeNext"` / `"moduleResolution": "NodeNext"`. Dependencies like `@teslemetry/tesla-protocol` gate access behind a package.json `exports` map with per-subpath types; classic/ESNext resolution can't see those subpaths and every scoped-package import (even `@types/node` internals) fails to resolve. - `pnpm install` (which runs `tsc` via the `prepare` script) is the build gate; run `npx tsc --noEmit` to typecheck without emitting. `pnpm test` runs the vitest suite under `test/` - `tsconfig.json`'s `include` only covers `src/**.ts`, so test files never ship in `dist` and don't need to satisfy the library's own module settings beyond what vitest's esbuild transform requires. -- The signed-command layer is split the same way as `python-tesla-fleet-api`: `src/commands.ts` (`Commands`, abstract) builds and HMAC-signs every vehicle command into a `RoutableMessage` and dispatches it through one abstract seam, `_send`; `src/vehiclesigned.ts` (`VehicleSigned`, concrete) fills that seam over the cloud `/signed_command` HTTP endpoint. A future BLE transport (Story 3) is meant to be another concrete subclass of `Commands`, not a change to it. `src/signing/session.ts` holds the per-domain (VCSEC/Infotainment) handshake + HMAC session state; `src/signing/crypto.ts` holds the raw ECDH/SHA-1 key derivation; `src/signing/errors.ts` holds the fault-error taxonomy that drives `Commands`' bounded WAIT/epoch-fault retry. Only HMAC-personalized signing is implemented - AES-GCM personalized signing is BLE-only and was left out of this story. `take_drivenote`/`upcoming_calendar_entries` are deliberately not overridden in `Commands` (inherited unsigned), matching the same two commands' exclusion in `python-tesla-fleet-api`. `test/helpers/fakevehicle.ts` is a from-scratch reimplementation of the vehicle side of the protocol (independent HMAC verification, not just a mock) used to round-trip-test the client against a second implementation of the same wire format. +- The signed-command layer is split the same way as `python-tesla-fleet-api`: `src/commands.ts` (`Commands`, abstract) builds and HMAC-signs every vehicle command into a `RoutableMessage` and dispatches it through one abstract seam, `_send`; `src/vehiclesigned.ts` (`VehicleSigned`, concrete) fills that seam over the cloud `/signed_command` HTTP endpoint. A future BLE transport (Story 3) is meant to be another concrete subclass of `Commands`, not a change to it. `src/signing/session.ts` holds the per-domain (VCSEC/Infotainment) handshake + HMAC session state; `src/signing/crypto.ts` holds the raw ECDH/SHA-1 key derivation; `src/signing/errors.ts` holds the fault-error taxonomy that drives `Commands`' bounded WAIT/epoch-fault retry. Only HMAC-personalized signing is implemented - AES-GCM personalized signing is BLE-only and was left out of this story. Every command a signing-required vehicle would reject on the plaintext endpoint must be overridden in `Commands` so it is signed rather than silently inherited unsigned from `VehicleSpecific`: `take_drivenote` (`takeDrivenoteAction`) and `upcoming_calendar_entries` (`uiSetUpcomingCalendarEntries`) are signed Infotainment actions here, matching `python-tesla-fleet-api`. `test/helpers/fakevehicle.ts` is a from-scratch reimplementation of the vehicle side of the protocol (independent HMAC verification, not just a mock) used to round-trip-test the client against a second implementation of the same wire format. - A `SessionInfo` (handshake reply, or piggy-backed on any later reply for resync) is untrusted wire data until `Commands.validateAndUpdateSession` verifies its `signatureData.sessionInfoTag` - an HMAC-SHA256 over the exact received `session_info` bytes, keyed by `HMAC-SHA256(K, "session info")`, TLV-metadata-bound to the VIN and the request's own `uuid` as a challenge (`Signatures.SIGNATURE_TYPE_HMAC` + `TAG_PERSONALIZATION` + `TAG_CHALLENGE`, per `github.com/teslamotors/vehicle-command`'s `pkg/protocol/protocol.md` "Response authentication" section - fetch that file directly from GitHub via `gh api` for the authoritative spec and its own worked test vectors, which `test/hmac-golden-vector.test.ts`'s "Official teslamotors/vehicle-command test vectors" block reproduces exactly). Only once that tag verifies does `Session.commit` apply the new epoch/counter/clock - and even then it refuses (returns `false`) a clock time that regresses within the same epoch, and never lets the anti-replay counter roll backward within an epoch. Skipping tag verification, or trusting counter/clock from an unauthenticated reply, is a real vulnerability here (confirmed against the upstream spec, not just an automated reviewer's guess) - don't reintroduce it. - **`resp.requestUuid` must never gate acceptance of a `SessionInfo` reply - only cross-check it when the vehicle actually populated it.** Real VCSEC hardware leaves it empty (protocol.md: "due to memory constraints, the `request_uuid` field is typically not populated in replies from VCSEC"); an equality-or-reject guard here silently breaks every VCSEC command (`door_lock`, `door_unlock`, `actuate_truck`, `charge_port_door_open/close`, `remote_start_drive`) against real vehicles even though `test/helpers/fakevehicle.ts` (which always echoes it) can't catch the regression. The `session_info_tag` HMAC - which binds the *sent* uuid as `TAG_CHALLENGE` - is the actual authentication; `FakeVehicle.omitRequestUuidLikeRealVcsec()` reproduces the real-hardware shape in tests. - For `DOMAIN_VEHICLE_SECURITY`, `Commands.dispatch` holds the domain's session lock across the *entire* dispatch (handshake + build + send + retry), not just message-build - VCSEC requires messages to arrive in strict counter order and the spec warns against simultaneous requests to it at all, unlike Infotainment (sliding window), which keeps the narrower build-only lock. diff --git a/src/commands.ts b/src/commands.ts index fa7a0a9..98657f5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -76,6 +76,8 @@ import { DrivingClearSpeedLimitPinAdminAction, ScheduledChargingAction, ScheduledDepartureAction, + TakeDrivenoteAction, + UiSetUpcomingCalendarEntries, } from "@teslemetry/tesla-protocol/command/car_server"; import { Void, LatLong, PreconditioningTimes, OffPeakChargingTimes, StwHeatLevel } from "@teslemetry/tesla-protocol/command/common"; import { VehicleState_GuestMode, ClimateState_CopActivationTemp } from "@teslemetry/tesla-protocol/command/vehicle"; @@ -491,11 +493,6 @@ export default abstract class Commands extends VehicleSpecific { } // Vehicle Commands - // - // `take_drivenote` and `upcoming_calendar_entries` are intentionally not - // overridden here - they are inherited unsigned from `VehicleSpecific`, - // matching python-tesla-fleet-api's `Commands`, which excludes them with - // the same "doesn't require signing" rationale. /** * Controls the front (which_trunk: "front") or rear (which_trunk: "rear") trunk. @@ -1080,6 +1077,17 @@ export default abstract class Commands extends VehicleSpecific { return this.vehicleAction(VehicleAction.create({ vehicleControlSunroofOpenCloseAction: action })); } + /** + * Records a drive note. Overrides the inherited plaintext command so it is + * signed and dispatched over the vehicle-command protocol rather than + * silently falling through to the unsigned REST endpoint on vehicles that + * require signing. + * @param note Drive note + */ + async take_drivenote(note: string): Promise { + return this.vehicleAction(VehicleAction.create({ takeDrivenoteAction: TakeDrivenoteAction.create({ note }) })); + } + /** * Turns on HomeLink (used to open and close garage doors). * @param lat Latitude @@ -1097,6 +1105,19 @@ export default abstract class Commands extends VehicleSpecific { ); } + /** + * Sends upcoming calendar entries to the vehicle. Overrides the inherited + * plaintext command so it is signed and dispatched over the vehicle-command + * protocol rather than silently falling through to the unsigned REST + * endpoint on vehicles that require signing. + * @param calendar_data Calendar data + */ + async upcoming_calendar_entries(calendar_data: string): Promise { + return this.vehicleAction( + VehicleAction.create({ uiSetUpcomingCalendarEntries: UiSetUpcomingCalendarEntries.create({ calendarData: calendar_data }) }), + ); + } + /** * Control the windows of a parked vehicle. Supported commands: vent and close. * @param command "vent" or "close" diff --git a/test/commands.test.ts b/test/commands.test.ts index 0b25836..5961c6e 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { RoutableMessage } from "@teslemetry/tesla-protocol/command/universal_message"; +import { Domain, RoutableMessage } from "@teslemetry/tesla-protocol/command/universal_message"; import { VehicleStatus } from "@teslemetry/tesla-protocol/command/vcsec"; +import { Action, VehicleAction } from "@teslemetry/tesla-protocol/command/car_server"; import Commands from "../src/commands.js"; import Vehicle from "../src/vehicle.js"; import { NotOnVehicleWhitelistError, SignedCommandFaultError } from "../src/signing/errors.js"; @@ -276,6 +277,41 @@ describe("Commands: HMAC round trip against an independent vehicle simulator", ( }); }); +describe("Commands: drivenote and calendar entries are signed, not sent unsigned", () => { + // Regression test: take_drivenote and upcoming_calendar_entries are inherited + // from VehicleSpecific and previously fell through to the plaintext + // /command/... REST endpoint, silently bypassing /signed_command on vehicles + // that require the vehicle-command protocol. They are now overridden to sign + // and dispatch as Infotainment VehicleActions (matching python-tesla-fleet-api). + + /** Captures every signed Infotainment VehicleAction that actually goes out over `_send`. */ + class CapturingCommands extends TestCommands { + capturedActions: VehicleAction[] = []; + + protected async _send(msg: RoutableMessage, requires: string, expectsData?: boolean, confirmBroadcast?: (status: VehicleStatus) => boolean): Promise { + if (msg.protobufMessageAsBytes && msg.protobufMessageAsBytes.length > 0 && msg.toDestination?.domain === Domain.DOMAIN_INFOTAINMENT) { + const action = Action.decode(msg.protobufMessageAsBytes); + if (action.vehicleAction) { + this.capturedActions.push(action.vehicleAction); + } + } + return super._send(msg, requires, expectsData, confirmBroadcast); + } + } + + it("signs take_drivenote as an Infotainment VehicleAction instead of falling through unsigned", async () => { + const commands = new CapturingCommands(); + await expect(commands.take_drivenote("check tire pressure")).resolves.toMatchObject({ response: { result: true } }); + expect(commands.capturedActions.some((a) => a.takeDrivenoteAction?.note === "check tire pressure")).toBe(true); + }); + + it("signs upcoming_calendar_entries as an Infotainment VehicleAction instead of falling through unsigned", async () => { + const commands = new CapturingCommands(); + await expect(commands.upcoming_calendar_entries("BEGIN:VCALENDAR")).resolves.toMatchObject({ response: { result: true } }); + expect(commands.capturedActions.some((a) => a.uiSetUpcomingCalendarEntries?.calendarData === "BEGIN:VCALENDAR")).toBe(true); + }); +}); + describe("Commands: protobuf construction for representative commands", () => { let commands: TestCommands;