test_vp_camera_bridge.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. """The slicer's camera through a virtual printer signs in with the printer's code (#1430).
  2. The printer checks the camera's access code itself, so a slicer bound with a
  3. personal access code was refused there and kept asking for the code.
  4. """
  5. import asyncio
  6. import re
  7. import ssl
  8. import pytest
  9. from backend.app.services.virtual_printer.camera_bridge import (
  10. CHAMBER,
  11. RTSP,
  12. AcceptedCode,
  13. CameraBridge,
  14. digest_response,
  15. parse_digest,
  16. rewrite_chamber_auth,
  17. rewrite_rtsp_auth,
  18. )
  19. from backend.app.services.virtual_printer.ftp_server import AuthRateLimiter
  20. PRINTER_CODE = "12345678"
  21. VP_CODE = "87654321"
  22. PERSONAL = "11112222"
  23. ACCEPTED = [AcceptedCode(VP_CODE), AcceptedCode(PERSONAL, user_id=7)]
  24. REALM = "LIVE555 Streaming Media"
  25. NONCE = "b0047e376b1831bbc8afa1461ba6c1ca"
  26. URI = "rtsps://192.0.2.10:322/streaming/live/1"
  27. def _signed(code: str, method: str = "DESCRIBE") -> dict[str, str]:
  28. params = {"username": "bblp", "realm": REALM, "nonce": NONCE, "uri": URI}
  29. params["response"] = digest_response(params, method, code)
  30. return params
  31. def _request(code: str | None, method: str = "DESCRIBE", cseq: int = 2) -> bytes:
  32. lines = [f"{method} {URI} RTSP/1.0", f"CSeq: {cseq}"]
  33. if code is not None:
  34. p = _signed(code, method)
  35. lines.append(
  36. f'Authorization: Digest username="bblp", realm="{REALM}", nonce="{NONCE}", uri="{URI}", '
  37. f'response="{p["response"]}"'
  38. )
  39. return ("\r\n".join(lines) + "\r\n\r\n").encode()
  40. def _response_of(head: bytes) -> str:
  41. return re.search(rb'response="([0-9a-f]+)"', head).group(1).decode()
  42. class TestDigest:
  43. def test_rfc2617_example(self):
  44. params = parse_digest(
  45. 'Digest username="Mufasa", realm="testrealm@host.com", '
  46. 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", uri="/dir/index.html", qop=auth, '
  47. 'nc=00000001, cnonce="0a4f113b", response="6629fae49393a05397450978507c4ef1", '
  48. 'opaque="5ccc069c403ebaf9f0171e9517f40e41"'
  49. )
  50. assert digest_response(params, "GET", "Circle Of Life") == "6629fae49393a05397450978507c4ef1"
  51. def test_other_schemes_are_not_digest(self):
  52. assert parse_digest("Basic YmJscDoxMjM0") is None
  53. class TestRtspRewrite:
  54. @pytest.mark.parametrize("code", [PERSONAL, VP_CODE])
  55. def test_an_accepted_code_is_signed_with_the_printers(self, code):
  56. head, matched, had_auth = rewrite_rtsp_auth(_request(code), ACCEPTED, PRINTER_CODE)
  57. assert had_auth and matched is not None and matched.code == code
  58. assert _response_of(head) == _signed(PRINTER_CODE)["response"]
  59. # Everything else stays as the slicer sent it.
  60. assert head.replace(_response_of(head).encode(), b"") == _request(code).replace(
  61. _signed(code)["response"].encode(), b""
  62. )
  63. def test_the_personal_code_names_its_user(self):
  64. _, matched, _ = rewrite_rtsp_auth(_request(PERSONAL), ACCEPTED, PRINTER_CODE)
  65. assert matched.user_id == 7
  66. def test_an_unknown_code_passes_unchanged(self):
  67. request = _request("99990000")
  68. assert rewrite_rtsp_auth(request, ACCEPTED, PRINTER_CODE) == (request, None, True)
  69. def test_a_request_without_auth_passes_unchanged(self):
  70. request = _request(None, method="OPTIONS", cseq=1)
  71. assert rewrite_rtsp_auth(request, ACCEPTED, PRINTER_CODE) == (request, None, False)
  72. def _chamber_payload(code: str) -> bytes:
  73. from backend.app.services.camera import _create_chamber_auth_payload
  74. return _create_chamber_auth_payload(code)
  75. class TestChamberRewrite:
  76. def test_an_accepted_code_is_replaced_by_the_printers(self):
  77. payload, matched = rewrite_chamber_auth(_chamber_payload(PERSONAL), ACCEPTED, PRINTER_CODE)
  78. assert payload == _chamber_payload(PRINTER_CODE)
  79. assert matched.user_id == 7
  80. def test_an_unknown_code_passes_unchanged(self):
  81. payload = _chamber_payload("99990000")
  82. assert rewrite_chamber_auth(payload, ACCEPTED, PRINTER_CODE) == (payload, None)
  83. # -- end to end, against a printer that checks Digest itself -------------------
  84. @pytest.fixture
  85. def certs(tmp_path):
  86. from backend.app.services.virtual_printer.certificate import CertificateService
  87. return CertificateService(cert_dir=tmp_path, serial="09400A391800001").generate_certificates()
  88. class FakePrinter:
  89. """RTSPS that answers 401 unless the Digest was made with the printer's code, and records what it got."""
  90. def __init__(self):
  91. self.received = bytearray()
  92. self.server = None
  93. async def start(self, cert_path, key_path) -> int:
  94. context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  95. context.load_cert_chain(str(cert_path), str(key_path))
  96. self.server = await asyncio.start_server(self._handle, "127.0.0.1", 0, ssl=context)
  97. return self.server.sockets[0].getsockname()[1]
  98. async def _handle(self, reader, writer):
  99. try:
  100. while True:
  101. first = await reader.readexactly(1)
  102. if first == b"$":
  103. header = await reader.readexactly(3)
  104. frame = await reader.readexactly(int.from_bytes(header[1:3], "big"))
  105. self.received += first + header + frame
  106. continue
  107. head = first + await reader.readuntil(b"\r\n\r\n")
  108. self.received += head
  109. cseq = re.search(rb"CSeq: (\d+)", head).group(1)
  110. auth = re.search(rb"Authorization: (.*)\r\n", head)
  111. ok = False
  112. if auth:
  113. params = parse_digest(auth.group(1).decode())
  114. ok = params["response"] == digest_response(params, head.split(b" ")[0].decode(), PRINTER_CODE)
  115. status = b"200 OK" if ok else b"401 Unauthorized"
  116. writer.write(b"RTSP/1.0 " + status + b"\r\nCSeq: " + cseq + b"\r\n\r\n")
  117. await writer.drain()
  118. except (asyncio.IncompleteReadError, ConnectionError):
  119. pass
  120. finally:
  121. writer.close()
  122. async def stop(self):
  123. self.server.close()
  124. await self.server.wait_closed()
  125. @pytest.fixture
  126. async def bridge(certs):
  127. cert_path, key_path = certs
  128. printer = FakePrinter()
  129. port = await printer.start(cert_path, key_path)
  130. async def codes():
  131. return ACCEPTED
  132. camera = CameraBridge(
  133. name="test",
  134. protocol=RTSP,
  135. listen_port=0,
  136. bind_address="127.0.0.1",
  137. target_host="127.0.0.1",
  138. target_port=port,
  139. cert_path=cert_path,
  140. key_path=key_path,
  141. codes=codes,
  142. printer_code=lambda: PRINTER_CODE,
  143. rate_limiter=AuthRateLimiter(max_attempts=3),
  144. )
  145. task = asyncio.create_task(camera.start())
  146. for _ in range(100):
  147. if camera._server is not None:
  148. break
  149. await asyncio.sleep(0.01)
  150. camera.listen_port = camera._server.sockets[0].getsockname()[1]
  151. yield camera, printer
  152. task.cancel()
  153. await asyncio.gather(task, return_exceptions=True)
  154. await printer.stop()
  155. async def _connect(camera):
  156. context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  157. context.check_hostname = False
  158. context.verify_mode = ssl.CERT_NONE
  159. return await asyncio.open_connection("127.0.0.1", camera.listen_port, ssl=context)
  160. async def _reply(reader) -> bytes:
  161. return await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=5)
  162. @pytest.mark.asyncio
  163. async def test_a_personal_code_gets_the_stream_and_frames_pass_through(bridge):
  164. camera, printer = bridge
  165. reader, writer = await _connect(camera)
  166. writer.write(_request(None, method="OPTIONS", cseq=1))
  167. assert b"401" in await _reply(reader) # the fake asks for auth on everything
  168. writer.write(_request(PERSONAL, cseq=2))
  169. assert b"200 OK" in await _reply(reader)
  170. frame = b"$\x01\x00\x04RTCP"
  171. writer.write(frame + _request(PERSONAL, method="GET_PARAMETER", cseq=3))
  172. assert b"200 OK" in await _reply(reader)
  173. writer.close()
  174. assert frame in printer.received
  175. assert PERSONAL.encode() not in printer.received
  176. @pytest.mark.asyncio
  177. async def test_unknown_codes_never_reach_the_printer_and_are_rate_limited(bridge):
  178. camera, printer = bridge
  179. for attempt in range(3):
  180. reader, writer = await _connect(camera)
  181. writer.write(_request("99990000", cseq=attempt + 2))
  182. # Cut off without an answer from the printer, which would say whether the guess was right.
  183. assert await asyncio.wait_for(reader.read(), timeout=5) == b""
  184. assert b"Authorization" not in printer.received
  185. # Three failures: the source stays out, even with a good code.
  186. reader, writer = await _connect(camera)
  187. writer.write(_request(PERSONAL))
  188. assert await asyncio.wait_for(reader.read(), timeout=5) == b""
  189. def _hidden_digest(code: str) -> bytes:
  190. p = _signed(code)
  191. return (f'username="bblp", realm="{REALM}", nonce="{NONCE}", uri="{URI}", response="{p["response"]}"').encode()
  192. @pytest.mark.parametrize(
  193. "request_bytes",
  194. [
  195. # A Digest header inside another header's value, which the printer's server finds too.
  196. lambda: (
  197. f"DESCRIBE {URI} RTSP/1.0\r\nCSeq: 2\r\nX-A: Authorization: Digest ".encode()
  198. + _hidden_digest("99990000")
  199. + b"\r\n\r\n"
  200. ),
  201. # A Digest header after a Basic one.
  202. lambda: (
  203. f"DESCRIBE {URI} RTSP/1.0\r\nCSeq: 2\r\nAuthorization: Basic Zm9v\r\nAuthorization: Digest ".encode()
  204. + _hidden_digest("99990000")
  205. + b"\r\n\r\n"
  206. ),
  207. # A Basic header alone: not a sign-in the bridge can re-sign.
  208. lambda: f"DESCRIBE {URI} RTSP/1.0\r\nCSeq: 2\r\nAuthorization: Basic Zm9v\r\n\r\n".encode(),
  209. # Two Digest headers, the first one accepted.
  210. lambda: _request(PERSONAL)[:-2] + b"Authorization: Digest " + _hidden_digest("99990000") + b"\r\n\r\n",
  211. ],
  212. )
  213. def test_a_sign_in_it_does_not_re_sign_counts_as_one(request_bytes):
  214. head = request_bytes()
  215. assert rewrite_rtsp_auth(head, ACCEPTED, PRINTER_CODE) == (head, None, True)
  216. @pytest.mark.asyncio
  217. async def test_codes_are_looked_up_on_a_sign_in_and_again_when_old(monkeypatch):
  218. from backend.app.services.virtual_printer import camera_bridge
  219. calls = []
  220. async def lookup():
  221. calls.append(1)
  222. return ACCEPTED
  223. clock = [1000.0]
  224. monkeypatch.setattr(camera_bridge.time, "monotonic", lambda: clock[0])
  225. codes = camera_bridge._AcceptedCodes(lookup)
  226. await codes.get()
  227. await codes.get()
  228. assert len(calls) == 1
  229. clock[0] += camera_bridge.CODES_MAX_AGE + 1
  230. await codes.get()
  231. assert len(calls) == 2
  232. @pytest.mark.asyncio
  233. async def test_a_connection_that_sends_nothing_costs_no_lookup(certs, monkeypatch):
  234. from backend.app.services.virtual_printer import camera_bridge
  235. monkeypatch.setattr(camera_bridge, "FIRST_BYTES_TIMEOUT", 0.2)
  236. cert_path, key_path = certs
  237. looked_up = []
  238. async def codes():
  239. looked_up.append(1)
  240. return ACCEPTED
  241. camera = CameraBridge(
  242. name="test",
  243. protocol=RTSP,
  244. listen_port=0,
  245. bind_address="127.0.0.1",
  246. target_host="127.0.0.1",
  247. target_port=1, # nothing listens: a connection attempt would fail the test below
  248. cert_path=cert_path,
  249. key_path=key_path,
  250. codes=codes,
  251. printer_code=lambda: PRINTER_CODE,
  252. )
  253. task = asyncio.create_task(camera.start())
  254. for _ in range(100):
  255. if camera._server is not None:
  256. break
  257. await asyncio.sleep(0.01)
  258. camera.listen_port = camera._server.sockets[0].getsockname()[1]
  259. try:
  260. reader, _ = await _connect(camera)
  261. assert await asyncio.wait_for(reader.read(), timeout=5) == b""
  262. assert looked_up == []
  263. finally:
  264. task.cancel()
  265. await asyncio.gather(task, return_exceptions=True)
  266. @pytest.mark.asyncio
  267. async def test_chamber_sign_in_uses_the_printers_code(certs):
  268. cert_path, key_path = certs
  269. got = asyncio.get_running_loop().create_future()
  270. async def printer_side(reader, writer):
  271. got.set_result(await reader.readexactly(80))
  272. writer.write(b"jpeg")
  273. await writer.drain()
  274. writer.close()
  275. context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  276. context.load_cert_chain(str(cert_path), str(key_path))
  277. server = await asyncio.start_server(printer_side, "127.0.0.1", 0, ssl=context)
  278. async def codes():
  279. return ACCEPTED
  280. camera = CameraBridge(
  281. name="test",
  282. protocol=CHAMBER,
  283. listen_port=0,
  284. bind_address="127.0.0.1",
  285. target_host="127.0.0.1",
  286. target_port=server.sockets[0].getsockname()[1],
  287. cert_path=cert_path,
  288. key_path=key_path,
  289. codes=codes,
  290. printer_code=lambda: PRINTER_CODE,
  291. )
  292. task = asyncio.create_task(camera.start())
  293. for _ in range(100):
  294. if camera._server is not None:
  295. break
  296. await asyncio.sleep(0.01)
  297. camera.listen_port = camera._server.sockets[0].getsockname()[1]
  298. try:
  299. reader, writer = await _connect(camera)
  300. writer.write(_chamber_payload(PERSONAL))
  301. await writer.drain()
  302. assert await asyncio.wait_for(got, timeout=5) == _chamber_payload(PRINTER_CODE)
  303. assert await asyncio.wait_for(reader.read(4), timeout=5) == b"jpeg"
  304. writer.close()
  305. finally:
  306. task.cancel()
  307. await asyncio.gather(task, return_exceptions=True)
  308. server.close()
  309. await server.wait_closed()
  310. @pytest.mark.asyncio
  311. async def test_stopping_ends_a_connection_still_open(bridge):
  312. """The virtual printer stops even while a slicer is watching the camera."""
  313. camera, _ = bridge
  314. reader, writer = await _connect(camera)
  315. writer.write(_request(PERSONAL))
  316. assert b"200 OK" in await _reply(reader)
  317. await asyncio.wait_for(camera.stop(), timeout=5)
  318. assert await asyncio.wait_for(reader.read(), timeout=5) == b""
  319. @pytest.mark.asyncio
  320. async def test_cancelling_the_bridge_ends_a_connection_still_open(certs):
  321. """Cancelling the listening task must not wait for the slicer to hang up."""
  322. cert_path, key_path = certs
  323. printer = FakePrinter()
  324. port = await printer.start(cert_path, key_path)
  325. async def codes():
  326. return ACCEPTED
  327. camera = CameraBridge(
  328. name="test",
  329. protocol=RTSP,
  330. listen_port=0,
  331. bind_address="127.0.0.1",
  332. target_host="127.0.0.1",
  333. target_port=port,
  334. cert_path=cert_path,
  335. key_path=key_path,
  336. codes=codes,
  337. printer_code=lambda: PRINTER_CODE,
  338. )
  339. task = asyncio.create_task(camera.start())
  340. for _ in range(100):
  341. if camera._server is not None:
  342. break
  343. await asyncio.sleep(0.01)
  344. camera.listen_port = camera._server.sockets[0].getsockname()[1]
  345. try:
  346. reader, writer = await _connect(camera)
  347. writer.write(_request(PERSONAL))
  348. assert b"200 OK" in await _reply(reader)
  349. task.cancel()
  350. await asyncio.wait_for(asyncio.gather(task, return_exceptions=True), timeout=5)
  351. assert await asyncio.wait_for(reader.read(), timeout=5) == b""
  352. finally:
  353. await printer.stop()