USB Passthrough — Phase 2e Security Review Checklist

This page is for an external reviewer to walk before USB passthrough is enabled by default. It is not itself a sign-off — that lives in whatever ticket / record system the project uses.

Until every item below is checked off and signed by a reviewer who is not the author of the code, the passthrough feature must remain behind enable_usb_passthrough(True) (off by default).

Threat model

Trust boundary: the viewer is a peer outside the host’s local trust domain. They can send arbitrary frames over the usb DataChannel. The host must never:

  • Claim a device the operator has not approved (ACL).

  • Claim more devices than the policy allows (max_claims).

  • Spend unbounded buffer space on viewer-driven payloads (payload cap + credit window).

  • Continue to honor a viewer that is provably misbehaving (rate / lockout, inherited from the REST auth gate when channels are gated by the same session).

The viewer is also a potential victim of a malicious host — but this checklist is host-side only. A separate review for the viewer client comes in Phase 2f.

ACL

  • [ ] UsbAcl defaults to "deny" when no file exists. Verify

    with a fresh user account.

  • [ ] When the file is corrupt / wrong version, the ACL also defaults

    to deny (test test_unknown_version_is_ignored).

  • [ ] prompt_on_open rules without a wired callback fall back to

    deny (test test_session_prompt_no_callback_means_deny).

  • [ ] If the prompt callback raises, the open is denied (test

    test_session_prompt_callback_raising_means_deny).

  • [ ] ACL file is written with mode 0o600 on POSIX (test

    test_save_persists_to_disk_with_safe_mode).

  • [ ] Recommend storing the ACL on a filesystem that supports POSIX

    permissions; document the Windows ACL story in the deploy guide.

  • [ ] OQ8 — ACL integrity (HMAC) implemented. The ACL carries an

    HMAC-SHA256 sidecar signature and fails closed on tamper (tests test_tampered_acl_file_fails_closed, test_explicit_key_roundtrip_and_wrong_key_fails). Residual risk: the default key lives in a same-user-readable usb_acl.json.key; a same-identity process can still forge a signature. High-assurance deployments should pass a keychain-derived key via UsbAcl(hmac_key=...) — confirm whether the deployment does.

Audit

  • [ ] Every ACL decision is logged via audit_log with one of:

    usb_open_allowed, usb_open_denied, usb_open_rejected_max_claims, usb_open_backend_error, usb_close. Confirm by inspecting recent audit rows after a manual exercise.

  • [ ] Audit rows include viewer_id so a row can be attributed to

    a peer (test test_session_audit_captures_open_decisions).

  • [ ] Audit log itself is hash-chained (round 25). Confirm

    verify_chain() returns ok=True after a passthrough session.

  • [ ] Frame-level transfer logging is intentionally OFF to avoid

    capturing key material on YubiKey-class devices. ERRORs only are surfaced via the project logger.

Protocol hardening

  • [ ] Frame header is 4 bytes; decode_frame rejects buffers

    smaller than that (test test_decode_rejects_short_buffer).

  • [ ] Unknown opcodes raise ProtocolError (test

    test_decode_rejects_unknown_opcode) — the session never sees the bad frame.

  • [ ] Payloads are capped at MAX_PAYLOAD_BYTES (16 KiB) on both

    decode (test test_decode_rejects_oversize_payload) and construct (test test_frame_constructor_validates).

  • [ ] CTRL/BULK/INT request bodies that fail to parse return ERROR,

    not crash (test test_bad_transfer_payload_returns_error).

  • [ ] Backend exceptions are caught and returned as

    {"ok": false, "error": "..."} — the session never propagates a host-side RuntimeError to the wire (test test_backend_error_translates_to_ok_false).

Resource bounds

  • [ ] max_claims cap enforced (test

    test_max_concurrent_claims_enforced).

  • [ ] CREDIT-based inbound flow control prevents a peer from filling

    the host’s process queue (test test_credit_exhaustion_returns_error).

  • [ ] CREDIT replenishment is 1 frame per reply — well-behaved peer

    doesn’t stall (test test_each_transfer_consumes_then_replenishes_one_credit).

  • [ ] CREDIT messages with bad payloads are silently dropped (test

    test_credit_message_with_bad_payload_is_ignored).

  • [ ] CREDIT for unknown claim_id is silent (test

    test_credit_message_for_unknown_claim_is_silent).

Lifecycle

  • [ ] close_all() releases every outstanding handle and tolerates

    per-handle close errors (test test_close_all_releases_every_outstanding_claim).

  • [ ] FakeHandle close is idempotent (test

    test_backend_handle_close_is_idempotent); same property verified for the libusb backend during hardware testing.

  • [ ] Closing a handle and then issuing a transfer raises (test

    test_fake_handle_transfer_after_close_raises).

  • [ ] Viewer client shutdown() releases pending request waiters

    (test test_shutdown_unblocks_pending_transfers).

Per-OS requirements

  • [ ] Linux libusb: udev rule documented for the target devices;

    tested without root.

  • [ ] Linux libusb: libusb_detach_kernel_driver invoked before

    a HID device is claimed; reattached on close. Confirm host OS keyboard / mouse remains functional after a session.

  • [ ] Windows WinUSB (Phase 2b — implemented, hardware-unverified):

    the device must already be associated with WinUSB (Zadig / libwdi); only bound devices appear in list(). Sign off only after running the bulk / HID / composite test matrix on real hardware.

  • [ ] macOS IOKit (Phase 2c — implemented, hardware-unverified):

    native IOKit enumeration + libusb transfers. Notarisation for non-App-Store distribution; document SIP exclusions. Sign off only after running the test matrix on real hardware.

  • [ ] All three backends: opening a device that another driver owns

    surfaces as a clear “busy” RuntimeError, not a hang or crash.

Pen-test scenarios

These are recommended scenarios for an external pen-tester to attempt before sign-off. None should succeed:

  1. ACL bypass via case folding. Try VID/PID with mixed case and leading zeros; confirm only the canonical form matches.

  2. ACL bypass via Unicode normalization. Try a serial string that is visually identical but Unicode-different from the rule.

  3. Credit DoS. Send 1 million transfer frames as fast as possible against a small max_claims; confirm host RSS stays bounded.

  4. Frame fragmentation attack. Send a frame with a header that claims a payload size larger than what arrives; confirm decode_frame rejects the truncated stream.

  5. Concurrent OPEN race. Two peers (or one peer with multiple threads) issuing OPEN simultaneously — confirm exactly one claim_id is granted per OPEN request and the bookkeeping doesn’t drift.

  6. Audit tampering. Edit an usb_* row in audit.db via raw SQLite; confirm verify_chain() flags the row.

  7. Prompt callback timing. A slow prompt callback (sleeping 30s) should not allow another peer to slip a CTRL through in the meantime — confirm the prompt callback is awaited before any subsequent decision for the same vid/pid.

  8. Permission downgrade. Run the host as a non-privileged user on Linux without the udev rule; confirm OPEN fails cleanly with a clear “permission denied” message rather than crashing.

Sign-off

Reviewer name: ____________________________________________________

Reviewer affiliation: _____________________________________________

Date: _____________________________________________________________

Items above all checked: [ ] yes [ ] no — list failing items below.

Recommendation:

[ ] Ready to ship Phase 2 default-on. [ ] Ready to ship behind opt-in flag (current state). [ ] Block release; remediation required.

Notes / remediation list: