From efb5be5a16e44d79e1733814409c18cfeedd6fd3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 18:03:29 -0800 Subject: [PATCH 1/3] chore(quest): claim quest/m1/carla-driver-ids.md Quest-Claim: 08c0d10b764e8622 From 4d5b28a8bb64e440f494df97c930c3fa2e821591 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 18:11:07 -0800 Subject: [PATCH 2/3] fix(carla): keep driver endpoint ids out of the public status A driver's endpoint id is now 128 random bits, like a boy viewer's, and the truck's `status` names the lease holder and the e-stop's sender by a handle: `driver-` and 8 hex digits of the SHA-256 of the session's secret id. The player derives the same handle from its own session to recognize its lease, so no viewer learns an id it could announce to win the route to that driver's next dial. Co-Authored-By: Claude Opus 5.5 --- demo/carla/README.md | 22 +++++----- demo/carla/sim/moq_carla_sim/drive.py | 38 ++++++++++------ demo/carla/sim/moq_carla_sim/publish.py | 2 +- demo/carla/sim/tests/test_drive.py | 58 +++++++++++++++++++------ demo/carla/sim/tests/test_publish.py | 10 +++-- demo/carla/sim/tests/test_runtime.py | 57 ++++++++++++++---------- demo/carla/web/src/hint.test.ts | 2 +- demo/carla/web/src/hint.ts | 7 +-- demo/carla/web/src/paths.test.ts | 14 +++++- demo/carla/web/src/paths.ts | 21 +++++++-- demo/carla/web/src/player.ts | 10 ++--- demo/carla/web/src/truck.test.ts | 21 ++++++--- demo/carla/web/src/truck.ts | 22 ++++++---- quest/m1/README.md | 1 - quest/m1/carla-driver-ids.md | 31 ------------- quest/m1/socket-roles.md | 4 +- 16 files changed, 189 insertions(+), 131 deletions(-) delete mode 100644 quest/m1/carla-driver-ids.md 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..906d59f8571 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,7 @@ export class Truck { this.estopped.peek() || !status || status.estopped || - status.driver !== this.driverId.peek() + status.driver !== this.handle.peek() ) { return false; } @@ -350,8 +351,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 +412,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 From d5210d8fd433d9623bfab0846fcae7e6011c6ae1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 20:48:23 -0800 Subject: [PATCH 3/3] fix(carla): respawn requires a known session handle Co-Authored-By: Claude Opus 5.5 --- demo/carla/web/src/truck.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/demo/carla/web/src/truck.ts b/demo/carla/web/src/truck.ts index 906d59f8571..0a7ebefcae3 100644 --- a/demo/carla/web/src/truck.ts +++ b/demo/carla/web/src/truck.ts @@ -260,6 +260,7 @@ export class Truck { this.estopped.peek() || !status || status.estopped || + !this.handle.peek() || status.driver !== this.handle.peek() ) { return false;