check-xinput-usb.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. #!/usr/bin/env python3
  2. """Opt-in physical OpenPuck XSM3 transport check, not a console acceptance test."""
  3. import argparse
  4. import hashlib
  5. import importlib.util
  6. from pathlib import Path
  7. import re
  8. import shutil
  9. import subprocess
  10. import sys
  11. from threading import Event
  12. import time
  13. USB_TIMEOUT_MS = 1500
  14. READY_TIMEOUT_S = 5.0
  15. POLL_INTERVAL_S = 0.010
  16. SECURITY = (
  17. "Xbox Security Method 3, Version 1.00, © 2005 Microsoft "
  18. "Corporation. All rights reserved."
  19. )
  20. def require(condition, message):
  21. if not condition:
  22. raise RuntimeError(message)
  23. def positive_int(value):
  24. number = int(value, 10)
  25. if number <= 0:
  26. raise argparse.ArgumentTypeError("must be a positive decimal integer")
  27. return number
  28. def load_reference():
  29. path = Path(__file__).resolve().parents[1] / "tests/xinput/reference.py"
  30. spec = importlib.util.spec_from_file_location("xinput_usb_reference", path)
  31. require(spec is not None and spec.loader is not None,
  32. "cannot load independent reference")
  33. reference = importlib.util.module_from_spec(spec)
  34. # Execute source directly so the manual checker never creates a pycache.
  35. exec(compile(path.read_bytes(), str(path), "exec"), reference.__dict__)
  36. return reference
  37. class Probe:
  38. def __init__(self):
  39. self.stage = "setup"
  40. self.device = None
  41. self.waiter = Event()
  42. def progress(self, stage):
  43. self.stage = stage
  44. print(f"[{stage}]", flush=True)
  45. def descriptor(self, kind, index, length, language=0):
  46. return bytes(self.device.ctrl_transfer(
  47. 0x80, 6, (kind << 8) | index, language, length,
  48. timeout=USB_TIMEOUT_MS))
  49. def string(self, index):
  50. require(index != 0, "missing required string index")
  51. raw = self.descriptor(3, index, 255, 0x0409)
  52. require(len(raw) >= 2 and raw[0] == len(raw) and raw[1] == 3
  53. and len(raw) % 2 == 0, "malformed or truncated string")
  54. return raw[2:].decode("utf-16-le")
  55. def gate(self):
  56. dev = self.device
  57. require((dev.bcdUSB, dev.bcdDevice, dev.bDeviceClass,
  58. dev.bDeviceSubClass, dev.bDeviceProtocol,
  59. dev.bMaxPacketSize0, dev.bNumConfigurations)
  60. == (0x0200, 0x0114, 0xFF, 0xFF, 0xFF, 64, 1),
  61. "device descriptor is not the expected OpenPuck personality")
  62. config = self.descriptor(2, 0, 153)
  63. require(len(config) == 153 and config[:9] == bytes(
  64. [9, 2, 153, 0, 4, 1, 0, 0xA0, 250]),
  65. "expected a 153-byte, four-interface OpenPuck configuration")
  66. interfaces = []
  67. offset = 9
  68. while offset < len(config):
  69. size = config[offset]
  70. require(size >= 2 and offset + size <= len(config),
  71. "malformed configuration descriptor")
  72. if config[offset + 1] == 4:
  73. require(size == 9, "malformed interface descriptor")
  74. interfaces.append(tuple(config[offset + 2:offset + 9]))
  75. offset += size
  76. require(interfaces == [
  77. (0, 0, 2, 0xFF, 0x5D, 1, 0),
  78. (1, 0, 4, 0xFF, 0x5D, 3, 0),
  79. (2, 0, 1, 0xFF, 0x5D, 2, 0),
  80. (3, 0, 0, 0xFF, 0xFD, 0x13, 4),
  81. ], "interface layout is not the expected OpenPuck personality")
  82. require(self.string(dev.iManufacturer) == "©Microsoft Corporation"
  83. and self.string(dev.iProduct) == "Controller",
  84. "manufacturer/product strings do not match")
  85. require(self.string(4) == SECURITY, "full security string mismatch")
  86. serial = self.string(dev.iSerialNumber)
  87. # Firmware formats the hardware ID as %04lX%08lX; no fixed prefix.
  88. require(re.fullmatch(r"[0-9A-F]{12}", serial) is not None,
  89. "expected a 12-character uppercase hexadecimal serial")
  90. print("PASS descriptor/string gates (serial withheld)", flush=True)
  91. return serial.encode("ascii")
  92. def read(self, request, length, value=0, timeout=USB_TIMEOUT_MS):
  93. data = bytes(self.device.ctrl_transfer(
  94. 0xC1, request, value, 0x0103, length, timeout=timeout))
  95. require(len(data) == length,
  96. f"IN 0x{request:02x}: expected {length} bytes, got {len(data)}")
  97. return data
  98. def write(self, request, data):
  99. count = self.device.ctrl_transfer(
  100. 0x41, request, 3, 0x0103, data, timeout=USB_TIMEOUT_MS)
  101. require(count == len(data), f"OUT 0x{request:02x}: short transfer")
  102. def exchange(self, request, challenge, length):
  103. started = time.monotonic()
  104. self.write(request, challenge)
  105. deadline = started + READY_TIMEOUT_S
  106. polls = 0
  107. while True:
  108. remaining = deadline - time.monotonic()
  109. require(remaining > 0, "authentication readiness deadline expired")
  110. status = self.read(0x86, 2, timeout=min(
  111. USB_TIMEOUT_MS, max(1, int(remaining * 1000))))
  112. polls += 1
  113. require(time.monotonic() <= deadline,
  114. "authentication readiness deadline expired")
  115. if status == b"\x02\x00":
  116. ready_ms = (time.monotonic() - started) * 1000
  117. response = self.read(0x83, length, 0x5C00 | (length - 6))
  118. print(f"ready in {ready_ms:.1f} ms ({polls} polls); "
  119. f"response in {(time.monotonic() - started) * 1000:.1f} ms",
  120. flush=True)
  121. return response
  122. require(status == b"\x01\x00",
  123. f"unexpected authentication status {status.hex()}")
  124. self.waiter.wait(min(POLL_INTERVAL_S,
  125. max(0, deadline - time.monotonic())))
  126. def authenticate(self, reference, serial, skip_keepalive):
  127. r = reference
  128. self.progress("identification: IN 0x81 (invalidates active session)")
  129. identification = self.read(0x81, 29, 0x5B17)
  130. require(identification == r.packet(
  131. serial + bytes.fromhex("0080025e048e0203000101"), b"IK"),
  132. "identification framing, serial or identity mismatch")
  133. identity = bytearray(32)
  134. identity[:15] = identification[5:20]
  135. identity[16:21] = identification[20:25]
  136. identity[21] = identification[27]
  137. identity[22:24] = identification[25:27]
  138. # Synthetic public-key oracle inputs, never console secrets/captures.
  139. console = bytes(range(16, 24))
  140. nonce = bytes(range(16))
  141. digest = hashlib.sha1(console).digest()
  142. session1 = r.crypt(r.crypt(r.ROOT_23, digest[:16]), nonce)
  143. session2 = r.crypt(r.crypt(r.ROOT_24, digest[4:]), nonce[8:] + nonce[:8])
  144. encrypted = r.crypt(r.KEY_D, nonce + console)
  145. self.progress("init: OUT 0x82 → status → response")
  146. reply = self.exchange(
  147. 0x82, r.packet(encrypted + r.mac(r.KEY_E, encrypted)[4:]), 46)
  148. recovered = r.crypt(session1, reply[5:37], False)
  149. require(recovered[16:] == nonce, "init recovered console nonce mismatch")
  150. require(reply == r.packet(reply[5:37] + r.acr(
  151. console, identity, r.mac(session2, reply[5:37]))),
  152. "init response differs from independent OpenSSL/Python oracle")
  153. print("PASS init", flush=True)
  154. self.progress("keepalive: zero-data OUT 0x84")
  155. if skip_keepalive:
  156. print("SKIPPED: --skip-keepalive diagnostic old-build test; "
  157. "keepalive NOT validated", flush=True)
  158. else:
  159. started = time.monotonic()
  160. self.write(0x84, b"")
  161. print(f"PASS keepalive ({(time.monotonic() - started) * 1000:.1f} ms)",
  162. flush=True)
  163. controller = recovered[:16]
  164. init_hash = hashlib.sha1(recovered).digest()
  165. salt = bytearray(controller[12:] + nonce[12:] + nonce[8:])
  166. for iteration in range(3):
  167. self.progress(f"verify {iteration + 1}/3: OUT 0x87 → status → response")
  168. fresh = bytes(range(0x60 + iteration * 8, 0x68 + iteration * 8))
  169. salt[8:] = fresh
  170. encrypted = r.crypt(controller, fresh)
  171. challenge = r.packet(encrypted + r.mac(init_hash, encrypted, salt))
  172. reply = self.exchange(0x87, challenge, 22)
  173. encrypted = r.crypt(session1, r.acr(console, identity, fresh))
  174. require(reply == r.packet(encrypted + r.mac(session2, encrypted, salt)),
  175. "verify response differs from independent OpenSSL/Python oracle")
  176. print(f"PASS verify {iteration + 1}/3", flush=True)
  177. def main():
  178. parser = argparse.ArgumentParser(
  179. description="Manual PC USB authentication check for an attached OpenPuck "
  180. "045e:028e, not a genuine controller. --run invalidates any active "
  181. "authentication session; unplug/replug afterward, even on failure.",
  182. epilog="Requires PyUSB, a libusb backend, USB permissions, and OpenSSL. "
  183. "Never detaches drivers, sets configuration, resets or flashes hardware. "
  184. "Descriptor/string gates reduce accidental targeting but cannot prove "
  185. "device provenance: connect only the intended OpenPuck.")
  186. parser.add_argument("--run", action="store_true",
  187. help="explicitly permit device access and authentication")
  188. parser.add_argument("--bus", type=positive_int, help="USB bus (decimal)")
  189. parser.add_argument("--address", type=positive_int, help="USB address (decimal)")
  190. parser.add_argument("--skip-keepalive", action="store_true",
  191. help="DIAGNOSTIC old-build test only: skip zero-data OUT "
  192. "0x84; does not validate the keepalive fix")
  193. args = parser.parse_args()
  194. if not args.run:
  195. print("No USB access performed. Use --run explicitly; see --help.")
  196. return 1
  197. probe = Probe()
  198. try:
  199. try:
  200. import usb.core
  201. import usb.util
  202. except ImportError:
  203. raise RuntimeError("PyUSB unavailable; install the pyusb package "
  204. "in your Python environment") from None
  205. require(shutil.which("openssl") is not None, "OpenSSL executable missing")
  206. reference = load_reference()
  207. probe.progress("select exactly one 045e:028e (optional bus/address filter)")
  208. devices = list(usb.core.find(
  209. find_all=True, idVendor=0x045E, idProduct=0x028E))
  210. devices = [dev for dev in devices
  211. if (args.bus is None or dev.bus == args.bus)
  212. and (args.address is None or dev.address == args.address)]
  213. require(len(devices) == 1,
  214. f"found {len(devices)} matching devices; require exactly one "
  215. "(use --bus/--address to disambiguate)")
  216. probe.device = devices[0]
  217. probe.progress("read-only descriptor/string gates")
  218. try:
  219. serial = probe.gate()
  220. print("WARNING: authentication invalidates the active session. "
  221. "Unplug/replug afterward, including on failure.", flush=True)
  222. probe.authenticate(reference, serial, args.skip_keepalive)
  223. finally:
  224. usb.util.dispose_resources(probe.device)
  225. print("PASS PC USB synthetic authentication" +
  226. (" (DIAGNOSTIC: keepalive skipped)" if args.skip_keepalive else "") +
  227. "; console compatibility remains unverified. Unplug/replug now.")
  228. return 0
  229. except KeyboardInterrupt:
  230. print(f"ERROR [{probe.stage}]: interrupted; unplug/replug afterward.",
  231. file=sys.stderr)
  232. return 1
  233. except Exception as error:
  234. # CalledProcessError includes the OpenSSL command/key; don't log it.
  235. detail = ("OpenSSL reference operation failed"
  236. if isinstance(error, subprocess.CalledProcessError)
  237. else str(error))
  238. print(f"ERROR [{probe.stage}]: {detail}; unplug/replug if authentication "
  239. "started.", file=sys.stderr)
  240. return 1
  241. if __name__ == "__main__":
  242. sys.exit(main())