USB Passthrough — 第二階段設計

Note

軟體面已全部完成;八個未決問題全部拍板(見「設計決策」)。 剩餘兩項本質上無法靠寫程式完成:真實 USB 硬體驗證外部人員的安全 sign-off(Phase 2e)。在這兩項完成前, feature flag 維持預設 off。

已完成並發布(rounds 27 / 34 / 37 / 39 / 40 / 41 / 42 / 43): Phase 1(唯讀列舉)、Phase 1.5(hotplug events)、Phase 2a (協定 + ABC + LibusbBackend lifecycle + 給測試用的 FakeUsbBackend + feature flag,預設 off)、Phase 2a.1 (完整 LibusbBackend 傳輸 + CREDIT-based 入站流量控制 + 稽核 hook)、viewer 端 ``UsbPassthroughClient``(阻塞式 open / control_transfer / bulk_transfer / interrupt_transfer / close / list_devices,含 outbound credit 等待與 shutdown 傳播)、Phase 2d (UsbAcl 持久化白名單、ACL-gated OPEN 含 prompt-callback、 稽核紀錄整合到既有的 tamper-evident 鏈)、Phase 2d.1 (ACL 檔案 HMAC-SHA256 完整性,竄改則 fail-closed)。

Phase 2b — Windows ``WinUSB``: SetupAPI 列舉 + WinUsb_* 傳輸的 ctypes 接線已完成(硬體未驗證)。

Phase 2c — macOS ``IOKit``: 透過 ctypes 的原生 IOKit 列舉已 完成;裝置 claim/傳輸委派給 libusb(macOS 上經硬體驗證的路徑)。 詳見 iokit_backend 模組說明(硬體未驗證)。

backend 選擇: default_passthrough_backend() 依 OS 自動挑 WinUSB / IOKit / libusb。

剩餘流程: Phase 2e — 見 USB Passthrough — Phase 2e 安全審查清單 的審查者清單;feature flag 翻成預設 on 之前必須由外部人員簽核, 且三個 backend 都需在真實硬體上跑過測試矩陣。

目標

讓遠端 AutoControl viewer 使用實體插在 host 機器上的 USB 裝置。 具體使用情境:

  • 在 host 插一支 USB security key;讓 viewer 發起的 WebAuthn challenge 在那支 key 上簽章。

  • 在實驗室 host 插 USB-serial debug board;讓遠端開發者透過自己 本機的終端機跟它對話。

  • 在 host 插一台印表機;讓 viewer 的 OS 把它看成本機印表機。

非目標

  • 高吞吐 isochronous 傳輸(USB webcam、音訊介面)。WebRTC + DataChannel + driver 來回的延遲預算跟 isochronous USB 不相容。 那些情境用既有的 audio/video track。

  • 核心層裝置重導向(如 USB/IP)。我們做的是 userspace forwarder,不是替代 kernel driver。

  • 第二階段在通過明確的安全審查之前不會發布。

傳輸

Channel

每個 session 一條專用的 WebRTC DataChannel,名稱 usbordered=TruemaxRetransmits=None(完全可靠傳輸)。

已串接: host(webrtc_host)會建立 usb channel 並以 UsbChannelHost 餵給 session(綁定 viewer 認證 + feature flag); viewer(webrtc_viewer)收到該 channel 後以 UsbChannelClient 包裝,透過 viewer.usb_client() 供上層呼叫 list_devices / open / resume。adapter 與 transport 解耦,可用 fake channel 單測(見 test_usb_webrtc_channel)。斷線續租: OPENED 會帶 resume_token;若 host session 撐過 viewer 的 transport 中斷, viewer 重連後送 RESUME{resume_token} 即可重綁原 claim、保留裝置 狀態,不需重新 OPEN。 USB 的 bulk 與 interrupt 傳輸對延遲的容忍度遠高於對遺失的容忍度; 既有的 video/audio channel 也已示範底層 SCTP 傳輸足以承擔有序可靠 串流。

已決(OQ1): 維持 ordered=True + maxRetransmits=None (完全可靠有序)。USB control 傳輸(如 WebAuthn 簽章、descriptor 讀取)對遺失零容忍,部分遺失的串流會讓裝置狀態機壞掉;可靠有序的 語意比節省幾毫秒重傳延遲重要。maxPacketLifeTime 的有界遺失模型 留給未來若出現純高吞吐、可容忍遺失的使用情境再評估(目前 YAGNI)。

Framing

每個 channel message 是一個前綴長度的協定 frame:

+-----+--------+----------+--------------------+
| 1B  |   1B   |    2B    |       payload      |
| op  | flags  | claim_id | (op-specific body) |
+-----+--------+----------+--------------------+
  • op:1 byte opcode(見下方 操作

  • flags:8 bits,目前只用到 EOF(bit 0,分塊讀取用)

  • claim_id:16-bit 識別碼,代表單一 session 中的一次 device claim。host 在 OPEN 時配發、在 CLOSE 時回收。

  • payload:依 opcode 不同。上限 16 KiB 以維持 DataChannel 訊息 尺寸合理。

已決(OQ2): 已實作分片。protocol.fragment_payload() 把超過 16 KiB 的 payload 切成多個同 claim_id 的 frame,除最後一個外都清掉 FLAG_EOF;接收端串接連續 frame 的 payload 直到看到 EOF。傳輸回覆 與 LIST 回覆都走這條路;single-frame 的常見情形仍只送一個帶 EOF 的 frame,行為與先前一致。

操作

Op (hex)

方向

用途

0x01 LIST

viewer → host、host → viewer(回應)

列舉 viewer 有權 claim 的裝置

0x02 OPEN

viewer → host

請求 claim (vendor_id, product_id, serial)

0x03 OPENED

host → viewer

回覆:成功 + claim_id,或錯誤

0x04 CTRL

viewer ↔ host

Control 傳輸(bmRequestType, bRequest, wValue, wIndex, data)

0x05 BULK

viewer ↔ host

指定 endpoint 的 Bulk IN/OUT 傳輸

0x06 INT

viewer ↔ host

Interrupt IN/OUT 傳輸

0x07 CREDIT

viewer ↔ host

Backpressure 視窗更新

0x08 CLOSE

viewer → host

釋放 claim

0x09 CLOSED

host → viewer

確認(host 端斷線時也可主動發出)

0x0A RESUME

viewer → host

重連後以 resume_token 重新綁定既有 claim

0xFF ERROR

雙向

協定錯誤/不支援 op

已決(OQ3): LIST 走 channel。session 處理 LIST frame 並回傳 ACL 不會 deny 的裝置(被 deny 的裝置連存在都不讓 viewer 知道); viewer 端用 UsbPassthroughClient.list_devices() 取得。讓列舉與傳輸 共用同一條已通過 auth gate 的 channel,避免再耦合一層 REST transport, 也讓 ACL 過濾與 claim 決策走同一份邏輯。

Backpressure

雙方各以 16 個未確認 frame 為 claim_id 的初始 credit window。 收一個 frame 消一個 credit;用 CREDIT 訊息傳正整數來補回。 沒有流量控制的話,慢的遠端 USB 裝置會把 DataChannel 送出 buffer 撐爆。

已決(OQ4): 維持 per-claim credit。它已足以達成核心目的—— 防止慢速遠端裝置撐爆 host 送出 buffer——而且狀態最少、推理最簡單。 per-endpoint credit 會把 IN/OUT、多 bulk endpoint 各自記帳,複雜度 明顯上升卻只在單一 claim 內多個 endpoint 同時飽和時才有差別;屬於 YAGNI,待真實量測出現 head-of-line 問題再說。

各 OS driver 包裝

driver 層藏在單一 UsbBackend ABC 後面:

class UsbBackend(abc.ABC):
    def open(self, vendor_id, product_id, serial) -> "UsbHandle": ...
    def list(self) -> list[UsbDevice]: ...

class UsbHandle(abc.ABC):
    def control_transfer(self, ...): ...
    def bulk_transfer(self, endpoint, data, timeout_ms): ...
    def interrupt_transfer(self, endpoint, data, timeout_ms): ...
    def close(self): ...

這把 OS 特定的東西隔離開,讓我們可以在不選定 backend 的前提下 寫協定/session 層。

Windows — WinUSB

  • 對於我們沒有現成 driver 的 HID-class 裝置,最佳路徑:用 libwdi 安裝 WinUSB,或讓使用者透過 Zadig 手動把裝置綁到 WinUSB。

  • CreateFile + WinUsb_Initialize + WinUsb_ControlTransferWinUsb_ReadPipeWinUsb_WritePipe

  • ctypeswinusb.dll 的 wrapper 是 public API;不需要 寫 kernel driver。

已決(OQ5): WinUSB 要求裝置 尚未被別的 driver claim,且只有 已綁定 winusb.sys 的裝置會出現在 WinusbBackend.list() 中。因此 host OS 自己擁有的裝置(印表機、hub、鍵盤)根本不會列出來——viewer 看到的就是「可 claim」的子集,不會誤以為能 claim 系統裝置。若 OPEN 的 vid/pid 不在清單中,host 回明確的 no device matches 錯誤; operator guide 說明如何用 Zadig / libwdi 綁定裝置到 WinUSB。

macOS — IOKit

  • IOUSBHostInterface(現代版,10.12 起)或 IOUSBInterfaceInterface (比較舊但無所不在),透過 pyobjc

  • 透過 App Store 發行需要 entitlement 簽章;直接散布的話 OK,但 binary 必須做 notarisation。

  • IOKit 的 CompletionMethod callback 整合 CFRunLoop,不是 asyncio。需要一個專屬 thread 持有 runloop,把 completion marshal 回 WebRTC bridge thread。

已決(OQ6): 列舉走原生 IOKit(ctypes,不需 pyobjc);claim/ 傳輸委派給 libusb——它是 macOS 上經硬體驗證的 USB 路徑,避免手刻 無法在無硬體下驗證的 IOUSBHostInterface plugin vtable。直接散布 (非 App Store)的 build 必須 notarisation;libusb 存取裝置不需特殊 entitlement,但 System Integrity Protection 仍會藏起 Apple 內部裝置與 某些 USB-C 週邊。operator guide 記載 SIP 排除界線。

Linux — libusb

  • 透過 libusb-1.0pyusb 不需要 root,只要 udev rule 給使用者存取權。我們會提供範例 rule。

  • 拔線處理:libusb 對進行中的傳輸發出 LIBUSB_TRANSFER_NO_DEVICE; 我們把它 map 成 channel 上的 CLOSED

已決(OQ7): 已實作。_LibusbHandle 在 open 時對 active configuration 的每個介面呼叫 detach_kernel_driver``(只 detach 真的 kernel 佔住的),記住動過哪些介面,並在 close ``attach_kernel_driver 復原——否則 session 結束後 host OS 會永久丟掉鍵盤/滑鼠。Windows/ macOS 的 libusb 對 detach 拋 NotImplementedError,會被容忍跳過 (那些平台由 OS 仲裁 driver)。

安全與 ACL

每裝置白名單

存於 ~/.je_auto_control/usb_acl.json:

{
  "version": 1,
  "rules": [
    {"vendor_id": "1050", "product_id": "0407", "label": "YubiKey 5",
     "allow": true, "prompt_on_open": true},
    ...
  ],
  "default": "deny"
}
  • 預設政策是 deny。使用者沒有明確允許過的裝置不能被 claim。

  • prompt_on_open 在每次 viewer 請求 OPEN 時觸發 host 端 modal。 modal 顯示 vendor/product/serial 與請求存取的 viewer ID。

  • Allow rule 可以靠提示中的「記住」勾選持久化。

已決(OQ8): 已實作 HMAC-SHA256。ACL 旁附一個 <acl>.sig sidecar 簽章;載入時驗證,不符就 fail-closed(default-deny、 integrity_ok 為 False),讓偷偷改寫 JSON 的 process 無法在不同時 偽造簽章的情況下給自己授權。簽章金鑰可插拔——部署可透過建構子的 hmac_key= 傳入由平台 keychain 衍生的金鑰;未指定時會在 ACL 旁 產生一把隨機金鑰檔(POSIX 上 0o600)。注意:同使用者身分的 process 仍可讀金鑰檔而偽造簽章,故高保證部署建議改用 keychain 金鑰 (見 operator guide)。升級前既有的未簽章檔案視為 legacy,仍可載入 (下次儲存即補簽),可用 require_signature=True 拒絕未簽章檔。

稽核

每筆 OPEN、OPENED、CLOSE、ERROR 都附加到既有稽核紀錄,event_type "usb_passthrough"。Frame 層傳輸紀錄太雜,只在 ERROR 時記錄。

權限

host process 必須以選定 backend 所需的權限執行(Linux udev rule、 macOS entitlement、Windows WinUSB 通常不需要)。README 會逐 OS 寫清楚。

階段

  1. 完成 — Phase 1:唯讀列舉(list_usb_devices)。

  2. 完成 — Phase 1.5:hotplug events(UsbHotplugWatcher/usb/events)。

  3. 完成 — Phase 2a:協定 + UsbBackend ABC + Linux libusb backend,置於 feature flag 之後。

  4. 完成 — Phase 2b:Windows WinUSB backend(ctypes,硬體未驗證)。

  5. 完成 — Phase 2c:macOS IOKit backend(原生列舉 + libusb 傳輸,硬體未驗證)。

  6. 完成 — Phase 2d / 2d.1:ACL 持久化 + host 端提示 callback + 稽核整合 + ACL 檔案 HMAC 完整性。

  7. 進行中 — Phase 2e:默認開啟之前的外部安全審查 加上 三個 backend 的真實硬體測試矩陣。這兩項本質上需要硬體與外部人員, 無法只靠程式碼完成。

在 Phase 2e 簽核之前,feature flag 維持預設 off。

設計決策(原未決問題)

八個原始未決問題均已拍板,對應實作見上方各節:

  1. OQ1 — Channel 可靠度``maxRetransmits=None``(完全可靠有序)。

  2. OQ2 — frame 分片:已實作 fragment_payload + EOF 重組。

  3. OQ3 — ``LIST`` 走 channel:是,ACL 過濾後經 channel 回傳。

  4. OQ4 — Backpressure 顆粒度:per-claim(per-endpoint 屬 YAGNI)。

  5. OQ5 — WinUSB 不可 claim 裝置:只列出已綁 WinUSB 的裝置, claim 不到回明確錯誤。

  6. OQ6 — macOS 發行:原生 IOKit 列舉 + libusb 傳輸;notarisation, 無需特殊 entitlement,文件記載 SIP 界線。

  7. OQ7 — Linux kernel driver:open 時 detach、close 時 reattach。

  8. OQ8 — ACL 完整性:HMAC-SHA256 sidecar,金鑰可插拔(keychain)。