|
| 1 | +================================================ |
| 2 | +USB Passthrough — Phase 2 Design (DRAFT) |
| 3 | +================================================ |
| 4 | + |
| 5 | +.. warning:: |
| 6 | + **DRAFT — Linux-libusb path complete; cross-platform backends are |
| 7 | + structural skeletons only.** |
| 8 | + |
| 9 | + **Shipped (rounds 27 / 34 / 37 / 39 / 40 / 41 / 42):** |
| 10 | + Phase 1 (read-only enumeration), Phase 1.5 (hotplug events), |
| 11 | + Phase 2a (protocol + ABCs + ``LibusbBackend`` lifecycle + |
| 12 | + ``FakeUsbBackend`` for tests + feature flag, default off), |
| 13 | + Phase 2a.1 (full ``LibusbBackend`` transfers + CREDIT-based |
| 14 | + inbound flow control + audit hooks), |
| 15 | + **viewer-side ``UsbPassthroughClient``** (blocking |
| 16 | + open / control_transfer / bulk_transfer / interrupt_transfer / close |
| 17 | + with outbound credit waits and shutdown propagation), |
| 18 | + Phase 2d (``UsbAcl`` persistent allow-list, ACL-gated OPEN with |
| 19 | + prompt-callback path, audit-log integration via the existing |
| 20 | + tamper-evident chain). |
| 21 | + |
| 22 | + **Structural-only:** ``WinusbBackend`` (Phase 2b) and |
| 23 | + ``IokitBackend`` (Phase 2c) — class scaffolding + platform / |
| 24 | + dependency validation in place; ``list`` and ``open`` raise |
| 25 | + ``NotImplementedError`` referencing the in-module TODO list. |
| 26 | + These need ctypes / pyobjc wiring **plus hardware testing** to |
| 27 | + become real. |
| 28 | + |
| 29 | + **Process step:** Phase 2e — see |
| 30 | + :doc:`usb_passthrough_security_review` for the reviewer |
| 31 | + checklist that must be signed before the feature flag flips |
| 32 | + to default-on. |
| 33 | + |
| 34 | + Open questions stay flagged inline as ``OPEN`` for reviewers. |
| 35 | + |
| 36 | +.. contents:: |
| 37 | + :local: |
| 38 | + :depth: 2 |
| 39 | + |
| 40 | + |
| 41 | +Goals |
| 42 | +===== |
| 43 | + |
| 44 | +Allow a remote AutoControl viewer to use a USB device that is |
| 45 | +physically attached to the host. Concrete user stories: |
| 46 | + |
| 47 | +- Plug a USB security key into the host machine; have it sign a |
| 48 | + WebAuthn challenge initiated by the viewer. |
| 49 | +- Plug a USB-serial debug board into a lab host; let a remote |
| 50 | + developer talk to it via their local terminal. |
| 51 | +- Plug a printer into the host; let the viewer's OS see the printer |
| 52 | + as if it were locally attached. |
| 53 | + |
| 54 | +Non-Goals |
| 55 | +========= |
| 56 | + |
| 57 | +- **High-throughput isochronous transfers** (USB webcams, audio |
| 58 | + interfaces). The latency budget across WebRTC + DataChannel + |
| 59 | + driver round-trips is not compatible with isochronous USB. Use the |
| 60 | + existing audio/video tracks for those. |
| 61 | +- **Automatic kernel-level device redirection** like USB/IP. We are |
| 62 | + building a userspace forwarder, not replacing a kernel driver. |
| 63 | +- **Phase 2 will not ship without an explicit security review.** |
| 64 | + |
| 65 | + |
| 66 | +Transport |
| 67 | +========= |
| 68 | + |
| 69 | +Channel |
| 70 | +------- |
| 71 | + |
| 72 | +A dedicated WebRTC ``DataChannel`` named ``usb`` per session, with |
| 73 | +``ordered=True`` and ``maxRetransmits=None`` (full reliability). |
| 74 | +Bulk and interrupt USB transfers tolerate the latency far better |
| 75 | +than they tolerate loss; the existing video/audio channels already |
| 76 | +demonstrate that the underlying SCTP transport handles ordered |
| 77 | +reliable streams adequately. |
| 78 | + |
| 79 | +OPEN: Should we use ``maxPacketLifeTime`` instead, with a generous |
| 80 | +budget (~5 s)? Worth measuring on real WAN links before shipping. |
| 81 | + |
| 82 | +Framing |
| 83 | +------- |
| 84 | + |
| 85 | +Each channel message is one length-prefixed protocol frame:: |
| 86 | + |
| 87 | + +----+--------+----------+--------------------+ |
| 88 | + | 1B | 1B | 2B | payload | |
| 89 | + | op | flags | claim_id | (op-specific body) | |
| 90 | + +----+--------+----------+--------------------+ |
| 91 | + |
| 92 | +- ``op``: 1-byte opcode (see *Operations* below) |
| 93 | +- ``flags``: 8 bits, currently only ``EOF`` (bit 0) for chunked reads |
| 94 | +- ``claim_id``: 16-bit identifier for one open device claim within |
| 95 | + the session. Allocated by the host at OPEN time, recycled at CLOSE. |
| 96 | +- payload: opcode-specific. Bounded to 16 KiB to keep DataChannel |
| 97 | + message sizes reasonable. |
| 98 | + |
| 99 | +OPEN: Do we need fragmentation above 16 KiB? Most USB transfers fit; |
| 100 | +control transfers are bounded by the device's wMaxPacketSize. A |
| 101 | +follow-up frame with the same ``claim_id`` and a continuation flag |
| 102 | +would be a low-cost addition. |
| 103 | + |
| 104 | +Operations |
| 105 | +---------- |
| 106 | + |
| 107 | +================ ========================================= ============== |
| 108 | +Op (hex) Direction Purpose |
| 109 | +================ ========================================= ============== |
| 110 | +``0x01 LIST`` viewer → host, host → viewer (response) Enumerate devices the viewer is permitted to claim |
| 111 | +``0x02 OPEN`` viewer → host Request claim of (vendor_id, product_id, serial) |
| 112 | +``0x03 OPENED`` host → viewer Reply: success + claim_id, or error |
| 113 | +``0x04 CTRL`` viewer ↔ host Control transfer (bmRequestType, bRequest, wValue, wIndex, data) |
| 114 | +``0x05 BULK`` viewer ↔ host Bulk IN/OUT transfer on a specific endpoint |
| 115 | +``0x06 INT`` viewer ↔ host Interrupt IN/OUT transfer |
| 116 | +``0x07 CREDIT`` viewer ↔ host Backpressure window update |
| 117 | +``0x08 CLOSE`` viewer → host Release the claim |
| 118 | +``0x09 CLOSED`` host → viewer Acknowledgement (or unsolicited on host-side disconnect) |
| 119 | +``0xFF ERROR`` either Protocol error / unsupported op |
| 120 | +================ ========================================= ============== |
| 121 | + |
| 122 | +OPEN: Should ``LIST`` go through the channel at all, or should the |
| 123 | +viewer use the existing REST ``/usb/devices`` endpoint and only use |
| 124 | +the channel for transfers? The latter is simpler but couples the |
| 125 | +two transports. |
| 126 | + |
| 127 | +Backpressure |
| 128 | +------------ |
| 129 | + |
| 130 | +Each side starts with a credit window of 16 outstanding frames per |
| 131 | +``claim_id``. Receiving a frame consumes one credit; a ``CREDIT`` |
| 132 | +message with a positive integer replenishes. Without flow control |
| 133 | +a slow remote USB device would balloon DataChannel send buffers. |
| 134 | + |
| 135 | +OPEN: Should credits be per-endpoint (IN/OUT separately) instead of |
| 136 | +per-claim? Bulk endpoints are independent, so per-endpoint is more |
| 137 | +faithful to the hardware. Costs more state. |
| 138 | + |
| 139 | + |
| 140 | +Per-OS driver wrappers |
| 141 | +====================== |
| 142 | + |
| 143 | +The driver layer is hidden behind a single ``UsbBackend`` ABC:: |
| 144 | + |
| 145 | + class UsbBackend(abc.ABC): |
| 146 | + def open(self, vendor_id, product_id, serial) -> "UsbHandle": ... |
| 147 | + def list(self) -> list[UsbDevice]: ... |
| 148 | + |
| 149 | + class UsbHandle(abc.ABC): |
| 150 | + def control_transfer(self, ...): ... |
| 151 | + def bulk_transfer(self, endpoint, data, timeout_ms): ... |
| 152 | + def interrupt_transfer(self, endpoint, data, timeout_ms): ... |
| 153 | + def close(self): ... |
| 154 | + |
| 155 | +This isolates the OS-specific bits and lets us write the protocol / |
| 156 | +session layer without committing to a backend choice up front. |
| 157 | + |
| 158 | +Windows — WinUSB |
| 159 | +---------------- |
| 160 | + |
| 161 | +- Best path for HID-class devices we don't already own a driver for: |
| 162 | + install ``WinUSB`` via libwdi or have the user manually associate |
| 163 | + the device with WinUSB through Zadig. |
| 164 | +- Use ``CreateFile`` + ``WinUsb_Initialize`` + ``WinUsb_ControlTransfer`` |
| 165 | + / ``WinUsb_ReadPipe`` / ``WinUsb_WritePipe``. |
| 166 | +- ``ctypes`` wrappers around ``winusb.dll`` are public API; no kernel |
| 167 | + driver authoring required. |
| 168 | + |
| 169 | +OPEN: WinUSB requires the device to be *not already claimed* by another |
| 170 | +driver. This rules out devices that the host OS thinks it owns |
| 171 | +(printers, hubs, keyboards). We will need an in-app prompt explaining |
| 172 | +why a particular device cannot be claimed. |
| 173 | + |
| 174 | +macOS — IOKit |
| 175 | +------------- |
| 176 | + |
| 177 | +- ``IOUSBHostInterface`` (modern, since 10.12) or ``IOUSBInterfaceInterface`` |
| 178 | + (older but ubiquitous) via ``pyobjc``. |
| 179 | +- Requires entitlement signing if shipped through the App Store; for |
| 180 | + dev / direct distribution this is fine but the binary must be |
| 181 | + notarised. |
| 182 | +- IOKit's ``CompletionMethod`` callbacks integrate with ``CFRunLoop``, |
| 183 | + not asyncio. We will need a thread that owns the runloop and |
| 184 | + marshals completions back to the WebRTC bridge thread. |
| 185 | + |
| 186 | +OPEN: System Integrity Protection blocks claiming Apple devices and |
| 187 | +some USB-C peripherals. Document the limit clearly. |
| 188 | + |
| 189 | +Linux — libusb |
| 190 | +-------------- |
| 191 | + |
| 192 | +- ``pyusb`` over ``libusb-1.0`` works without root if ``udev`` rules |
| 193 | + grant the user access; we will document a sample rule. |
| 194 | +- Hot-detach handling: libusb fires ``LIBUSB_TRANSFER_NO_DEVICE`` |
| 195 | + on in-flight transfers; we map that to ``CLOSED`` on the channel. |
| 196 | + |
| 197 | +OPEN: Some distros default to attaching ``usbhid`` to anything that |
| 198 | +looks like a HID. We must call ``libusb_detach_kernel_driver`` and, |
| 199 | +on close, ``libusb_attach_kernel_driver`` to restore — otherwise the |
| 200 | +host OS loses input devices. |
| 201 | + |
| 202 | + |
| 203 | +Security & ACL |
| 204 | +============== |
| 205 | + |
| 206 | +Per-device allow-list |
| 207 | +--------------------- |
| 208 | + |
| 209 | +Stored in ``~/.je_auto_control/usb_acl.json``:: |
| 210 | + |
| 211 | + { |
| 212 | + "version": 1, |
| 213 | + "rules": [ |
| 214 | + {"vendor_id": "1050", "product_id": "0407", "label": "YubiKey 5", |
| 215 | + "allow": true, "prompt_on_open": true}, |
| 216 | + ... |
| 217 | + ], |
| 218 | + "default": "deny" |
| 219 | + } |
| 220 | + |
| 221 | +- Default policy is **deny**. A device the user has not explicitly |
| 222 | + allowed cannot be claimed. |
| 223 | +- ``prompt_on_open`` triggers a host-side modal each time a viewer |
| 224 | + requests OPEN. The modal shows the vendor/product/serial and the |
| 225 | + viewer ID requesting access. |
| 226 | +- Allow rules can be persisted with a "remember" checkbox in the |
| 227 | + prompt. |
| 228 | + |
| 229 | +OPEN: Should we sign or HMAC the ACL file so a compromised host |
| 230 | +process cannot silently grant itself access? Probably yes, with a |
| 231 | +master key derived from a user passphrase or platform keychain. |
| 232 | + |
| 233 | +Audit |
| 234 | +----- |
| 235 | + |
| 236 | +Every OPEN, OPENED, CLOSE, and ERROR is appended to the existing |
| 237 | +audit log under event_type ``"usb_passthrough"``. Frame-level |
| 238 | +transfer logging is too noisy and is logged only on ERROR. |
| 239 | + |
| 240 | +Privilege |
| 241 | +--------- |
| 242 | + |
| 243 | +The host process must run with whatever privilege the chosen |
| 244 | +backend requires (Linux udev rules, macOS entitlements, Windows |
| 245 | +maybe nothing for WinUSB). The README will spell this out per-OS. |
| 246 | + |
| 247 | + |
| 248 | +Phasing |
| 249 | +======= |
| 250 | + |
| 251 | +1. **Done — Phase 1**: read-only enumeration (``list_usb_devices``). |
| 252 | +2. **Done — Phase 1.5**: hotplug events (``UsbHotplugWatcher``, |
| 253 | + ``/usb/events``). |
| 254 | +3. **Phase 2a (this design)**: protocol skeleton + ``UsbBackend`` ABC |
| 255 | + + Linux ``libusb`` backend behind a feature flag. |
| 256 | +4. **Phase 2b**: Windows ``WinUSB`` backend. |
| 257 | +5. **Phase 2c**: macOS ``IOKit`` backend. |
| 258 | +6. **Phase 2d**: ACL persistence + host-side prompt UI + audit |
| 259 | + integration. |
| 260 | +7. **Phase 2e**: external security review *before* default-on. |
| 261 | + |
| 262 | +Each subphase is its own multi-round project. Estimated effort |
| 263 | +(experienced contributor): ~1 week per backend, ~1 week for ACL/UI, |
| 264 | +plus the security review which depends on a reviewer's calendar. |
| 265 | + |
| 266 | + |
| 267 | +Open questions, summarised |
| 268 | +========================== |
| 269 | + |
| 270 | +1. ``maxRetransmits=None`` vs ``maxPacketLifeTime`` for the channel. |
| 271 | +2. Frame fragmentation above 16 KiB. |
| 272 | +3. ``LIST`` over the channel vs. exclusively over REST. |
| 273 | +4. Backpressure granularity (per-claim vs per-endpoint). |
| 274 | +5. What WinUSB cannot claim, and how to communicate that to the |
| 275 | + viewer. |
| 276 | +6. macOS entitlement story for non-App-Store distribution. |
| 277 | +7. Linux kernel-driver detach/reattach lifecycle. |
| 278 | +8. ACL file integrity (HMAC vs platform keychain). |
0 commit comments