certificate.py 19 KB

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