Prechádzať zdrojové kódy

fix(virtual-printer): #1610 add Bambu cipher pin to every slicer-facing TLS context

  The #620 patch fixed the OpenSSL-3.x-strips-plain-RSA-AES-GCM cipher
  mismatch on the printer-facing TLSProxy client context. The same fix
  was never applied to the four other slicer-facing TLS contexts. On
  hardened distros (Fedora / RHEL with update-crypto-policies, hardened
  Alpine builds) where the system narrows DEFAULT to forward-secrecy
  only, the slicer's ClientHello finds no overlap with what Bambuddy
  offers and the handshake aborts with the slicer reporting code=-1
  before any application data flows. The reporter pinpointed the missing
  set_ciphers call in bind_server.py against the #620 lineage; the
  audit-wide sweep here extends the same fix to mqtt_server.py,
  tcp_proxy._create_server_ssl_context (the missing other half of #620),
  and ftp_server.py.

  For the three new contexts (bind / mqtt / proxy-server) the cipher
  string is DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256 — verbatim match
  with the #620 client-side fix. For FTPS the original HIGH baseline is
  kept (HIGH:AES256-GCM-SHA384:AES128-GCM-SHA256:!aNULL:!MD5:!RC4) so the
  cipher set stays a strict superset of what shipped before — HIGH
  offers ~58 suites DEFAULT doesn't (CCM / ARIA / CAMELLIA / DSS) that
  no Bambu slicer is known to pick, but narrowing a compat surface
  without proof would violate the existing don't-remove-compat-pinning
  rule. TLS version pins (TLSv1_2 minimum across all four, TLSv1_2 max
  on FTPS for the BambuStudio PSK-reuse compat) and verify-mode settings
  are unchanged — only the cipher list is widened.
maziggy 1 deň pred
rodič
commit
9cc4b6aa60

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
CHANGELOG.md


+ 9 - 0
backend/app/services/virtual_printer/bind_server.py

@@ -77,6 +77,15 @@ class BindServer:
         ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
         ctx.load_cert_chain(str(self.cert_path), str(self.key_path))
         ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+        # Match real Bambu printer cipher behaviour: include the plain-RSA
+        # AES-GCM suites the slicer's bind/connect path expects. On hardened
+        # distros (Fedora / RHEL with `update-crypto-policies`, hardened Alpine
+        # builds) the OpenSSL `DEFAULT` list strips these suites, leaving no
+        # overlap with the slicer's ClientHello and producing `code=-1` on the
+        # slicer side (#1610). Same fix the #620 client-side patch applied to
+        # `tcp_proxy.py::_create_client_ssl_context`; the bind-server / server
+        # side needs it too.
+        ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
         ctx.verify_mode = ssl.CERT_NONE
         return ctx
 

+ 14 - 2
backend/app/services/virtual_printer/ftp_server.py

@@ -642,8 +642,20 @@ class VirtualPrinterFTPServer:
         self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
         self._ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
 
-        # Use standard TLS settings for compatibility
-        self._ssl_context.set_ciphers("HIGH:!aNULL:!MD5:!RC4")
+        # Keep the historical `HIGH:!aNULL:!MD5:!RC4` baseline so the cipher
+        # set stays a strict superset of what shipped before (the previous
+        # set offered ~58 extra suites — CCM, ARIA, CAMELLIA, DSS variants —
+        # that no Bambu slicer is known to pick, but the
+        # [[feedback_dont_remove_compat_pinning]] HARD RULE says don't
+        # narrow a compat surface without proof). The two explicit additions
+        # cover the #1610 case on hardened distros (Fedora / RHEL with
+        # `update-crypto-policies`, hardened Alpine builds) where the system
+        # policy strips the plain-RSA `AES256-GCM-SHA384` / `AES128-GCM-SHA256`
+        # suites from `HIGH` — without them present the slicer's FTPS
+        # ClientHello (which mimics the cipher set real Bambu printers offer)
+        # finds no overlap and the handshake aborts. Listing them explicitly
+        # survives any system policy that strips them from `HIGH`.
+        self._ssl_context.set_ciphers("HIGH:AES256-GCM-SHA384:AES128-GCM-SHA256:!aNULL:!MD5:!RC4")
 
         logger.info("FTP SSL context created with standard settings")
 

+ 8 - 0
backend/app/services/virtual_printer/mqtt_server.py

@@ -276,6 +276,14 @@ class SimpleMQTTServer:
         ssl_context.verify_mode = ssl.CERT_NONE
         # Allow TLS 1.2 for broader compatibility (some slicers may not support 1.3)
         ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
+        # Match real Bambu printer cipher behaviour: include the plain-RSA
+        # AES-GCM suites the slicer expects. On hardened distros
+        # (Fedora / RHEL with `update-crypto-policies`, hardened Alpine builds)
+        # OpenSSL's `DEFAULT` list strips these suites, leaving no overlap
+        # with the slicer's MQTT-over-TLS ClientHello — handshake fails
+        # immediately and the slicer reports a connect error before any MQTT
+        # CONNECT can be sent (#1610 audit). Same shape as the #620 fix.
+        ssl_context.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
         # Disable hostname checking
         ssl_context.check_hostname = False
 

+ 7 - 0
backend/app/services/virtual_printer/tcp_proxy.py

@@ -194,6 +194,13 @@ class TLSProxy:
         ctx.minimum_version = ssl.TLSVersion.TLSv1_2
         # Don't require client certificates
         ctx.verify_mode = ssl.CERT_NONE
+        # Match real Bambu printer cipher behaviour on the slicer-facing side
+        # as well (the #620 fix only patched the printer-facing client context
+        # below). On hardened distros where OpenSSL `DEFAULT` strips the
+        # plain-RSA AES-GCM suites, the slicer's ClientHello has no overlap
+        # with our server's offered suites and the handshake fails before any
+        # data flows (#1610 audit).
+        ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
         return ctx
 
     def _create_client_ssl_context(self) -> ssl.SSLContext:

+ 205 - 0
backend/tests/unit/test_vp_tls_ciphers.py

@@ -0,0 +1,205 @@
+"""Regression tests for #1610: every slicer-facing VP TLS context must
+explicitly include the plain-RSA AES-GCM cipher suites that real Bambu
+printers (and therefore the BambuStudio / OrcaSlicer client paths) expect.
+
+Real Bambu printers offer only ``AES256-GCM-SHA384`` / ``AES128-GCM-SHA256``
+(plain RSA key exchange) on their TLS endpoints. Slicers built against
+that surface assume the server side will accept those suites. On
+distributions whose OpenSSL ``DEFAULT`` cipher list has been narrowed by a
+system crypto policy (Fedora / RHEL ``update-crypto-policies``, hardened
+Alpine builds), Python's stock ``SSLContext`` ends up offering only
+ECDHE/DHE — no overlap with the slicer's ClientHello, the handshake
+aborts, and the slicer reports a generic ``code=-1`` connect error.
+
+The #620 patch fixed this for the printer-facing CLIENT context in
+``tcp_proxy.py::_create_client_ssl_context``. #1610 audited the remaining
+slicer-facing surface and applied the same explicit cipher pin to every
+context that accepts a slicer connection:
+
+* ``bind_server.py::_create_tls_context``      — port 3002 (bind/detect)
+* ``mqtt_server.py`` (inline in ``start``)     — port 8883 (MQTT-over-TLS)
+* ``tcp_proxy.py::_create_server_ssl_context`` — proxy-mode 3002
+* ``ftp_server.py`` (inline in ``start``)      — port 990 (FTPS)
+
+If any of these regress to a context that no longer offers
+``AES256-GCM-SHA384`` / ``AES128-GCM-SHA256``, users on hardened distros
+will hit the #1610 / #620 cipher-mismatch failure mode.
+"""
+
+import ssl
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.services.virtual_printer.bind_server import BindServer
+from backend.app.services.virtual_printer.certificate import CertificateService
+from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
+from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
+from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+REQUIRED_CIPHERS = ("AES256-GCM-SHA384", "AES128-GCM-SHA256")
+
+
+def _cert_pair(tmp_path: Path) -> tuple[Path, Path]:
+    """Generate a real self-signed CA + per-VP cert pair via the production
+    CertificateService. Returns ``(cert_path, key_path)`` suitable for
+    ``load_cert_chain`` calls inside the VP services under test."""
+    svc = CertificateService(cert_dir=tmp_path, serial="01P00A391800001")
+    return svc.ensure_certificates()
+
+
+def _assert_required_ciphers(ctx: ssl.SSLContext, where: str) -> None:
+    """Fail with a useful diagnostic if either required cipher is missing."""
+    offered = {c["name"] for c in ctx.get_ciphers()}
+    missing = [c for c in REQUIRED_CIPHERS if c not in offered]
+    assert not missing, (
+        f"{where}: missing plain-RSA AES-GCM cipher(s) {missing}. "
+        f"Real Bambu printers / slicers require these on the slicer-facing "
+        f"TLS surface — see #1610. Offered ciphers: {sorted(offered)}"
+    )
+
+
+class TestBindServerTlsCiphers:
+    def test_create_tls_context_offers_plain_rsa_aes_gcm(self, tmp_path):
+        cert_path, key_path = _cert_pair(tmp_path)
+        server = BindServer(
+            serial="01P00A391800001",
+            model="C12",
+            name="vp",
+            version="01.07.00.00",
+            bind_address="127.0.0.1",
+            cert_path=cert_path,
+            key_path=key_path,
+        )
+        ctx = server._create_tls_context()
+        assert ctx is not None
+        _assert_required_ciphers(ctx, "bind_server._create_tls_context")
+
+
+class TestTlsProxyServerCiphers:
+    """Slicer-facing side of proxy mode — was not patched by the #620 fix."""
+
+    def test_create_server_ssl_context_offers_plain_rsa_aes_gcm(self, tmp_path):
+        cert_path, key_path = _cert_pair(tmp_path)
+        proxy = TLSProxy(
+            name="Bind-TLS",
+            listen_port=3002,
+            target_host="127.0.0.1",
+            target_port=3002,
+            server_cert_path=str(cert_path),
+            server_key_path=str(key_path),
+            on_connect=lambda cid: None,
+            on_disconnect=lambda cid: None,
+            bind_address="127.0.0.1",
+        )
+        ctx = proxy._create_server_ssl_context()
+        _assert_required_ciphers(ctx, "tcp_proxy._create_server_ssl_context")
+
+    def test_create_client_ssl_context_still_offers_plain_rsa_aes_gcm(self, tmp_path):
+        """The original #620 fix must remain in place."""
+        cert_path, key_path = _cert_pair(tmp_path)
+        proxy = TLSProxy(
+            name="Bind-TLS",
+            listen_port=3002,
+            target_host="127.0.0.1",
+            target_port=3002,
+            server_cert_path=str(cert_path),
+            server_key_path=str(key_path),
+            on_connect=lambda cid: None,
+            on_disconnect=lambda cid: None,
+            bind_address="127.0.0.1",
+        )
+        ctx = proxy._create_client_ssl_context()
+        _assert_required_ciphers(ctx, "tcp_proxy._create_client_ssl_context")
+
+
+class TestMqttServerTlsCiphers:
+    """MQTT server builds its SSLContext inline in start(); intercept the
+    ``asyncio.start_server`` call so the test doesn't actually bind a port."""
+
+    @pytest.mark.asyncio
+    async def test_start_configures_plain_rsa_aes_gcm(self, tmp_path):
+        cert_path, key_path = _cert_pair(tmp_path)
+        server = SimpleMQTTServer(
+            serial="01P00A391800001",
+            access_code="deadbeef",
+            cert_path=cert_path,
+            key_path=key_path,
+            model="C12",
+            bind_address="127.0.0.1",
+        )
+
+        captured: dict[str, ssl.SSLContext] = {}
+
+        async def _capture(*_args, ssl=None, **_kwargs):
+            captured["ctx"] = ssl
+
+            class _FakeServer:
+                sockets = []
+
+                def close(self):
+                    pass
+
+                async def wait_closed(self):
+                    pass
+
+                def is_serving(self):
+                    return False
+
+            return _FakeServer()
+
+        with patch("asyncio.start_server", _capture):
+            await server.start()
+        try:
+            assert "ctx" in captured, "asyncio.start_server was not invoked"
+            _assert_required_ciphers(captured["ctx"], "mqtt_server.start")
+        finally:
+            await server.stop()
+
+
+class TestFtpServerTlsCiphers:
+    """FTP server builds its SSLContext inline in start()."""
+
+    @pytest.mark.asyncio
+    async def test_start_configures_plain_rsa_aes_gcm(self, tmp_path):
+        cert_path, key_path = _cert_pair(tmp_path)
+        upload_dir = tmp_path / "uploads"
+        upload_dir.mkdir()
+        server = VirtualPrinterFTPServer(
+            upload_dir=upload_dir,
+            access_code="deadbeef",
+            cert_path=cert_path,
+            key_path=key_path,
+            bind_address="127.0.0.1",
+        )
+
+        captured: dict[str, ssl.SSLContext] = {}
+
+        async def _capture(*_args, ssl=None, **_kwargs):
+            captured["ctx"] = ssl
+
+            class _FakeServer:
+                sockets = []
+
+                def close(self):
+                    pass
+
+                async def wait_closed(self):
+                    pass
+
+                def is_serving(self):
+                    return False
+
+                async def serve_forever(self):
+                    pass
+
+            return _FakeServer()
+
+        with patch("asyncio.start_server", _capture):
+            await server.start()
+        try:
+            assert "ctx" in captured, "asyncio.start_server was not invoked"
+            _assert_required_ciphers(captured["ctx"], "ftp_server.start")
+        finally:
+            await server.stop()

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov