diff --git a/demo/carla/README.md b/demo/carla/README.md index 55ad677d74d..9445197e29c 100644 --- a/demo/carla/README.md +++ b/demo/carla/README.md @@ -98,7 +98,7 @@ A bystander's e-stop, a command the gate refused, or an accepted `respawn` ```json { "accepted": { "steering": .., "throttle": .., "brake": .., "gear": ".." }, "autonomy": "autonomous|manual", "estopped": true, - "driver": "driver-ab12cd", "estop_by": "driver-ef34gh", + "driver": "driver-3eb1bd43", "estop_by": "driver-9c0a7f12", "map": "Town10HD_Opt", "pose": { "x": 12.35, "y": -78.9, "yaw": 180.1, "speed": 9.88 }, "measured": { "steering": .., "throttle": .., "brake": .., "gear": ".." }, @@ -149,15 +149,17 @@ The worker's gate (`sim/moq_carla_sim/drive.py`) owns every truck's controls: e-stop latched, that has not moved 2 m in 5 minutes of a running world returns to a free spawn point under autonomy, its e-stop cleared. -The lease is cooperative. Anonymous drivers share one grant, publish -`carla/.socket/driver-*`, so anyone can announce an endpoint under the id that -`status` names as the holder. That never continues the holder's lease, since a -lease is one session, but it can disrupt it: the relay may route the truck's -accept of the holder's next session to the impostor. Truck names must not look -like driver ids, or the same grant would let anyone impersonate a truck; the -worker refuses one. So the lease stops two honest drivers fighting over a -truck, not a deliberate disruption. Anything beyond a simulator needs -per-driver tokens scoped to the driver's own endpoint. +The lease is cooperative. Anonymous drivers share one grant to publish +`carla/.socket/driver-*`, so an endpoint id is all it takes to announce in a +driver's place and win the relay's route to its next dial. Each driver's id is +therefore 128 random bits, and `status` names the holder and the e-stop's +sender by a handle instead: `driver-` and 8 hex digits of the SHA-256 of the +session's secret id, which the driver derives the same way to recognize its +own lease. Truck names must not look like driver ids, or the same grant would +let anyone impersonate a truck; the worker refuses one. Anyone may still claim +a free truck or press its e-stop, so the lease stops two honest drivers +fighting over a truck, not a deliberate disruption. Anything beyond a simulator +needs per-driver tokens scoped to the driver's own endpoint. ## The simulator diff --git a/demo/carla/sim/moq_carla_sim/drive.py b/demo/carla/sim/moq_carla_sim/drive.py index 48c530f4793..93faa3b1a64 100644 --- a/demo/carla/sim/moq_carla_sim/drive.py +++ b/demo/carla/sim/moq_carla_sim/drive.py @@ -8,6 +8,7 @@ from __future__ import annotations +import hashlib from dataclasses import dataclass, field, replace from enum import Enum from typing import Literal @@ -23,6 +24,16 @@ class Gear(str, Enum): REVERSE = "reverse" +def handle(session: str) -> str: + """The name `status` gives the lease or e-stop a session carried. + + A hash of the session id, which only that session's two ends know, so the + driver recognizes its own lease while no viewer learns its endpoint id, + which anyone could announce to take the route to that driver's next dial. + """ + return "driver-" + hashlib.sha256(session.encode()).hexdigest()[:8] + + def _clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) @@ -94,11 +105,12 @@ class Command: `drive`, `estop`, `reset_estop`, and `respawn` arrive on the wire; `driver_left` and `watchdog_expired` are synthesized locally. - `driver_id` is the driver's socket endpoint id, which `status` echoes. + `driver_id` is the driver's socket endpoint id, which stays private. `session` is the socket session that carried it, one per lease the player takes: the same id on another session is a different driver for the lease. - The session id is the secret that keeps a session to its two ends, so it - never leaves this process. + The session id is the secret that keeps a session to its two ends, so only + its `handle` leaves this process. Only a locally synthesized command has + none. """ kind: CommandKind @@ -117,12 +129,9 @@ class Drive: The lease binds to the driver's socket session, so a driver whose session ends loses it, and another session claiming the same driver id is another - driver. It is still cooperative, not enforced: anonymous drivers share one - grant, so anyone can announce an endpoint under the holder's id, which the - public `status` track echoes. Their own sessions never continue the - holder's lease, but they can disrupt it: a relay may route the truck's - accept of the holder's next session to the impostor instead. This stops two - honest drivers colliding; it does not stop a deliberate disruption. + driver. `driver` and `estop_by` are session handles, never endpoint ids, so + the public `status` track gives nobody an id to announce in the holder's + place. """ def __init__(self) -> None: @@ -135,7 +144,8 @@ def __init__(self) -> None: @property def driver(self) -> str | None: - return self._driver + """The lease holder's handle, or None while nobody holds it.""" + return None if self._session is None else handle(self._session) @property def state(self) -> DriveState: @@ -147,6 +157,7 @@ def estopped(self) -> bool: @property def estop_by(self) -> str | None: + """The handle of the session that latched the e-stop.""" return self._estop_by def holds(self, command: Command) -> bool: @@ -159,7 +170,8 @@ def accepted(self) -> Accepted: def apply(self, command: Command) -> bool: """Apply a command, returning whether it changed or refreshed state.""" if command.kind == "drive": - if self._estopped or not (self._driver is None or self.holds(command)): + # A lease binds to a session, so a command without one cannot hold it. + if command.session is None or self._estopped or not (self._driver is None or self.holds(command)): return False claiming = self._driver is None self._driver = command.driver_id @@ -187,8 +199,8 @@ def apply(self, command: Command) -> bool: # legitimately claimed. self._state = DriveState.stopped() self._estopped = True - if not was_estopped: - self._estop_by = command.driver_id + if not was_estopped and command.session is not None: + self._estop_by = handle(command.session) elif command.kind == "reset_estop": if not self._estopped: diff --git a/demo/carla/sim/moq_carla_sim/publish.py b/demo/carla/sim/moq_carla_sim/publish.py index a7f36b14e44..a1f4d9e10ec 100644 --- a/demo/carla/sim/moq_carla_sim/publish.py +++ b/demo/carla/sim/moq_carla_sim/publish.py @@ -233,7 +233,7 @@ def _parse_timestamps(raw: Any) -> tuple[TimestampEntry, ...]: return tuple(entries.values()) -def parse_command(driver_id: str, message: Any, session: str | None = None) -> Command | None: +def parse_command(driver_id: str, message: Any, session: str) -> Command | None: """Turn one control message from a driver's session into a Command. Returns None for anything unrecognized. A driver is an untrusted publisher, diff --git a/demo/carla/sim/tests/test_drive.py b/demo/carla/sim/tests/test_drive.py index c54d61cb4e0..499d77d2da7 100644 --- a/demo/carla/sim/tests/test_drive.py +++ b/demo/carla/sim/tests/test_drive.py @@ -4,31 +4,32 @@ import pytest -from moq_carla_sim.drive import Command, Drive, DriveState, Gear +from moq_carla_sim.drive import Command, Drive, DriveState, Gear, handle def drive(driver_id: str, sequence: int, throttle: float) -> Command: return Command( kind="drive", driver_id=driver_id, + session=driver_id, sequence=sequence, state=DriveState(throttle=throttle, brake=0.0), ) def estop(driver_id: str, sequence: int) -> Command: - return Command(kind="estop", driver_id=driver_id, sequence=sequence) + return Command(kind="estop", driver_id=driver_id, session=driver_id, sequence=sequence) def reset(driver_id: str, sequence: int) -> Command: - return Command(kind="reset_estop", driver_id=driver_id, sequence=sequence) + return Command(kind="reset_estop", driver_id=driver_id, session=driver_id, sequence=sequence) def test_first_driver_keeps_control() -> None: state = Drive() assert state.apply(drive("first", 1, 0.5)) assert not state.apply(drive("second", 2, 1.0)) - assert state.driver == "first" + assert state.driver == handle("first") assert state.state.throttle == 0.0 assert state.state.brake == 1.0 assert state.apply(drive("first", 2, 0.6)) @@ -46,14 +47,32 @@ def test_the_lease_belongs_to_one_session_of_a_driver() -> None: assert not state.apply(second) assert state.state.throttle == 0.5 assert not state.apply(Command(kind="driver_left", driver_id="abc", session="s2")) - assert state.driver == "abc" + assert state.driver == handle("s1") # The first session leaving frees the lease for the second. assert state.apply(Command(kind="driver_left", driver_id="abc", session="s1")) assert state.driver is None assert state.apply(second) assert state.holds(second) - assert state.driver == "abc" + assert state.driver == handle("s2") + + +def test_status_names_sessions_by_a_handle_the_player_derives_too() -> None: + """The handle, never the endpoint id, names the holder and the e-stop's sender. + `web/src/paths.test.ts` pins the player to the same handle for this session.""" + state = Drive() + session = "0123456789abcdef0123456789abcdef" + assert state.apply(Command(kind="drive", driver_id="driver-holder", session=session)) + assert state.apply(Command(kind="estop", driver_id="driver-holder", session=session)) + assert state.driver == state.estop_by == "driver-3eb1bd43" + + +def test_a_command_without_a_session_takes_no_lease_but_still_stops() -> None: + state = Drive() + assert not state.apply(Command(kind="drive", driver_id="driver")) + assert state.driver is None + assert state.apply(Command(kind="estop", driver_id="driver")) + assert state.estopped and state.estop_by is None def test_estop_latches_until_explicit_reset() -> None: @@ -81,11 +100,11 @@ def test_disconnect_does_not_clear_estop() -> None: assert state.apply(drive("driver", 1, 1.0)) assert state.apply(estop("driver", 2)) assert state.estopped - assert state.driver == "driver" + assert state.driver == handle("driver") # The driver disconnects: the lease releases but the latch must persist, so # the truck stays stopped until an explicit reset. - assert state.apply(Command(kind="driver_left", driver_id="driver")) + assert state.apply(Command(kind="driver_left", driver_id="driver", session="driver")) assert state.estopped assert state.driver is None @@ -97,15 +116,15 @@ def test_estop_from_a_bystander_latches_without_transferring_the_lease() -> None # Anyone may hit the big red button, so the latch must engage... assert state.apply(estop("second", 2)) assert state.estopped - assert state.estop_by == "second" + assert state.estop_by == handle("second") # ...but the lease stays with "first". - assert state.driver == "first" + assert state.driver == handle("first") # The holder's own e-stop does not replace the actor that originally # latched the stop. assert state.apply(estop("first", 3)) - assert state.estop_by == "second" + assert state.estop_by == handle("second") assert state.holds(estop("first", 3)) # Anyone may clear the global latch, but doing so does not transfer the @@ -113,17 +132,18 @@ def test_estop_from_a_bystander_latches_without_transferring_the_lease() -> None assert state.apply(reset("second", 4)) assert not state.estopped assert state.estop_by is None - assert state.driver == "first" + assert state.driver == handle("first") assert not state.apply(drive("second", 5, 1.0)) def test_axes_are_clamped_into_range() -> None: state = Drive() - assert state.apply(Command(kind="drive", driver_id="driver", sequence=1)) + assert state.apply(Command(kind="drive", driver_id="driver", session="driver", sequence=1)) assert state.apply( Command( kind="drive", driver_id="driver", + session="driver", sequence=2, state=DriveState(steering=-4.0, throttle=9.0, brake=-1.0, gear=Gear.FORWARD), ) @@ -143,6 +163,7 @@ def test_every_new_lease_resets_pedals_without_changing_steering_or_gear(gear: G command = Command( kind="drive", driver_id="driver", + session="driver", sequence=1, state=DriveState(steering=0.3, throttle=1.0, brake=0.0, gear=gear), ) @@ -172,7 +193,12 @@ def test_every_new_lease_resets_pedals_without_changing_steering_or_gear(gear: G def test_takeover_brake_allows_a_forward_claim_without_autonomous_feedback() -> None: state = Drive() assert state.apply( - Command(kind="drive", driver_id="driver", state=DriveState(throttle=0.85, brake=0.0, gear=Gear.FORWARD)) + Command( + kind="drive", + driver_id="driver", + session="driver", + state=DriveState(throttle=0.85, brake=0.0, gear=Gear.FORWARD), + ) ) assert state.state == DriveState(gear=Gear.FORWARD) @@ -185,6 +211,7 @@ def test_gear_change_requires_brake_and_released_throttle() -> None: Command( kind="drive", driver_id="driver", + session="driver", sequence=1, state=DriveState.stopped(), ) @@ -194,6 +221,7 @@ def test_gear_change_requires_brake_and_released_throttle() -> None: Command( kind="drive", driver_id="driver", + session="driver", sequence=2, state=DriveState(throttle=0.0, brake=0.0, gear=Gear.FORWARD), ) @@ -204,6 +232,7 @@ def test_gear_change_requires_brake_and_released_throttle() -> None: Command( kind="drive", driver_id="driver", + session="driver", sequence=3, state=DriveState(throttle=0.01, brake=0.001, gear=Gear.FORWARD), ) @@ -214,6 +243,7 @@ def test_gear_change_requires_brake_and_released_throttle() -> None: Command( kind="drive", driver_id="driver", + session="driver", sequence=4, state=DriveState(throttle=0.02, brake=1.0, gear=Gear.REVERSE), ) diff --git a/demo/carla/sim/tests/test_publish.py b/demo/carla/sim/tests/test_publish.py index 9b41c3d3b74..d182008194c 100644 --- a/demo/carla/sim/tests/test_publish.py +++ b/demo/carla/sim/tests/test_publish.py @@ -6,7 +6,7 @@ import pytest from moq_carla_sim import publish -from moq_carla_sim.drive import Command, Drive, DriveState, Gear, TimestampEntry +from moq_carla_sim.drive import Command, Drive, DriveState, Gear, TimestampEntry, handle from moq_carla_sim.publish import ( Collision, Motion, @@ -56,6 +56,7 @@ def test_malformed_timestamp_does_not_drop_driver_command() -> None: {"label": "received", "ts": 12.5}, ], }, + "s1", ) assert command is not None @@ -69,6 +70,7 @@ def test_only_the_players_pipeline_stages_are_kept() -> None: command = parse_command( "driver", {"type": "drive", "sequence": 1, "timestamps": [*stages, *junk, {"label": "received", "ts": 9.0}]}, + "s1", ) assert command is not None @@ -79,7 +81,7 @@ def test_only_the_players_pipeline_stages_are_kept() -> None: def test_respawn_command_is_carla_only_control_input() -> None: - command = parse_command("driver", {"type": "respawn", "sequence": 8}) + command = parse_command("driver", {"type": "respawn", "sequence": 8}, "s1") assert command is not None assert command.kind == "respawn" @@ -287,7 +289,7 @@ async def scenario(endpoint: Endpoint, drivers, commands: asyncio.Queue, gate: D session.send(DRIVE) command = await next_command(commands, gate) assert (command.kind, command.driver_id, command.session) == ("drive", "driver-abc", "s1") - assert gate.driver == "driver-abc" + assert gate.driver == handle("s1") session.end() left = await next_command(commands, gate) @@ -352,7 +354,7 @@ async def scenario(endpoint: Endpoint, drivers, commands: asyncio.Queue, gate: D second.end() assert (await next_command(commands, gate)).kind == "driver_left" - assert gate.driver == "driver-abc" + assert gate.driver == handle("s1") assert gate.holds(Command(kind="drive", driver_id="driver-abc", session="s1")) asyncio.run(serving(scenario, monkeypatch)) diff --git a/demo/carla/sim/tests/test_runtime.py b/demo/carla/sim/tests/test_runtime.py index 8912de2a135..2280f25b990 100644 --- a/demo/carla/sim/tests/test_runtime.py +++ b/demo/carla/sim/tests/test_runtime.py @@ -5,7 +5,7 @@ import pytest from moq_carla_sim import runtime -from moq_carla_sim.drive import Command, Drive, DriveState, Gear, TimestampEntry +from moq_carla_sim.drive import Command, Drive, DriveState, Gear, TimestampEntry, handle from moq_carla_sim.publish import Pose from moq_carla_sim.runtime import MAX_STAGE_MS, StuckDetector, latency_breakdown from moq_carla_sim.world import Truck @@ -68,7 +68,7 @@ def test_takeover_preserves_steering_and_gear_but_resets_pedals(gear: Gear) -> N async def scenario() -> None: measured = DriveState(steering=-0.3, throttle=0.85, brake=0.0, gear=gear) commands = asyncio.Queue() - commands.put_nowait(Command(kind="drive", driver_id="driver", sequence=1, state=measured)) + commands.put_nowait(Command(kind="drive", driver_id="driver", session="driver", sequence=1, state=measured)) session = runtime.TruckSession.__new__(runtime.TruckSession) session._clock = FakeClock() @@ -91,7 +91,7 @@ async def scenario() -> None: await asyncio.wait({task, published_waiter}, return_when=asyncio.FIRST_COMPLETED) if task.done(): await task - assert session._drive.driver == "driver" + assert session._drive.driver == handle("driver") assert session._drive.state == DriveState(steering=-0.3, throttle=0.0, brake=1.0, gear=gear) assert actuated == [(session._drive.accepted(), True)] finally: @@ -150,10 +150,10 @@ async def scenario() -> None: def test_only_unestopped_lease_holder_can_respawn_truck() -> None: async def scenario() -> None: commands = asyncio.Queue() - commands.put_nowait(Command(kind="respawn", driver_id="bystander", sequence=2)) - commands.put_nowait(Command(kind="respawn", driver_id="holder", sequence=3)) - commands.put_nowait(Command(kind="reset_estop", driver_id="holder", sequence=4)) - commands.put_nowait(Command(kind="respawn", driver_id="holder", sequence=5)) + commands.put_nowait(Command(kind="respawn", driver_id="bystander", session="bystander", sequence=2)) + commands.put_nowait(Command(kind="respawn", driver_id="holder", session="holder", sequence=3)) + commands.put_nowait(Command(kind="reset_estop", driver_id="holder", session="holder", sequence=4)) + commands.put_nowait(Command(kind="respawn", driver_id="holder", session="holder", sequence=5)) respawns: list[str] = [] published: list[bool] = [] @@ -162,8 +162,10 @@ async def scenario() -> None: session._truck = SimpleNamespace(name="truck1", respawn=lambda: respawns.append("truck1")) session._drive = Drive() session._stuck = StuckDetector() - assert session._drive.apply(Command(kind="drive", driver_id="holder", sequence=1, state=DriveState())) - assert session._drive.apply(Command(kind="estop", driver_id="bystander", sequence=2)) + assert session._drive.apply( + Command(kind="drive", driver_id="holder", session="holder", sequence=1, state=DriveState()) + ) + assert session._drive.apply(Command(kind="estop", driver_id="bystander", session="bystander", sequence=2)) session._commands = commands session._deadline = None session._drivers = Replies() @@ -445,21 +447,24 @@ async def next_command() -> Command: # Claim with the brake applied, then supply a pedal input. This also # exercises a genuine manual takeover with the takeover pedal hold. - await command(Command(kind="drive", driver_id="holder", sequence=1, state=DriveState(gear=Gear.FORWARD))) + await command( + Command(kind="drive", driver_id="holder", session="holder", sequence=1, state=DriveState(gear=Gear.FORWARD)) + ) await command( Command( kind="drive", driver_id="holder", + session="holder", sequence=2, state=DriveState(throttle=0.5, brake=0.0, gear=Gear.FORWARD), ) ) assert not truck.autonomous assert controls[-1].throttle == 0.5 - await command(Command(kind="estop", driver_id="holder", sequence=3)) + await command(Command(kind="estop", driver_id="holder", session="holder", sequence=3)) assert controls[-1].brake == 1.0 and controls[-1].hand_brake if reset_before_handback: - await command(Command(kind="reset_estop", driver_id="holder", sequence=4)) + await command(Command(kind="reset_estop", driver_id="holder", session="holder", sequence=4)) if handback == "watchdog_expired": assert session._deadline is not None clock = session._deadline @@ -467,7 +472,7 @@ async def next_command() -> Command: assert expired.kind == "watchdog_expired" await command(expired) else: - await command(Command(kind="driver_left", driver_id="holder")) + await command(Command(kind="driver_left", driver_id="holder", session="holder")) assert session._drive.driver is None assert session._drive.estopped is not reset_before_handback assert truck.autonomous is reset_before_handback @@ -494,26 +499,28 @@ async def sleep(delay: float) -> None: assert published[-1] == (False, None, True) # Until then the latch stood, attributed to the driver who set it. if not reset_before_handback: - assert (True, "holder", False) in published + assert (True, handle("holder"), False) in published asyncio.run(scenario()) def holding(drive: Drive) -> None: """A driver parked with the brake on.""" - assert drive.apply(Command(kind="drive", driver_id="holder", sequence=1, state=DriveState(brake=1.0))) + assert drive.apply( + Command(kind="drive", driver_id="holder", session="holder", sequence=1, state=DriveState(brake=1.0)) + ) def estopped_holding(drive: Drive) -> None: """A driver who e-stopped and still holds the lease.""" holding(drive) - assert drive.apply(Command(kind="estop", driver_id="holder", sequence=2)) - assert drive.driver == "holder" and drive.estopped + assert drive.apply(Command(kind="estop", driver_id="holder", session="holder", sequence=2)) + assert drive.driver == handle("holder") and drive.estopped def estopped_by_bystander(drive: Drive) -> None: """A bystander's e-stop outlives the lease it never held.""" - assert drive.apply(Command(kind="estop", driver_id="bystander", sequence=1)) + assert drive.apply(Command(kind="estop", driver_id="bystander", session="bystander", sequence=1)) assert drive.driver is None and drive.estopped @@ -609,7 +616,9 @@ def test_a_driver_keeps_the_world_stepping_with_nobody_watching() -> None: async def scenario() -> None: commands = asyncio.Queue() - commands.put_nowait(Command(kind="drive", driver_id="holder", sequence=1, state=DriveState(throttle=0.5))) + commands.put_nowait( + Command(kind="drive", driver_id="holder", session="holder", sequence=1, state=DriveState(throttle=0.5)) + ) session = runtime.TruckSession.__new__(runtime.TruckSession) session._clock = FakeClock() @@ -736,14 +745,14 @@ async def settle(count: int) -> None: task = asyncio.create_task(session._loop()) try: session._commands.put_nowait( - Command(kind="drive", driver_id="holder", sequence=1, state=DriveState(brake=1.0)) + Command(kind="drive", driver_id="holder", session="holder", sequence=1, state=DriveState(brake=1.0)) ) await settle(1) assert session._clock.held == {"drive:truck1"} - session._commands.put_nowait(Command(kind="estop", driver_id="holder", sequence=2)) + session._commands.put_nowait(Command(kind="estop", driver_id="holder", session="holder", sequence=2)) await settle(2) - assert session._drive.driver == "holder" + assert session._drive.driver == handle("holder") assert session._clock.held == set() assert session._deadline == runtime.COMMAND_TIMEOUT @@ -752,9 +761,9 @@ async def settle(count: int) -> None: # marks the refused frame as processed. now = runtime.COMMAND_TIMEOUT / 2 session._commands.put_nowait( - Command(kind="drive", driver_id="holder", sequence=3, state=DriveState(throttle=0.5)) + Command(kind="drive", driver_id="holder", session="holder", sequence=3, state=DriveState(throttle=0.5)) ) - session._commands.put_nowait(Command(kind="estop", driver_id="bystander", sequence=1)) + session._commands.put_nowait(Command(kind="estop", driver_id="bystander", session="bystander", sequence=1)) await settle(3) assert session._deadline == runtime.COMMAND_TIMEOUT assert session._clock.held == set() diff --git a/demo/carla/web/src/hint.test.ts b/demo/carla/web/src/hint.test.ts index 454e8c5e391..399e1fdd20a 100644 --- a/demo/carla/web/src/hint.test.ts +++ b/demo/carla/web/src/hint.test.ts @@ -6,7 +6,7 @@ const active: ControlHintState = { focused: true, estopped: false, status: "pending", - driverId: "mine", + handle: "mine", wheel: false, }; diff --git a/demo/carla/web/src/hint.ts b/demo/carla/web/src/hint.ts index 5ef977b9430..57e5f54bddb 100644 --- a/demo/carla/web/src/hint.ts +++ b/demo/carla/web/src/hint.ts @@ -4,7 +4,8 @@ export interface ControlHintState { estopped: boolean; estopBy?: string; status: "pending" | "available" | "unavailable"; - driverId?: string; + /** How `status` names this player's lease. */ + handle?: string; statusDriver?: string; wheel: boolean; } @@ -24,8 +25,8 @@ export function controlHint(state: ControlHintState): string { if (!state.active && state.status === "pending") return "Waiting for vehicle status"; if (!state.active) return "Take control to drive"; if (state.status === "unavailable") return "Vehicle status lost · Control unconfirmed"; - if (!state.driverId || !state.statusDriver) return "Requesting control…"; - if (state.statusDriver !== state.driverId) return `Driven by ${state.statusDriver}`; + if (!state.handle || !state.statusDriver) return "Requesting control…"; + if (state.statusDriver !== state.handle) return `Driven by ${state.statusDriver}`; if (state.wheel) { return "Wheel · Brake before shifting · Esc to release"; } diff --git a/demo/carla/web/src/paths.test.ts b/demo/carla/web/src/paths.test.ts index 2974f6de172..7880edaf578 100644 --- a/demo/carla/web/src/paths.test.ts +++ b/demo/carla/web/src/paths.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseCamera } from "./paths.ts"; +import { driverId, handle, parseCamera } from "./paths.ts"; describe("parseCamera", () => { test("accepts exactly carla//.hang", () => { @@ -19,3 +19,15 @@ describe("parseCamera", () => { } }); }); + +describe("driver names", () => { + test("an endpoint id carries 128 random bits", () => { + expect(driverId()).toMatch(/^driver-[0-9a-f]{32}$/); + expect(driverId()).not.toBe(driverId()); + }); + + test("a session's handle is the one the worker publishes", async () => { + // Pinned by the worker's test_status_names_sessions_by_a_handle_the_player_derives_too. + expect(await handle("0123456789abcdef0123456789abcdef")).toBe("driver-3eb1bd43"); + }); +}); diff --git a/demo/carla/web/src/paths.ts b/demo/carla/web/src/paths.ts index b71eea0fc19..ec63f07f39a 100644 --- a/demo/carla/web/src/paths.ts +++ b/demo/carla/web/src/paths.ts @@ -4,7 +4,8 @@ * Trucks publish one media broadcast per camera at `carla/{truck}/{camera}.hang`, * and accept drivers on a socket endpoint named for the truck. A driver dials it * from an endpoint of its own, `driver-{id}`, a form the demo relay lets - * anonymous viewers publish. + * anonymous viewers publish. The truck's public `status` names a driver by its + * session's {@link handle} instead, so no viewer learns an id it could announce. * * @module */ @@ -37,7 +38,21 @@ export const SOCKET = Moq.Path.from("carla"); */ export const VERSIONS = ["moq-lite-07-wip", "moq-transport-22"] as const; -/** A fresh driver endpoint id. Public, since the truck's status names its driver. */ +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** A fresh driver endpoint id: random, so no other viewer can claim it. */ export function driverId(): string { - return `driver-${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`; + return `driver-${hex(crypto.getRandomValues(new Uint8Array(16)))}`; +} + +/** + * The name the truck's `status` gives the lease or e-stop a session carried, as the worker's `drive.handle` derives it. + * + * A hash of the session id, which only the session's two ends know. + */ +export async function handle(session: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(session)); + return `driver-${hex(new Uint8Array(digest, 0, 4))}`; } diff --git a/demo/carla/web/src/player.ts b/demo/carla/web/src/player.ts index 564d6a0a765..0b890b141b7 100644 --- a/demo/carla/web/src/player.ts +++ b/demo/carla/web/src/player.ts @@ -1011,15 +1011,11 @@ export function mount(root: ShadowRoot, connection: Moq.Connection, origin: Moq. // Status carries the measured takeover state. Never guess while it is unavailable. controlButton.disabled = !active && !status; const respawnButton = q(".respawn-btn") as HTMLButtonElement; - const driverId = effect.get(this.truck.driverId); + const handle = effect.get(this.truck.handle); const estopped = effect.get(this.truck.estopped) || Boolean(status?.estopped); respawnButton.hidden = !isFocused; respawnButton.disabled = - effect.get(this.truck.respawning) || - !active || - !driverId || - status?.driver !== driverId || - estopped; + effect.get(this.truck.respawning) || !active || !handle || status?.driver !== handle || estopped; // Latched if either side thinks so: the local signal reacts // instantly, the status echo is authoritative (see resetEstop). const stopButton = q(".estop-btn") as HTMLButtonElement; @@ -1049,7 +1045,7 @@ export function mount(root: ShadowRoot, connection: Moq.Connection, origin: Moq. estopped, estopBy: status?.estop_by, status: statusState, - driverId, + handle, statusDriver: status?.driver, wheel: effect.get(source) === "wheel", }); diff --git a/demo/carla/web/src/truck.test.ts b/demo/carla/web/src/truck.test.ts index de263f6927a..ccf90b7bee6 100644 --- a/demo/carla/web/src/truck.test.ts +++ b/demo/carla/web/src/truck.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import * as Json from "@moq/json"; import * as Moq from "@moq/net"; import { applyKeys, REST } from "./input.ts"; -import { SOCKET } from "./paths.ts"; +import { handle, SOCKET } from "./paths.ts"; import type { TruckStatus } from "./schemas.ts"; import { shiftGear, Truck } from "./truck.ts"; @@ -452,9 +452,11 @@ describe("Truck sessions", () => { const incoming = endpoint.acceptTrack(); driving.set(truck.id); const dial = await incoming; - expect(dial.peer).toBe(truck.driverId.peek() as string); - expect(dial.peer).toMatch(/^driver-/); + expect(dial.peer).toMatch(/^driver-[0-9a-f]{32}$/); const stream = await dial.accept(); + // Status names the lease by the session, never by the endpoint id. + while (truck.handle.peek() === undefined) await truck.handle.changed(); + expect(truck.handle.peek()).toBe(await handle(stream.session)); // Taking control heartbeats at once, so the truck hears the claim. const command = await read(stream); @@ -483,7 +485,7 @@ describe("Truck sessions", () => { stream.producer.finish(); expect(await released).toBeUndefined(); // The lease's endpoint goes with it. - while (truck.driverId.peek() !== undefined) await truck.driverId.changed(); + while (truck.handle.peek() !== undefined) await truck.handle.changed(); } finally { close(); } @@ -517,7 +519,7 @@ describe("Truck sessions", () => { const reader = stream.consumer.ordered(); expect((await read(stream, reader)).type).toBe("estop"); expect(driving.peek()).toBeUndefined(); - expect(truck.driverId.peek()).toBeUndefined(); + expect(truck.handle.peek()).toBeUndefined(); // The truck's status echoing the latch is what lets the session go. const broadcast = new Moq.Broadcast.Producer(); @@ -529,7 +531,12 @@ describe("Truck sessions", () => { broadcast.consume(), ); producer.update({ - value: { accepted: { ...REST }, autonomy: "manual", estopped: true, estop_by: dial.peer }, + value: { + accepted: { ...REST }, + autonomy: "manual", + estopped: true, + estop_by: await handle(dial.session), + }, }); expect(await read(stream, reader)).toBeUndefined(); } finally { @@ -595,7 +602,7 @@ describe("Truck respawn", () => { driving.set(truck.id); expect(await active).toBe(true); - truck.driverId.set("mine"); + truck.handle.set("mine"); const status = { accepted: { ...REST }, autonomy: "manual", diff --git a/demo/carla/web/src/truck.ts b/demo/carla/web/src/truck.ts index ad283e18466..0a7ebefcae3 100644 --- a/demo/carla/web/src/truck.ts +++ b/demo/carla/web/src/truck.ts @@ -14,7 +14,7 @@ import * as Json from "@moq/json"; import * as Moq from "@moq/net"; import * as Watch from "@moq/watch"; import { changed, REST, takeoverState } from "./input.ts"; -import { driverId, parseCamera, SOCKET } from "./paths.ts"; +import { driverId, handle, parseCamera, SOCKET } from "./paths.ts"; import type { DriveState, Gear, LatencyEntry, Reply, TruckStatus } from "./schemas.ts"; import { ReplySchema, TruckStatusSchema } from "./schemas.ts"; @@ -73,7 +73,8 @@ export class Truck { readonly active = new Moq.Signals.Signal(false); readonly status = new Moq.Signals.Signal(undefined); readonly statusState = new Moq.Signals.Signal<"pending" | "available" | "unavailable">("pending"); - readonly driverId = new Moq.Signals.Signal(undefined); + /** How `status` names this player's lease, once the truck accepted the driving session. */ + readonly handle = new Moq.Signals.Signal(undefined); readonly estopped = new Moq.Signals.Signal(false); readonly respawning = new Moq.Signals.Signal(false); readonly roundTripMs = new Moq.Signals.Signal(undefined); @@ -259,7 +260,8 @@ export class Truck { this.estopped.peek() || !status || status.estopped || - status.driver !== this.driverId.peek() + !this.handle.peek() || + status.driver !== this.handle.peek() ) { return false; } @@ -350,8 +352,9 @@ export class Truck { #runRespawn(effect: Moq.Signals.Effect) { if (!effect.get(this.respawning)) return; const status = effect.get(this.status); - const driverId = effect.get(this.driverId); - if (!status || status.driver === driverId) return; + // No handle means the driving session already ended, and the lease with it. + const handle = effect.get(this.handle); + if (!status || (handle !== undefined && status.driver === handle)) return; this.respawning.set(false); if (this.#driving.peek() === this.id) this.#driving.set(undefined); } @@ -410,21 +413,23 @@ export class Truck { // A fresh endpoint and session per lease: the truck binds its single // driver to the session, so a new lease is a new claimant. - const id = driverId(); - this.driverId.set(id); effect.cleanup(() => { - this.driverId.set(undefined); + this.handle.set(undefined); this.roundTripMs.set(undefined); this.pipeline.set(undefined); this.#sentAt.clear(); }); - const session = this.#dial(effect, id); + const session = this.#dial(effect, driverId()); effect.run((nested) => { const stream = nested.get(session); // The truck refused the dial or ended the session, which ended the lease. if (stream === null) return this.#release(); if (!stream) return; + nested.spawn(async () => { + const name = await handle(stream.session); + if (!nested.abort.aborted) this.handle.set(name); + }); nested.spawn(async () => { if (await this.#readReplies(nested, stream)) this.#release(); }); diff --git a/quest/m1/README.md b/quest/m1/README.md index c772a56bdbb..3176eed7479 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -47,7 +47,6 @@ moves what remains of them once their claims merge. - [Push-to-talk loses no speech](/quest/m1/push-to-talk/README.md) - an unmute is heard from its first frame with no catalog round trip: mute moves from holding a disabled rendition to suppressed silence with comfort noise, after a route spike; capture delivers frames on a steady cadence - [Push-to-talk's press-to-audible latency fits a budget on the defaults](/quest/m1/push-to-talk-latency.md) - press to audible on the browser defaults, excluding device output, stays under a kixtop-measured budget that `just test push-to-talk` enforces - [Socket endpoints split into servers and clients](/quest/m1/socket-roles.md) - servers announce `.server/` and clients `.client/`, so plain grants keep a client from posing as a server or reading another client -- [CARLA's driver ids stay private](/quest/m1/carla-driver-ids.md) - 128-bit driver ids, and `status` shows a handle that isn't the endpoint id - [TS fidelity and recovery](/quest/m1/ts-reliability/README.md) - both modes have explicit profiles, automated validation, loss tests, and a dedicated guide - [WebGPU on Safari](/quest/m1/webgpu-safari.md) - the WebGPU renderer is verified on Safari 26 for macOS and iOS - [Session policy lives in moq-net](/quest/m1/session-policy/README.md) - accept and connect deadlines, admission, window widening and reconnect live once in moq-net; moq-tokio and moq-relay's io_uring path call them diff --git a/quest/m1/carla-driver-ids.md b/quest/m1/carla-driver-ids.md deleted file mode 100644 index 37f8b2a2ef0..00000000000 --- a/quest/m1/carla-driver-ids.md +++ /dev/null @@ -1,31 +0,0 @@ -# [S] CARLA's driver ids stay private - -## Goal - -A CARLA driver's endpoint id is 128 random bits, like a boy viewer's, and the -truck's public `status` track shows a handle that isn't the endpoint id. So no -visitor learns a lease holder's id from `status`, and none can announce it to -disrupt that driver's next session. - -## Plan - -Decided 2026-11-06 (maintainer, `/quest-complete` on -[moq-dev/moq#8011](https://github.com/moq-dev/moq/pull/8011)): let #8011 -land and fix this after it. Not chosen: fix it in #8011 first; accept it as a -cooperative demo. - -After #8011, a driver id is `driver-<12 hex>` (48 bits), the lease binds to -`(driver_id, session)`, and `status` echoes `"driver": "driver-ab12cd"`. -Every viewer reads `status`, and two announcements of one path coexist with -the relay picking a route, so an impostor that announces a leaked id can win -the route to it. It can't drive (the session id stays secret), but it can break -the holder's next dial. - -- Generate 128-bit driver ids in the web driver, as `js/moq-boy` does. -- Publish a display handle in `status`, such as a short hash of the session or - a per-lease counter, that the truck maps to its lease. `driver` and - `estop_by` keep showing who holds the truck. -- If [socket roles](/quest/m1/socket-roles.md) lands first, the ids live - under `.client/` and need no `driver-` prefix. - -Public API: none. Wire: none. diff --git a/quest/m1/socket-roles.md b/quest/m1/socket-roles.md index 1a70610028b..3cac616ae68 100644 --- a/quest/m1/socket-roles.md +++ b/quest/m1/socket-roles.md @@ -18,8 +18,8 @@ clients, moq-boy, and CARLA. Non-goals: stopping a client from announcing another client's id once it learns it. Ids stay unguessable secrets until the m3 [mesh](/quest/m3/mesh/README.md) -makes them public keys; [CARLA's driver ids](/quest/m1/carla-driver-ids.md) -stops CARLA leaking them. +makes them public keys; CARLA's `status` names drivers by a session handle, +so it leaks none. ## Plan