certificate.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. """TLS certificate generation for virtual printer services.
  2. Generates the certificate chain a slicer accepts in place of a real printer's:
  3. - CA certificate with CN = "Virtual Printer CA <id>", unique to the install
  4. that generated it (a CA generated before that carries the bare name)
  5. - Printer certificate has CN = serial number, signed by the CA
  6. The CA certificate is persistent and only regenerated if missing or expired.
  7. This allows users to add the CA to their slicer's trust store once.
  8. """
  9. import logging
  10. import socket
  11. from datetime import datetime, timedelta, timezone
  12. from ipaddress import IPv4Address
  13. from pathlib import Path
  14. from cryptography import x509
  15. from cryptography.hazmat.primitives import hashes, serialization
  16. from cryptography.hazmat.primitives.asymmetric import rsa
  17. from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
  18. logger = logging.getLogger(__name__)
  19. # How far back a new certificate's validity starts. A certificate valid from
  20. # the second it was made is refused, as not valid yet, by a client whose clock
  21. # is behind; the slicer then reports only "-1" when binding. The printer
  22. # certificate is made anew at every start, so this hit right after a restart.
  23. CLOCK_SKEW_ALLOWANCE = timedelta(days=1)
  24. # Default serial number for virtual printer (matches SSDP/MQTT config)
  25. DEFAULT_SERIAL = "00M09A391800001"
  26. # Minimum days remaining before CA is considered expired and needs regeneration
  27. CA_EXPIRY_THRESHOLD_DAYS = 30
  28. # Common-name prefix of the generated CA. What follows it is derived from the
  29. # CA's own public key, so two installs never share a Subject DN -- see
  30. # ``_generate_ca_certificate`` for why that matters.
  31. CA_COMMON_NAME_PREFIX = "Virtual Printer CA"
  32. def _get_local_ip() -> str:
  33. """Get the local IP address."""
  34. try:
  35. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  36. s.connect(("8.8.8.8", 80))
  37. ip = s.getsockname()[0]
  38. s.close()
  39. return ip
  40. except OSError:
  41. return "127.0.0.1"
  42. class CertificateService:
  43. """Generate and manage TLS certificates for virtual printer.
  44. Creates a certificate chain a slicer accepts in place of a real
  45. printer's:
  46. - Root CA with CN="Virtual Printer CA <id>", unique to the install that
  47. generated it (an older CA carries the bare name and is kept as it is)
  48. - Printer cert with CN=serial_number, signed by the CA
  49. """
  50. def __init__(self, cert_dir: Path, serial: str = DEFAULT_SERIAL, shared_ca_dir: Path | None = None):
  51. """Initialize the certificate service.
  52. Args:
  53. cert_dir: Directory to store per-instance certificates
  54. serial: Serial number to use as CN in printer certificate
  55. shared_ca_dir: If set, CA cert/key are read from this directory
  56. instead of cert_dir (for multi-instance shared CA)
  57. """
  58. self.cert_dir = cert_dir
  59. self.serial = serial
  60. ca_dir = shared_ca_dir or cert_dir
  61. self.ca_cert_path = ca_dir / "bbl_ca.crt"
  62. self.ca_key_path = ca_dir / "bbl_ca.key"
  63. self.cert_path = cert_dir / "virtual_printer.crt"
  64. self.key_path = cert_dir / "virtual_printer.key"
  65. def _load_existing_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate] | None:
  66. """Try to load existing CA certificate and key.
  67. Returns:
  68. Tuple of (ca_private_key, ca_certificate) if valid CA exists, None otherwise
  69. """
  70. if not self.ca_cert_path.exists() or not self.ca_key_path.exists():
  71. logger.debug("CA certificate or key not found")
  72. return None
  73. try:
  74. # Load CA certificate
  75. ca_cert_pem = self.ca_cert_path.read_bytes()
  76. ca_cert = x509.load_pem_x509_certificate(ca_cert_pem)
  77. # Check if CA is expired or about to expire
  78. now = datetime.now(timezone.utc)
  79. days_remaining = (ca_cert.not_valid_after_utc - now).days
  80. if days_remaining < CA_EXPIRY_THRESHOLD_DAYS:
  81. logger.warning("CA certificate expires in %s days, will regenerate", days_remaining)
  82. return None
  83. # Load CA private key
  84. ca_key_pem = self.ca_key_path.read_bytes()
  85. ca_key = serialization.load_pem_private_key(ca_key_pem, password=None)
  86. logger.info("Using existing CA certificate (expires in %s days)", days_remaining)
  87. return ca_key, ca_cert
  88. except (OSError, ValueError) as e:
  89. logger.warning("Failed to load existing CA: %s", e)
  90. return None
  91. def _get_or_create_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
  92. """Get existing CA or create a new one.
  93. Returns:
  94. Tuple of (ca_private_key, ca_certificate)
  95. """
  96. # Try to load existing CA first
  97. existing = self._load_existing_ca()
  98. if existing:
  99. return existing
  100. # Generate new CA
  101. ca_key, ca_cert = self._generate_ca_certificate()
  102. # Save CA certificate and key. ``ca_key_path`` and ``ca_cert_path``
  103. # resolve under ``shared_ca_dir`` (which may differ from cert_dir),
  104. # so the parent we need to mkdir is the CA file's parent — not
  105. # cert_dir. Previously this created the per-VP subdirectory while
  106. # the writes targeted the parent CA dir, which works only because
  107. # the manager pre-creates both — the method itself was latent.
  108. self.ca_key_path.parent.mkdir(parents=True, exist_ok=True)
  109. self.ca_key_path.write_bytes(
  110. ca_key.private_bytes(
  111. encoding=serialization.Encoding.PEM,
  112. format=serialization.PrivateFormat.TraditionalOpenSSL,
  113. encryption_algorithm=serialization.NoEncryption(),
  114. )
  115. )
  116. try:
  117. self.ca_key_path.chmod(0o600)
  118. except OSError as e:
  119. logger.warning("Could not set CA key permissions on %s: %s", self.ca_key_path, e)
  120. self.ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
  121. logger.info("Saved new CA certificate")
  122. return ca_key, ca_cert
  123. def _generate_ca_certificate(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
  124. """Generate a new CA certificate for the virtual printer.
  125. We use a generic name instead of mimicking BBL CA, since the slicer
  126. may specifically reject certificates claiming to be from BBL but
  127. with a different public key.
  128. Returns:
  129. Tuple of (ca_private_key, ca_certificate)
  130. """
  131. logger.info("Generating new Virtual Printer CA certificate...")
  132. # Generate CA private key
  133. ca_key = rsa.generate_private_key(
  134. public_exponent=65537,
  135. key_size=2048,
  136. )
  137. # Use a generic CA name - NOT BBL to avoid being rejected as fake.
  138. #
  139. # The name carries a per-install suffix taken from this CA's own key
  140. # identifier. A slicer trust store is a flat list of certificates and
  141. # OpenSSL looks an issuer up by Subject DN: it takes the first CA whose
  142. # DN matches and fails the chain if that one did not sign the
  143. # certificate, rather than trying the next match. So while every
  144. # install signed as plain "CN=Virtual Printer CA", a user who imported
  145. # the CAs of two Bambuddy instances broke one of them — each worked on
  146. # its own, together whichever landed second in the file lost, with the
  147. # same generic connection error an unimported CA gives (#3014).
  148. # Distinct DNs mean both are found and both verify.
  149. ca_skid = x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key())
  150. ca_name = x509.Name(
  151. [
  152. x509.NameAttribute(
  153. NameOID.COMMON_NAME,
  154. f"{CA_COMMON_NAME_PREFIX} {ca_skid.digest.hex()[:8].upper()}",
  155. ),
  156. ]
  157. )
  158. now = datetime.now(timezone.utc)
  159. ca_cert = (
  160. x509.CertificateBuilder()
  161. .subject_name(ca_name)
  162. .issuer_name(ca_name)
  163. .public_key(ca_key.public_key())
  164. .serial_number(x509.random_serial_number())
  165. .not_valid_before(now - CLOCK_SKEW_ALLOWANCE)
  166. .not_valid_after(now + timedelta(days=7300)) # 20 years
  167. .add_extension(
  168. x509.BasicConstraints(ca=True, path_length=0),
  169. critical=True,
  170. )
  171. .add_extension(
  172. x509.KeyUsage(
  173. digital_signature=True,
  174. content_commitment=False,
  175. key_encipherment=False,
  176. data_encipherment=False,
  177. key_agreement=False,
  178. key_cert_sign=True,
  179. crl_sign=True,
  180. encipher_only=False,
  181. decipher_only=False,
  182. ),
  183. critical=True,
  184. )
  185. .add_extension(ca_skid, critical=False)
  186. .sign(ca_key, hashes.SHA256())
  187. )
  188. return ca_key, ca_cert
  189. def _build_san_entries(self, local_ip: str, additional_ips: list[str] | None) -> list[x509.GeneralName]:
  190. """Build Subject Alternative Name entries for the printer certificate."""
  191. entries: list[x509.GeneralName] = [
  192. x509.DNSName("localhost"),
  193. x509.DNSName("bambuddy"),
  194. x509.DNSName(self.serial),
  195. x509.IPAddress(IPv4Address(local_ip)),
  196. x509.IPAddress(IPv4Address("127.0.0.1")),
  197. ]
  198. seen_ips = {local_ip, "127.0.0.1"}
  199. if additional_ips:
  200. for ip in additional_ips:
  201. if ip and ip not in seen_ips:
  202. try:
  203. entries.append(x509.IPAddress(IPv4Address(ip)))
  204. seen_ips.add(ip)
  205. logger.info("Added additional SAN IP: %s", ip)
  206. except ValueError:
  207. logger.warning("Skipping invalid additional SAN IP: %s", ip)
  208. return entries
  209. def generate_certificates(self, additional_ips: list[str] | None = None) -> tuple[Path, Path]:
  210. """Generate printer certificate (reusing existing CA if available).
  211. Creates a certificate chain mimicking real Bambu printers:
  212. - CA certificate (reused if exists and valid, otherwise generated)
  213. - Printer certificate (CN=serial, signed by CA)
  214. Args:
  215. additional_ips: Extra IP addresses to include in certificate SAN.
  216. Used in proxy mode to include the remote interface IP so the
  217. slicer's TLS handshake succeeds when connecting to the proxy.
  218. Returns:
  219. Tuple of (cert_path, key_path)
  220. """
  221. logger.info("Generating certificates for virtual printer (serial: %s)...", self.serial)
  222. # Ensure directory exists
  223. self.cert_dir.mkdir(parents=True, exist_ok=True)
  224. # Get or create CA (reuses existing if valid)
  225. ca_key, ca_cert = self._get_or_create_ca()
  226. # Generate printer private key
  227. printer_key = rsa.generate_private_key(
  228. public_exponent=65537,
  229. key_size=2048,
  230. )
  231. # Printer certificate subject - CN is the serial number (like real Bambu printers)
  232. printer_subject = x509.Name(
  233. [
  234. x509.NameAttribute(NameOID.COMMON_NAME, self.serial),
  235. ]
  236. )
  237. # Issuer is the CA
  238. issuer = ca_cert.subject
  239. # Key identifiers, but only when the CA carries one to point at. A CA
  240. # generated before the per-install common name has no
  241. # SubjectKeyIdentifier, and a leaf signed by it keeps exactly the shape
  242. # it has today rather than naming an identifier its issuer does not
  243. # advertise — those installs keep working with the CA they imported
  244. # long ago, untouched.
  245. try:
  246. ca_skid = ca_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
  247. except x509.ExtensionNotFound:
  248. ca_skid = None
  249. now = datetime.now(timezone.utc)
  250. local_ip = _get_local_ip()
  251. logger.info("Generating printer certificate with CN=%s, local IP: %s", self.serial, local_ip)
  252. # Build printer certificate signed by CA
  253. printer_cert_builder = (
  254. x509.CertificateBuilder()
  255. .subject_name(printer_subject)
  256. .issuer_name(issuer)
  257. .public_key(printer_key.public_key())
  258. .serial_number(x509.random_serial_number())
  259. # Never before its CA's own start: a fresh CA is backdated as much.
  260. .not_valid_before(max(now - CLOCK_SKEW_ALLOWANCE, ca_cert.not_valid_before_utc))
  261. .not_valid_after(now + timedelta(days=3650)) # 10 years
  262. .add_extension(
  263. x509.BasicConstraints(ca=False, path_length=None),
  264. critical=True,
  265. )
  266. .add_extension(
  267. x509.SubjectAlternativeName(self._build_san_entries(local_ip, additional_ips)),
  268. critical=False,
  269. )
  270. .add_extension(
  271. x509.ExtendedKeyUsage(
  272. [
  273. ExtendedKeyUsageOID.SERVER_AUTH,
  274. ExtendedKeyUsageOID.CLIENT_AUTH,
  275. ]
  276. ),
  277. critical=False,
  278. )
  279. .add_extension(
  280. x509.KeyUsage(
  281. digital_signature=True,
  282. content_commitment=False,
  283. key_encipherment=True,
  284. data_encipherment=False,
  285. key_agreement=False,
  286. key_cert_sign=False,
  287. crl_sign=False,
  288. encipher_only=False,
  289. decipher_only=False,
  290. ),
  291. critical=True,
  292. )
  293. )
  294. if ca_skid is not None:
  295. printer_cert_builder = printer_cert_builder.add_extension(
  296. x509.SubjectKeyIdentifier.from_public_key(printer_key.public_key()),
  297. critical=False,
  298. ).add_extension(
  299. x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ca_skid),
  300. critical=False,
  301. )
  302. printer_cert = printer_cert_builder.sign(ca_key, hashes.SHA256()) # Signed by CA, not self-signed
  303. # Write printer private key
  304. self.key_path.write_bytes(
  305. printer_key.private_bytes(
  306. encoding=serialization.Encoding.PEM,
  307. format=serialization.PrivateFormat.TraditionalOpenSSL,
  308. encryption_algorithm=serialization.NoEncryption(),
  309. )
  310. )
  311. try:
  312. self.key_path.chmod(0o600)
  313. except OSError as e:
  314. logger.warning("Could not set printer key permissions on %s: %s", self.key_path, e)
  315. # Write printer certificate (include CA cert in chain for full chain)
  316. cert_chain = printer_cert.public_bytes(serialization.Encoding.PEM) + ca_cert.public_bytes(
  317. serialization.Encoding.PEM
  318. )
  319. self.cert_path.write_bytes(cert_chain)
  320. logger.info("Generated certificate chain at %s", self.cert_dir)
  321. logger.info(" CA: %s", ca_cert.subject.rfc4514_string())
  322. logger.info(" Printer: CN=%s", self.serial)
  323. return self.cert_path, self.key_path
  324. def get_ca_certificate_info(self) -> dict:
  325. """Return the shared CA certificate as PEM text plus identifying metadata.
  326. Generates the CA if it does not exist yet. Safe to expose over the
  327. API: this is the *public* CA certificate users import into their
  328. slicer's trust store. The CA private key (``bbl_ca.key``) is never
  329. included and never leaves the backend.
  330. Returns:
  331. Dict with ``pem`` (PEM-encoded certificate), ``fingerprint_sha256``
  332. (colon-separated uppercase hex) and ``not_valid_after`` (ISO 8601).
  333. """
  334. _ca_key, ca_cert = self._get_or_create_ca()
  335. pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("ascii")
  336. digest = ca_cert.fingerprint(hashes.SHA256()).hex().upper()
  337. fingerprint = ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
  338. return {
  339. "pem": pem,
  340. "fingerprint_sha256": fingerprint,
  341. "not_valid_after": ca_cert.not_valid_after_utc.isoformat(),
  342. }
  343. def delete_printer_certificate(self) -> None:
  344. """Delete only the printer certificate (preserves CA)."""
  345. for path in [self.cert_path, self.key_path]:
  346. if path.exists():
  347. path.unlink()
  348. logger.info("Deleted printer certificate (CA preserved)")