Skip to content

Commit aa5b1bd

Browse files
committed
Complete USB passthrough Phase 2b/2c, resolve open questions, add sharing GUI
Finish the cross-platform backends and settle every open design question so the protocol stack is feature-complete behind the default-off flag; only real-hardware verification and the external security sign-off remain. - WinUSB + IOKit backends implemented; IOKit enumerates natively and delegates transfers to libusb. Add default_passthrough_backend() factory. - Resolve OQ1-OQ8: reliable-ordered channel, frame fragmentation, LIST-over-channel, per-claim credits, WinUSB binding clarity, macOS notarisation path, Linux kernel-driver detach/reattach, and ACL HMAC-SHA256 integrity (fail-closed on tamper, pluggable key). - Add in-process UsbLoopback so one machine can share and use a device through the full protocol stack with no WebRTC channel. - Add AnyDesk-style USB Sharing panel; wire USB Browser Open for localhost via loopback. i18n across all four languages. - Lift the design doc out of DRAFT; refresh operator and security-review docs. Tests cover every new path; import je_auto_control stays Qt-free.
1 parent 0a0a636 commit aa5b1bd

28 files changed

Lines changed: 2354 additions & 309 deletions

docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst

Lines changed: 133 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,43 @@
11
================================================
2-
USB Passthrough — Phase 2 Design (DRAFT)
2+
USB Passthrough — Phase 2 Design
33
================================================
44

5-
.. warning::
6-
**DRAFT — Linux-libusb path complete; cross-platform backends are
7-
structural skeletons only.**
5+
.. note::
6+
**All software-side work is complete; the eight open questions are
7+
resolved (see "Design decisions").** Two items remain that cannot
8+
be completed in code: **verification against real USB hardware** and
9+
the **external human security sign-off (Phase 2e)**. The feature
10+
flag stays default-off until both are done.
811

9-
**Shipped (rounds 27 / 34 / 37 / 39 / 40 / 41 / 42):**
12+
**Done and shipped (rounds 27 / 34 / 37 / 39 / 40 / 41 / 42 / 43):**
1013
Phase 1 (read-only enumeration), Phase 1.5 (hotplug events),
1114
Phase 2a (protocol + ABCs + ``LibusbBackend`` lifecycle +
1215
``FakeUsbBackend`` for tests + feature flag, default off),
1316
Phase 2a.1 (full ``LibusbBackend`` transfers + CREDIT-based
1417
inbound flow control + audit hooks),
1518
**viewer-side ``UsbPassthroughClient``** (blocking
16-
open / control_transfer / bulk_transfer / interrupt_transfer / close
17-
with outbound credit waits and shutdown propagation),
19+
open / control_transfer / bulk_transfer / interrupt_transfer / close /
20+
list_devices with outbound credit waits and shutdown propagation),
1821
Phase 2d (``UsbAcl`` persistent allow-list, ACL-gated OPEN with
1922
prompt-callback path, audit-log integration via the existing
20-
tamper-evident chain).
23+
tamper-evident chain), Phase 2d.1 (ACL file HMAC-SHA256 integrity,
24+
fail-closed on tamper).
2125

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.
26+
**Phase 2b — Windows ``WinUSB``:** SetupAPI enumeration + ``WinUsb_*``
27+
transfer ctypes wiring complete (**hardware-unverified**).
2828

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.
29+
**Phase 2c — macOS ``IOKit``:** native IOKit enumeration via ctypes
30+
complete; device claim / transfers delegate to libusb (the
31+
hardware-proven path on macOS). See the ``iokit_backend`` module
32+
docstring (**hardware-unverified**).
3333

34-
Open questions stay flagged inline as ``OPEN`` for reviewers.
34+
**Backend selection:** ``default_passthrough_backend()`` picks
35+
WinUSB / IOKit / libusb by OS automatically.
36+
37+
**Remaining process step:** Phase 2e — see
38+
:doc:`usb_passthrough_security_review` for the reviewer checklist
39+
that must be signed by an external reviewer before the feature flag
40+
flips to default-on, plus the per-backend hardware test matrix.
3541

3642
.. contents::
3743
:local:
@@ -76,8 +82,13 @@ than they tolerate loss; the existing video/audio channels already
7682
demonstrate that the underlying SCTP transport handles ordered
7783
reliable streams adequately.
7884

79-
OPEN: Should we use ``maxPacketLifeTime`` instead, with a generous
80-
budget (~5 s)? Worth measuring on real WAN links before shipping.
85+
**Resolved (OQ1):** keep ``ordered=True`` + ``maxRetransmits=None``
86+
(fully reliable, ordered). USB control transfers (WebAuthn signing,
87+
descriptor reads) have zero tolerance for loss — a partially-lost
88+
stream corrupts the device state machine — so reliable-ordered
89+
semantics matter more than shaving a few ms of retransmit latency.
90+
The bounded-loss ``maxPacketLifeTime`` model is left for a future
91+
loss-tolerant high-throughput use case (YAGNI today).
8192

8293
Framing
8394
-------
@@ -96,10 +107,13 @@ Each channel message is one length-prefixed protocol frame::
96107
- payload: opcode-specific. Bounded to 16 KiB to keep DataChannel
97108
message sizes reasonable.
98109

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.
110+
**Resolved (OQ2):** fragmentation implemented.
111+
``protocol.fragment_payload()`` splits a payload larger than 16 KiB
112+
into multiple same-``claim_id`` frames, clearing ``FLAG_EOF`` on all
113+
but the last; the receiver concatenates consecutive frame payloads
114+
until it sees EOF. Both transfer replies and ``LIST`` replies use this
115+
path; the common single-frame case still sends exactly one EOF-flagged
116+
frame, so existing behaviour is unchanged.
103117

104118
Operations
105119
----------
@@ -119,10 +133,13 @@ Op (hex) Direction Purpose
119133
``0xFF ERROR`` either Protocol error / unsupported op
120134
================ ========================================= ==============
121135

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.
136+
**Resolved (OQ3):** ``LIST`` goes over the channel. The session
137+
handles a ``LIST`` frame and returns the devices the ACL would not
138+
deny (a denied device is never even revealed to the viewer); the
139+
viewer calls ``UsbPassthroughClient.list_devices()``. Enumeration and
140+
transfers share one already-authenticated channel instead of coupling
141+
in a second REST transport, and ACL filtering reuses the same logic as
142+
the claim decision.
126143

127144
Backpressure
128145
------------
@@ -132,9 +149,13 @@ Each side starts with a credit window of 16 outstanding frames per
132149
message with a positive integer replenishes. Without flow control
133150
a slow remote USB device would balloon DataChannel send buffers.
134151

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.
152+
**Resolved (OQ4):** keep per-claim credits. They already meet the core
153+
goal — stopping a slow remote device from ballooning the host send
154+
buffer — with the least state and the simplest reasoning.
155+
Per-endpoint credits would track IN/OUT and each bulk endpoint
156+
separately for a meaningful complexity jump that only matters when
157+
multiple endpoints on one claim saturate at once; that is YAGNI until
158+
real measurement shows head-of-line blocking.
138159

139160

140161
Per-OS driver wrappers
@@ -166,25 +187,33 @@ Windows — WinUSB
166187
- ``ctypes`` wrappers around ``winusb.dll`` are public API; no kernel
167188
driver authoring required.
168189

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.
190+
**Resolved (OQ5):** WinUSB requires the device to be *not already
191+
claimed* by another driver, and only devices already bound to
192+
``winusb.sys`` appear in ``WinusbBackend.list()``. Devices the host OS
193+
owns (printers, hubs, keyboards) therefore never list — the viewer
194+
only ever sees the claimable subset and cannot mistake a system device
195+
for one it could claim. An OPEN for a vid/pid not in the list returns a
196+
clear ``no device matches`` error; the operator guide explains binding
197+
a device to WinUSB via Zadig / libwdi.
173198

174199
macOS — IOKit
175200
-------------
176201

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.
202+
- Enumeration uses native IOKit through ``ctypes`` (no ``pyobjc``
203+
dependency): ``IOServiceGetMatchingServices`` over ``IOUSBDevice``
204+
plus ``IORegistryEntryCreateCFProperty`` reads of idVendor / idProduct
205+
/ serial / locationID.
206+
- Claim and transfers delegate to libusb (see OQ6) rather than
207+
hand-rolling the COM-style ``IOUSBHostInterface`` plugin vtable.
208+
209+
**Resolved (OQ6):** enumeration is native IOKit; claim / transfers
210+
delegate to libusb — the hardware-proven USB path on macOS — which
211+
avoids hand-coding an ``IOUSBHostInterface`` plugin vtable that could
212+
not be verified without hardware. A directly distributed (non
213+
App Store) build must be notarised; libusb device access needs no
214+
special entitlement, but System Integrity Protection still hides Apple
215+
internal devices and some USB-C peripherals. The operator guide
216+
documents the SIP exclusion boundary.
188217

189218
Linux — libusb
190219
--------------
@@ -194,10 +223,13 @@ Linux — libusb
194223
- Hot-detach handling: libusb fires ``LIBUSB_TRANSFER_NO_DEVICE``
195224
on in-flight transfers; we map that to ``CLOSED`` on the channel.
196225

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.
226+
**Resolved (OQ7):** implemented. ``_LibusbHandle`` calls
227+
``detach_kernel_driver`` for each interface of the active configuration
228+
that the kernel actually holds on open, remembers which it touched, and
229+
``attach_kernel_driver`` restores them on close — otherwise the host OS
230+
permanently loses its keyboard / mouse after the session. libusb on
231+
Windows / macOS raises ``NotImplementedError`` for detach, which is
232+
tolerated and skipped (those platforms arbitrate drivers in the OS).
201233

202234

203235
Security & ACL
@@ -226,9 +258,18 @@ Stored in ``~/.je_auto_control/usb_acl.json``::
226258
- Allow rules can be persisted with a "remember" checkbox in the
227259
prompt.
228260

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.
261+
**Resolved (OQ8):** HMAC-SHA256 implemented. The ACL carries a sidecar
262+
``<acl>.sig`` signature, verified on load; a mismatch fails closed
263+
(default-deny, ``integrity_ok`` False) so a process that silently
264+
rewrites the JSON cannot grant itself access without also forging the
265+
signature. The signing key is pluggable — a deployment can pass a
266+
keychain-derived key via the constructor's ``hmac_key=``; absent that,
267+
a random key file is generated next to the ACL (``0o600`` on POSIX).
268+
Note: a same-user process can still read the key file and forge a
269+
signature, so keychain-derived keys are recommended for high-assurance
270+
deployments (see operator guide). Files written before signing existed
271+
are treated as legacy (still load, signed on next save); pass
272+
``require_signature=True`` to reject unsigned files.
232273

233274
Audit
234275
-----
@@ -251,28 +292,41 @@ Phasing
251292
1. **Done — Phase 1**: read-only enumeration (``list_usb_devices``).
252293
2. **Done — Phase 1.5**: hotplug events (``UsbHotplugWatcher``,
253294
``/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).
295+
3. **Done — Phase 2a**: protocol + ``UsbBackend`` ABC + Linux
296+
``libusb`` backend behind a feature flag.
297+
4. **Done — Phase 2b**: Windows ``WinUSB`` backend (ctypes,
298+
hardware-unverified).
299+
5. **Done — Phase 2c**: macOS ``IOKit`` backend (native enumeration +
300+
libusb transfers, hardware-unverified).
301+
6. **Done — Phase 2d / 2d.1**: ACL persistence + host-side prompt
302+
callback + audit integration + ACL file HMAC integrity.
303+
7. **In progress — Phase 2e**: external security review *before*
304+
default-on, **plus** the per-backend real-hardware test matrix.
305+
Both inherently require hardware and an external reviewer and cannot
306+
be completed in code alone.
307+
308+
The feature flag stays default-off until Phase 2e is signed off.
309+
310+
311+
Design decisions (formerly open questions)
312+
==========================================
313+
314+
All eight original open questions are resolved; see the matching
315+
sections above for the implementation.
316+
317+
1. **OQ1 — channel reliability**: ``maxRetransmits=None`` (fully
318+
reliable, ordered).
319+
2. **OQ2 — frame fragmentation**: implemented via ``fragment_payload``
320+
+ EOF reassembly.
321+
3. **OQ3 — ``LIST`` over the channel**: yes, ACL-filtered, over the
322+
channel.
323+
4. **OQ4 — backpressure granularity**: per-claim (per-endpoint is
324+
YAGNI).
325+
5. **OQ5 — what WinUSB cannot claim**: only WinUSB-bound devices list;
326+
a failed claim returns a clear error.
327+
6. **OQ6 — macOS distribution**: native IOKit enumeration + libusb
328+
transfers; notarisation, no special entitlement, SIP boundary
329+
documented.
330+
7. **OQ7 — Linux kernel driver**: detach on open, reattach on close.
331+
8. **OQ8 — ACL integrity**: HMAC-SHA256 sidecar, pluggable
332+
(keychain-capable) key.

docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ USB Passthrough — Operator Guide
33
============================================================
44

55
Step-by-step recipe for getting a USB device on a host machine to
6-
respond to traffic from a remote viewer. Assumes Phase 2a.1 (current
7-
shipping state) — host-side end-to-end works on Linux libusb; Windows
8-
WinUSB is hardware-unverified; macOS IOKit is not yet implemented.
6+
respond to traffic from a remote viewer. Host-side end-to-end works on
7+
Linux libusb; Windows WinUSB and macOS IOKit are implemented but
8+
**hardware-unverified** — both must pass the Phase 2e hardware test
9+
matrix before production use. ``default_passthrough_backend()`` picks
10+
the right backend for the current OS.
911

1012
If you're a security reviewer instead of an operator, you want
1113
:doc:`usb_passthrough_security_review`. If you're a developer wanting
@@ -76,11 +78,21 @@ hardware. Treat as alpha. Steps:
7678
3. Hardware testing is required before relying on transfers. See
7779
the security review checklist for the expected test matrix.
7880

79-
macOS (IOKit) — *not yet implemented*
81+
macOS (IOKit) — *hardware-unverified*
8082
-------------------------------------
8183

82-
The skeleton exists; ``IokitBackend()`` constructs but ``list`` /
83-
``open`` raise ``NotImplementedError``. Track Phase 2c.
84+
``IokitBackend`` enumerates USB devices natively through IOKit
85+
(``ctypes``; no pyobjc needed), so ``IokitBackend().list()`` works.
86+
Claiming a device for transfers delegates to libusb, so install it:
87+
``pip install pyusb`` and ``brew install libusb``. Notes:
88+
89+
1. A directly distributed (non App Store) build must be notarised.
90+
libusb device access needs no special entitlement.
91+
2. System Integrity Protection hides Apple internal devices and some
92+
USB-C peripherals — they will not appear in ``list()`` and cannot be
93+
claimed. This is expected.
94+
3. Transfers are hardware-unverified; see the security review checklist
95+
for the expected test matrix before relying on them.
8496

8597

8698
Enabling the feature
@@ -129,7 +141,10 @@ operator hasn't approved. Add per-device rules:
129141

130142
3. By editing ``~/.je_auto_control/usb_acl.json`` directly. The file
131143
is permission-checked (mode ``0600`` on POSIX). Bad JSON or an
132-
unknown ``version`` falls back to default-deny.
144+
unknown ``version`` falls back to default-deny. **If you hand-edit
145+
the file, the HMAC signature will no longer match and the ACL fails
146+
closed** (see below) — re-save through ``UsbAcl`` instead, which
147+
refreshes the signature.
133148

134149
Decision precedence:
135150

@@ -138,6 +153,23 @@ Decision precedence:
138153
- If no rule matches, the file's ``default`` (``"deny"`` out of the
139154
box) applies.
140155

156+
ACL file integrity (HMAC)
157+
-------------------------
158+
159+
The ACL is protected by an HMAC-SHA256 signature stored in a sidecar
160+
``usb_acl.json.sig``. On load the signature is verified against the
161+
file bytes; a mismatch makes the ACL **fail closed** (default-deny,
162+
``UsbAcl.integrity_ok`` reports ``False``). This stops a process that
163+
silently rewrites the JSON from granting itself access.
164+
165+
- By default the signing key is a random 32-byte file
166+
``usb_acl.json.key`` (mode ``0600`` on POSIX), created on first save.
167+
- For higher assurance, derive the key from a platform keychain and
168+
pass it explicitly: ``UsbAcl(hmac_key=<bytes>)``. A same-user process
169+
that can read the key file could otherwise forge a signature.
170+
- Pass ``UsbAcl(require_signature=True)`` to reject even legacy
171+
unsigned files outright.
172+
141173

142174
Starting the host
143175
=================
@@ -241,11 +273,19 @@ Audit chain shows ``broken_at_id`` Someone edited ``audit.db`` directly
241273
What is *not* shipped yet
242274
=========================
243275

244-
- WebRTC viewer GUI does not auto-wire the ``usb`` DataChannel — the
245-
*USB Browser* tab's *Open* button shows a "not yet wired" message.
246-
You can drive the protocol from Python today.
247-
- Windows WinUSB transfer methods are written but not validated
248-
against real hardware. Do not use in production.
249-
- macOS IOKit backend is unimplemented (Phase 2c).
276+
- The *USB Sharing* tab is the simple, AnyDesk-style surface: enable
277+
sharing on the left and Allow / Block local devices in the ACL; on the
278+
right, list the shared devices over the in-process channel and *Open*
279+
one (a descriptor read proves the full stack). The *USB Browser* tab's
280+
*Open* button now also works against a **localhost** target via the
281+
same loopback path.
282+
- Cross-machine open still needs the WebRTC ``usb`` DataChannel, which
283+
the viewer GUI does not yet auto-wire — against a remote host the
284+
*Open* button shows a "not yet wired" message. You can drive the
285+
protocol from Python today (including
286+
``UsbPassthroughClient.list_devices()`` over the channel).
287+
- Windows WinUSB and macOS IOKit transfer paths are written but not yet
288+
validated against real hardware. Do not use in production until the
289+
Phase 2e hardware test matrix passes.
250290
- Phase 2e external security review has not been signed; the feature
251291
flag must remain explicit opt-in.

0 commit comments

Comments
 (0)