Quellcode durchsuchen

fix(virtual-printer): give each install's CA a name of its own (issue #3014)

A slicer holding the CA of two Bambuddy installs could only connect to
one of them. Each CA worked on its own; together, one stopped, with the
generic "Connect ... failed! [SN:..., code=-1]" that an install whose
CA was never imported gives.

Every install signed as exactly CN=Virtual Printer CA. A slicer's trust
store is a flat list of certificates and OpenSSL resolves an issuer by
Subject DN: it takes the first authority whose name matches and fails
the chain when that one turns out not to have signed the certificate,
rather than trying the next match. Whichever CA landed second in the
file lost -- decided by nothing but the order they were appended in.
Reproduced with openssl verify against a bundle holding two CAs: the
first leaf verifies, the second fails with "certificate signature
failure".

- certificate.py: a newly generated CA takes a suffix from its own key
  identifier (CN=Virtual Printer CA D55808BE) and publishes that
  identifier, which the printer certificate points back at.
- Existing CAs are untouched, so nothing has to be re-imported. A
  printer certificate signed by one keeps exactly the shape it has
  today: the authority key identifier is added only when the CA has an
  identifier to name.
- tests: unique names per install, the identifier reaching the leaf,
  an existing CA being reused unchanged, and both chains verifying
  through openssl from a single trust store.

The collision goes away as soon as one of the two CAs is newer than
this change. Two installs that both predate it still collide until one
has its bbl_ca.crt/.key deleted and regenerated, which is a re-import
for that one -- documented in the wiki.

Reported by @Steven-Pierce.
maziggy vor 23 Stunden
Ursprung
Commit
fc6b953816

Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
CHANGELOG.md


+ 58 - 12
backend/app/services/virtual_printer/certificate.py

@@ -1,7 +1,8 @@
 """TLS certificate generation for virtual printer services.
 
-Generates certificates that mimic real Bambu printer certificate format:
-- CA certificate mimics "BBL CA" from "BBL Technologies Co., Ltd"
+Generates the certificate chain a slicer accepts in place of a real printer's:
+- CA certificate with CN = "Virtual Printer CA <id>", unique to the install
+  that generated it (a CA generated before that carries the bare name)
 - Printer certificate has CN = serial number, signed by the CA
 
 The CA certificate is persistent and only regenerated if missing or expired.
@@ -27,6 +28,11 @@ DEFAULT_SERIAL = "00M09A391800001"
 # Minimum days remaining before CA is considered expired and needs regeneration
 CA_EXPIRY_THRESHOLD_DAYS = 30
 
+# Common-name prefix of the generated CA. What follows it is derived from the
+# CA's own public key, so two installs never share a Subject DN -- see
+# ``_generate_ca_certificate`` for why that matters.
+CA_COMMON_NAME_PREFIX = "Virtual Printer CA"
+
 
 def _get_local_ip() -> str:
     """Get the local IP address."""
@@ -43,8 +49,10 @@ def _get_local_ip() -> str:
 class CertificateService:
     """Generate and manage TLS certificates for virtual printer.
 
-    Creates a certificate chain mimicking real Bambu printers:
-    - Root CA with CN="BBL CA", O="BBL Technologies Co., Ltd", C="CN"
+    Creates a certificate chain a slicer accepts in place of a real
+    printer's:
+    - Root CA with CN="Virtual Printer CA <id>", unique to the install that
+      generated it (an older CA carries the bare name and is kept as it is)
     - Printer cert with CN=serial_number, signed by the CA
     """
 
@@ -90,9 +98,10 @@ class CertificateService:
         is broken even though both files exist on disk. ``ensure_certificates``
         uses this to decide whether to regenerate.
 
-        Uses real signature verification — Bambuddy's auto-generated CAs all
-        share the same Subject DN ("Virtual Printer CA"), so a DN-only compare
-        would incorrectly return True even after rotation.
+        Uses real signature verification — every CA generated before the
+        common name carried a per-install suffix is literally
+        "CN=Virtual Printer CA", so on those installs a DN-only compare would
+        incorrectly return True even after rotation.
         """
         try:
             if not self.ca_cert_path.exists():
@@ -213,10 +222,25 @@ class CertificateService:
             key_size=2048,
         )
 
-        # Use a generic CA name - NOT BBL to avoid being rejected as fake
+        # Use a generic CA name - NOT BBL to avoid being rejected as fake.
+        #
+        # The name carries a per-install suffix taken from this CA's own key
+        # identifier. A slicer trust store is a flat list of certificates and
+        # OpenSSL looks an issuer up by Subject DN: it takes the first CA whose
+        # DN matches and fails the chain if that one did not sign the
+        # certificate, rather than trying the next match. So while every
+        # install signed as plain "CN=Virtual Printer CA", a user who imported
+        # the CAs of two Bambuddy instances broke one of them — each worked on
+        # its own, together whichever landed second in the file lost, with the
+        # same generic connection error an unimported CA gives (#3014).
+        # Distinct DNs mean both are found and both verify.
+        ca_skid = x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key())
         ca_name = x509.Name(
             [
-                x509.NameAttribute(NameOID.COMMON_NAME, "Virtual Printer CA"),
+                x509.NameAttribute(
+                    NameOID.COMMON_NAME,
+                    f"{CA_COMMON_NAME_PREFIX} {ca_skid.digest.hex()[:8].upper()}",
+                ),
             ]
         )
 
@@ -248,6 +272,7 @@ class CertificateService:
                 ),
                 critical=True,
             )
+            .add_extension(ca_skid, critical=False)
             .sign(ca_key, hashes.SHA256())
         )
 
@@ -313,12 +338,23 @@ class CertificateService:
         # Issuer is the CA
         issuer = ca_cert.subject
 
+        # Key identifiers, but only when the CA carries one to point at. A CA
+        # generated before the per-install common name has no
+        # SubjectKeyIdentifier, and a leaf signed by it keeps exactly the shape
+        # it has today rather than naming an identifier its issuer does not
+        # advertise — those installs keep working with the CA they imported
+        # long ago, untouched.
+        try:
+            ca_skid = ca_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
+        except x509.ExtensionNotFound:
+            ca_skid = None
+
         now = datetime.now(timezone.utc)
         local_ip = _get_local_ip()
         logger.info("Generating printer certificate with CN=%s, local IP: %s", self.serial, local_ip)
 
         # Build printer certificate signed by CA
-        printer_cert = (
+        printer_cert_builder = (
             x509.CertificateBuilder()
             .subject_name(printer_subject)
             .issuer_name(issuer)
@@ -357,9 +393,19 @@ class CertificateService:
                 ),
                 critical=True,
             )
-            .sign(ca_key, hashes.SHA256())  # Signed by CA, not self-signed
         )
 
+        if ca_skid is not None:
+            printer_cert_builder = printer_cert_builder.add_extension(
+                x509.SubjectKeyIdentifier.from_public_key(printer_key.public_key()),
+                critical=False,
+            ).add_extension(
+                x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ca_skid),
+                critical=False,
+            )
+
+        printer_cert = printer_cert_builder.sign(ca_key, hashes.SHA256())  # Signed by CA, not self-signed
+
         # Write printer private key
         self.key_path.write_bytes(
             printer_key.private_bytes(
@@ -380,7 +426,7 @@ class CertificateService:
         self.cert_path.write_bytes(cert_chain)
 
         logger.info("Generated certificate chain at %s", self.cert_dir)
-        logger.info("  CA: CN=Virtual Printer CA")
+        logger.info("  CA: %s", ca_cert.subject.rfc4514_string())
         logger.info("  Printer: CN=%s", self.serial)
         return self.cert_path, self.key_path
 

+ 125 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -5,6 +5,7 @@ Tests the virtual printer manager, FTP server, and SSDP server components.
 
 import asyncio
 import json
+import shutil
 import zipfile
 from pathlib import Path
 from unittest.mock import AsyncMock, MagicMock, patch
@@ -3774,6 +3775,130 @@ class TestCertificateService:
         assert cert_path.exists()
         assert key_path.exists()
 
+    # --- CA identity (#3014) ------------------------------------------------
+    #
+    # A slicer trust store is a flat list of certificates, and OpenSSL resolves
+    # an issuer by Subject DN: the first CA whose DN matches is the only one
+    # tried. While every Bambuddy install signed as plain "Virtual Printer CA",
+    # importing the CAs of two instances broke whichever landed second in the
+    # file — with the same generic connection error an unimported CA produces.
+
+    @staticmethod
+    def _install(base, serial):
+        """Build a CertificateService laid out the way the manager lays one out."""
+        from backend.app.services.virtual_printer.certificate import CertificateService
+
+        return CertificateService(cert_dir=base / "0", serial=serial, shared_ca_dir=base)
+
+    @staticmethod
+    def _plant_legacy_ca(ca_dir):
+        """Write a pre-#3014 CA: plain common name, no SubjectKeyIdentifier."""
+        from datetime import datetime, timedelta, timezone
+
+        from cryptography import x509
+        from cryptography.hazmat.primitives import hashes, serialization
+        from cryptography.hazmat.primitives.asymmetric import rsa
+        from cryptography.x509.oid import NameOID
+
+        key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+        name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Virtual Printer CA")])
+        now = datetime.now(timezone.utc)
+        cert = (
+            x509.CertificateBuilder()
+            .subject_name(name)
+            .issuer_name(name)
+            .public_key(key.public_key())
+            .serial_number(x509.random_serial_number())
+            .not_valid_before(now)
+            .not_valid_after(now + timedelta(days=7300))
+            .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
+            .sign(key, hashes.SHA256())
+        )
+        ca_dir.mkdir(parents=True, exist_ok=True)
+        (ca_dir / "bbl_ca.key").write_bytes(
+            key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.TraditionalOpenSSL,
+                encryption_algorithm=serialization.NoEncryption(),
+            )
+        )
+        (ca_dir / "bbl_ca.crt").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
+        return cert
+
+    def test_ca_common_name_is_unique_per_install(self, tmp_path):
+        """Two installs must not produce CAs that share a Subject DN."""
+        from cryptography import x509
+
+        from backend.app.services.virtual_printer.certificate import CA_COMMON_NAME_PREFIX
+
+        cas = []
+        for name in ("a", "b"):
+            service = self._install(tmp_path / name, "TEST123")
+            service.generate_certificates()
+            cas.append(x509.load_pem_x509_certificate(service.ca_cert_path.read_bytes()))
+
+        subjects = [ca.subject.rfc4514_string() for ca in cas]
+        assert all(s.startswith(f"CN={CA_COMMON_NAME_PREFIX} ") for s in subjects)
+        assert subjects[0] != subjects[1]
+
+    def test_ca_key_identifier_is_carried_into_the_printer_certificate(self, cert_service):
+        """The CA advertises a key id and the leaf names it as its authority."""
+        from cryptography import x509
+
+        cert_path, _ = cert_service.generate_certificates()
+        leaf = x509.load_pem_x509_certificate(cert_path.read_bytes())
+        ca = x509.load_pem_x509_certificate(cert_service.ca_cert_path.read_bytes())
+
+        ca_skid = ca.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
+        leaf_akid = leaf.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier).value
+        assert leaf_akid.key_identifier == ca_skid.digest
+
+    def test_existing_ca_is_reused_and_its_certificate_shape_is_unchanged(self, tmp_path):
+        """An install that already has a CA keeps it — nothing to re-import.
+
+        Its printer certificate also stays exactly as it was: no authority key
+        identifier, because the CA it was signed by advertises none.
+        """
+        from cryptography import x509
+        from cryptography.hazmat.primitives import hashes
+
+        planted = self._plant_legacy_ca(tmp_path)
+        service = self._install(tmp_path, "TEST123")
+        cert_path, _ = service.generate_certificates()
+
+        ca = x509.load_pem_x509_certificate(service.ca_cert_path.read_bytes())
+        assert ca.fingerprint(hashes.SHA256()) == planted.fingerprint(hashes.SHA256())
+        assert ca.subject.rfc4514_string() == "CN=Virtual Printer CA"
+
+        leaf = x509.load_pem_x509_certificate(cert_path.read_bytes())
+        with pytest.raises(x509.ExtensionNotFound):
+            leaf.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier)
+
+    @pytest.mark.skipif(shutil.which("openssl") is None, reason="needs the openssl binary")
+    def test_two_installs_verify_from_a_single_trust_store(self, tmp_path):
+        """The reported symptom: both CAs imported, both chains must verify."""
+        import subprocess
+
+        leaves = []
+        bundle = b""
+        for name, serial in (("a", "00M09A391800001"), ("b", "01P00A391800002")):
+            service = self._install(tmp_path / name, serial)
+            cert_path, _ = service.generate_certificates()
+            # The per-VP file is a chain (leaf + CA); OpenSSL reads the leaf first.
+            leaves.append(cert_path)
+            bundle += service.ca_cert_path.read_bytes()
+
+        bundle_path = tmp_path / "trust_store.pem"
+        bundle_path.write_bytes(bundle)
+
+        for leaf in leaves:
+            result = subprocess.run(
+                ["openssl", "verify", "-CAfile", str(bundle_path), str(leaf)],
+                capture_output=True,
+                text=True,
+            )
+            assert result.returncode == 0, result.stdout + result.stderr
+
 
 class TestBindServer:
     """Tests for BindServer (port 3002 bind/detect protocol)."""

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.