mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 11:25:48 +02:00
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.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user