Files
bambuddy/backend/tests/unit/test_vp_tls_ciphers.py
maziggy 9cc4b6aa60 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.
2026-06-03 10:04:37 +02:00

206 lines
7.5 KiB
Python

"""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()