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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 26 additions & 5 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<CommandResponse> {
return this.vehicleAction(VehicleAction.create({ takeDrivenoteAction: TakeDrivenoteAction.create({ note }) }));
}

/**
* Turns on HomeLink (used to open and close garage doors).
* @param lat Latitude
Expand All @@ -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<CommandResponse> {
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"
Expand Down
38 changes: 37 additions & 1 deletion test/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<RoutableMessage> {
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;

Expand Down
Loading