certificate.py 16 KB

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