generator.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. # -*- coding: utf-8 -*-
  2. """
  3. Zertifikatsgenerator für selbstsignierte Zertifikate.
  4. Ermöglicht die Erstellung von Entwicklungs- und Produktionszertifikaten
  5. ohne externe Abhängigkeiten (nutzt nur Python-Standardbibliothek).
  6. """
  7. import hashlib
  8. import os
  9. import secrets
  10. import subprocess
  11. from dataclasses import dataclass, field
  12. from datetime import datetime, timedelta
  13. from enum import IntEnum, auto
  14. from pathlib import Path
  15. from typing import Any
  16. class KeyType(IntEnum):
  17. """Typ des kryptographischen Schlüssels."""
  18. RSA_2048 = auto()
  19. RSA_4096 = auto()
  20. ECDSA_P256 = auto()
  21. ECDSA_P384 = auto()
  22. @dataclass
  23. class CertificateRequest:
  24. """
  25. Anforderung für ein neues Zertifikat.
  26. Attributes:
  27. common_name: Common Name (CN) - üblicherweise Hostname
  28. organization: Organisation (O)
  29. organizational_unit: Organisationseinheit (OU)
  30. country: Land (C) - 2-Buchstaben-Code
  31. state: Bundesland/Region (ST)
  32. locality: Stadt (L)
  33. email: E-Mail-Adresse
  34. key_type: Schlüsseltyp
  35. days_valid: Gültigkeitsdauer in Tagen
  36. san_dns: Subject Alternative Names (DNS)
  37. san_ip: Subject Alternative Names (IP)
  38. """
  39. common_name: str
  40. organization: str = "Trixy"
  41. organizational_unit: str = ""
  42. country: str = "DE"
  43. state: str = ""
  44. locality: str = ""
  45. email: str = ""
  46. key_type: KeyType = KeyType.RSA_2048
  47. days_valid: int = 365
  48. san_dns: list[str] = field(default_factory=list)
  49. san_ip: list[str] = field(default_factory=list)
  50. def get_subject_string(self) -> str:
  51. """Gibt den Subject-String im OpenSSL-Format zurück."""
  52. parts = []
  53. if self.country:
  54. parts.append(f"/C={self.country}")
  55. if self.state:
  56. parts.append(f"/ST={self.state}")
  57. if self.locality:
  58. parts.append(f"/L={self.locality}")
  59. if self.organization:
  60. parts.append(f"/O={self.organization}")
  61. if self.organizational_unit:
  62. parts.append(f"/OU={self.organizational_unit}")
  63. if self.common_name:
  64. parts.append(f"/CN={self.common_name}")
  65. if self.email:
  66. parts.append(f"/emailAddress={self.email}")
  67. return "".join(parts)
  68. def get_san_extension(self) -> str:
  69. """Gibt die SAN-Erweiterung im OpenSSL-Format zurück."""
  70. san_entries = []
  71. for dns in self.san_dns:
  72. san_entries.append(f"DNS:{dns}")
  73. for ip in self.san_ip:
  74. san_entries.append(f"IP:{ip}")
  75. if not san_entries:
  76. # Mindestens den CN als SAN hinzufügen
  77. san_entries.append(f"DNS:{self.common_name}")
  78. return ",".join(san_entries)
  79. class CertificateGenerator:
  80. """
  81. Generator für selbstsignierte Zertifikate.
  82. Nutzt OpenSSL über subprocess für die Zertifikatserstellung.
  83. Fallback auf Python-Standardbibliothek wenn verfügbar.
  84. Example:
  85. generator = CertificateGenerator()
  86. # Entwicklungszertifikat erstellen
  87. request = CertificateRequest(
  88. common_name="localhost",
  89. organization="Development",
  90. san_dns=["localhost", "trixy.local"],
  91. san_ip=["127.0.0.1"]
  92. )
  93. cert_path, key_path = generator.generate_self_signed(
  94. request=request,
  95. output_dir=Path("./certs")
  96. )
  97. print(f"Zertifikat: {cert_path}")
  98. print(f"Schlüssel: {key_path}")
  99. """
  100. def __init__(self, openssl_path: str = "openssl") -> None:
  101. """
  102. Initialisiert den Generator.
  103. Args:
  104. openssl_path: Pfad zur OpenSSL-Binary
  105. """
  106. self._openssl_path = openssl_path
  107. self._has_openssl: bool | None = None
  108. def is_openssl_available(self) -> bool:
  109. """Prüft, ob OpenSSL verfügbar ist."""
  110. if self._has_openssl is not None:
  111. return self._has_openssl
  112. try:
  113. result = subprocess.run(
  114. [self._openssl_path, "version"],
  115. capture_output=True,
  116. text=True,
  117. timeout=5
  118. )
  119. self._has_openssl = result.returncode == 0
  120. except (FileNotFoundError, subprocess.TimeoutExpired):
  121. self._has_openssl = False
  122. return self._has_openssl
  123. def generate_self_signed(
  124. self,
  125. request: CertificateRequest,
  126. output_dir: Path,
  127. cert_filename: str = "server.crt",
  128. key_filename: str = "server.key"
  129. ) -> tuple[Path, Path]:
  130. """
  131. Generiert ein selbstsigniertes Zertifikat.
  132. Args:
  133. request: Zertifikatsanforderung
  134. output_dir: Ausgabeverzeichnis
  135. cert_filename: Name der Zertifikatsdatei
  136. key_filename: Name der Schlüsseldatei
  137. Returns:
  138. (cert_path, key_path)
  139. Raises:
  140. RuntimeError: Wenn Generierung fehlschlägt
  141. """
  142. if not self.is_openssl_available():
  143. return self._generate_with_python(
  144. request, output_dir, cert_filename, key_filename
  145. )
  146. return self._generate_with_openssl(
  147. request, output_dir, cert_filename, key_filename
  148. )
  149. def _generate_with_openssl(
  150. self,
  151. request: CertificateRequest,
  152. output_dir: Path,
  153. cert_filename: str,
  154. key_filename: str
  155. ) -> tuple[Path, Path]:
  156. """Generiert Zertifikat mit OpenSSL."""
  157. output_dir.mkdir(parents=True, exist_ok=True)
  158. key_path = output_dir / key_filename
  159. cert_path = output_dir / cert_filename
  160. # Schlüsselparameter
  161. key_params = self._get_openssl_key_params(request.key_type)
  162. # OpenSSL-Konfiguration für SAN erstellen
  163. config_path = output_dir / "openssl.cnf"
  164. self._write_openssl_config(config_path, request)
  165. try:
  166. # Privaten Schlüssel generieren
  167. if request.key_type in (KeyType.RSA_2048, KeyType.RSA_4096):
  168. key_bits = "2048" if request.key_type == KeyType.RSA_2048 else "4096"
  169. subprocess.run(
  170. [
  171. self._openssl_path, "genrsa",
  172. "-out", str(key_path),
  173. key_bits
  174. ],
  175. check=True,
  176. capture_output=True
  177. )
  178. else:
  179. curve = "prime256v1" if request.key_type == KeyType.ECDSA_P256 else "secp384r1"
  180. subprocess.run(
  181. [
  182. self._openssl_path, "ecparam",
  183. "-genkey",
  184. "-name", curve,
  185. "-out", str(key_path)
  186. ],
  187. check=True,
  188. capture_output=True
  189. )
  190. # Selbstsigniertes Zertifikat erstellen
  191. subprocess.run(
  192. [
  193. self._openssl_path, "req",
  194. "-new",
  195. "-x509",
  196. "-key", str(key_path),
  197. "-out", str(cert_path),
  198. "-days", str(request.days_valid),
  199. "-subj", request.get_subject_string(),
  200. "-config", str(config_path),
  201. "-extensions", "v3_req"
  202. ],
  203. check=True,
  204. capture_output=True
  205. )
  206. # Schlüsselberechtigungen setzen
  207. os.chmod(key_path, 0o600)
  208. return cert_path, key_path
  209. except subprocess.CalledProcessError as e:
  210. raise RuntimeError(
  211. f"OpenSSL-Fehler: {e.stderr.decode() if e.stderr else str(e)}"
  212. )
  213. finally:
  214. # Temporäre Config entfernen
  215. if config_path.exists():
  216. config_path.unlink()
  217. def _write_openssl_config(self, path: Path, request: CertificateRequest) -> None:
  218. """Schreibt die OpenSSL-Konfigurationsdatei."""
  219. san = request.get_san_extension()
  220. config = f"""
  221. [req]
  222. distinguished_name = req_distinguished_name
  223. req_extensions = v3_req
  224. prompt = no
  225. [req_distinguished_name]
  226. CN = {request.common_name}
  227. [v3_req]
  228. keyUsage = critical, digitalSignature, keyEncipherment
  229. extendedKeyUsage = serverAuth, clientAuth
  230. subjectAltName = {san}
  231. basicConstraints = critical, CA:FALSE
  232. """
  233. path.write_text(config)
  234. def _get_openssl_key_params(self, key_type: KeyType) -> dict[str, str]:
  235. """Gibt OpenSSL-Parameter für den Schlüsseltyp zurück."""
  236. params = {
  237. KeyType.RSA_2048: {"algorithm": "rsa", "bits": "2048"},
  238. KeyType.RSA_4096: {"algorithm": "rsa", "bits": "4096"},
  239. KeyType.ECDSA_P256: {"algorithm": "ec", "curve": "prime256v1"},
  240. KeyType.ECDSA_P384: {"algorithm": "ec", "curve": "secp384r1"},
  241. }
  242. return params[key_type]
  243. def _generate_with_python(
  244. self,
  245. request: CertificateRequest,
  246. output_dir: Path,
  247. cert_filename: str,
  248. key_filename: str
  249. ) -> tuple[Path, Path]:
  250. """
  251. Fallback-Generierung mit Python.
  252. Diese Methode erstellt ein minimales selbstsigniertes Zertifikat
  253. ohne externe Abhängigkeiten. Für Produktionsumgebungen wird
  254. OpenSSL oder die cryptography-Bibliothek empfohlen.
  255. """
  256. output_dir.mkdir(parents=True, exist_ok=True)
  257. key_path = output_dir / key_filename
  258. cert_path = output_dir / cert_filename
  259. # Warnung ausgeben
  260. from trixy_core.utils.debug import pwarn
  261. pwarn(
  262. "OpenSSL nicht verfügbar. Erstelle minimales Zertifikat. "
  263. "Für Produktion wird OpenSSL empfohlen."
  264. )
  265. # Versuche cryptography zu importieren
  266. try:
  267. return self._generate_with_cryptography(
  268. request, output_dir, cert_filename, key_filename
  269. )
  270. except ImportError:
  271. pass
  272. # Absoluter Fallback: Leere Dateien erstellen und Fehler werfen
  273. raise RuntimeError(
  274. "Weder OpenSSL noch cryptography-Bibliothek verfügbar. "
  275. "Bitte installiere: pip install cryptography"
  276. )
  277. def _generate_with_cryptography(
  278. self,
  279. request: CertificateRequest,
  280. output_dir: Path,
  281. cert_filename: str,
  282. key_filename: str
  283. ) -> tuple[Path, Path]:
  284. """Generiert Zertifikat mit cryptography-Bibliothek."""
  285. from cryptography import x509
  286. from cryptography.hazmat.primitives import hashes, serialization
  287. from cryptography.hazmat.primitives.asymmetric import rsa, ec
  288. from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
  289. import ipaddress
  290. output_dir.mkdir(parents=True, exist_ok=True)
  291. key_path = output_dir / key_filename
  292. cert_path = output_dir / cert_filename
  293. # Schlüssel generieren
  294. if request.key_type in (KeyType.RSA_2048, KeyType.RSA_4096):
  295. key_size = 2048 if request.key_type == KeyType.RSA_2048 else 4096
  296. private_key = rsa.generate_private_key(
  297. public_exponent=65537,
  298. key_size=key_size
  299. )
  300. else:
  301. curve = ec.SECP256R1() if request.key_type == KeyType.ECDSA_P256 else ec.SECP384R1()
  302. private_key = ec.generate_private_key(curve)
  303. # Subject erstellen
  304. subject_attrs = [
  305. x509.NameAttribute(NameOID.COMMON_NAME, request.common_name),
  306. ]
  307. if request.organization:
  308. subject_attrs.append(
  309. x509.NameAttribute(NameOID.ORGANIZATION_NAME, request.organization)
  310. )
  311. if request.country:
  312. subject_attrs.append(
  313. x509.NameAttribute(NameOID.COUNTRY_NAME, request.country)
  314. )
  315. subject = x509.Name(subject_attrs)
  316. # SAN erstellen
  317. san_entries = []
  318. for dns in request.san_dns:
  319. san_entries.append(x509.DNSName(dns))
  320. for ip in request.san_ip:
  321. san_entries.append(x509.IPAddress(ipaddress.ip_address(ip)))
  322. if not san_entries:
  323. san_entries.append(x509.DNSName(request.common_name))
  324. # Zertifikat erstellen
  325. now = datetime.utcnow()
  326. cert = (
  327. x509.CertificateBuilder()
  328. .subject_name(subject)
  329. .issuer_name(subject) # Selbstsigniert
  330. .public_key(private_key.public_key())
  331. .serial_number(x509.random_serial_number())
  332. .not_valid_before(now)
  333. .not_valid_after(now + timedelta(days=request.days_valid))
  334. .add_extension(
  335. x509.SubjectAlternativeName(san_entries),
  336. critical=False
  337. )
  338. .add_extension(
  339. x509.BasicConstraints(ca=False, path_length=None),
  340. critical=True
  341. )
  342. .add_extension(
  343. x509.KeyUsage(
  344. digital_signature=True,
  345. key_encipherment=True,
  346. content_commitment=False,
  347. data_encipherment=False,
  348. key_agreement=False,
  349. key_cert_sign=False,
  350. crl_sign=False,
  351. encipher_only=False,
  352. decipher_only=False
  353. ),
  354. critical=True
  355. )
  356. .add_extension(
  357. x509.ExtendedKeyUsage([
  358. ExtendedKeyUsageOID.SERVER_AUTH,
  359. ExtendedKeyUsageOID.CLIENT_AUTH
  360. ]),
  361. critical=False
  362. )
  363. .sign(private_key, hashes.SHA256())
  364. )
  365. # Schlüssel speichern
  366. key_pem = private_key.private_bytes(
  367. encoding=serialization.Encoding.PEM,
  368. format=serialization.PrivateFormat.TraditionalOpenSSL,
  369. encryption_algorithm=serialization.NoEncryption()
  370. )
  371. key_path.write_bytes(key_pem)
  372. os.chmod(key_path, 0o600)
  373. # Zertifikat speichern
  374. cert_pem = cert.public_bytes(serialization.Encoding.PEM)
  375. cert_path.write_bytes(cert_pem)
  376. return cert_path, key_path
  377. def generate_ca(
  378. self,
  379. request: CertificateRequest,
  380. output_dir: Path,
  381. ca_cert_filename: str = "ca.crt",
  382. ca_key_filename: str = "ca.key"
  383. ) -> tuple[Path, Path]:
  384. """
  385. Generiert ein CA-Zertifikat (Certificate Authority).
  386. Args:
  387. request: Zertifikatsanforderung
  388. output_dir: Ausgabeverzeichnis
  389. ca_cert_filename: Name der CA-Zertifikatsdatei
  390. ca_key_filename: Name der CA-Schlüsseldatei
  391. Returns:
  392. (ca_cert_path, ca_key_path)
  393. """
  394. if not self.is_openssl_available():
  395. raise RuntimeError(
  396. "OpenSSL erforderlich für CA-Generierung. "
  397. "Bitte OpenSSL installieren."
  398. )
  399. output_dir.mkdir(parents=True, exist_ok=True)
  400. ca_key_path = output_dir / ca_key_filename
  401. ca_cert_path = output_dir / ca_cert_filename
  402. # CA-Schlüssel generieren
  403. subprocess.run(
  404. [
  405. self._openssl_path, "genrsa",
  406. "-out", str(ca_key_path),
  407. "4096"
  408. ],
  409. check=True,
  410. capture_output=True
  411. )
  412. # CA-Zertifikat erstellen
  413. subprocess.run(
  414. [
  415. self._openssl_path, "req",
  416. "-new",
  417. "-x509",
  418. "-key", str(ca_key_path),
  419. "-out", str(ca_cert_path),
  420. "-days", str(request.days_valid),
  421. "-subj", request.get_subject_string(),
  422. "-extensions", "v3_ca",
  423. "-config", "-"
  424. ],
  425. input=b"""
  426. [req]
  427. distinguished_name = req_distinguished_name
  428. x509_extensions = v3_ca
  429. prompt = no
  430. [req_distinguished_name]
  431. CN = Trixy CA
  432. [v3_ca]
  433. basicConstraints = critical, CA:TRUE
  434. keyUsage = critical, keyCertSign, cRLSign
  435. """,
  436. check=True,
  437. capture_output=True
  438. )
  439. os.chmod(ca_key_path, 0o600)
  440. return ca_cert_path, ca_key_path
  441. def sign_certificate(
  442. self,
  443. csr_path: Path,
  444. ca_cert_path: Path,
  445. ca_key_path: Path,
  446. output_path: Path,
  447. days_valid: int = 365
  448. ) -> Path:
  449. """
  450. Signiert eine Zertifikatsanforderung (CSR) mit einer CA.
  451. Args:
  452. csr_path: Pfad zur CSR-Datei
  453. ca_cert_path: Pfad zum CA-Zertifikat
  454. ca_key_path: Pfad zum CA-Schlüssel
  455. output_path: Ausgabepfad für das signierte Zertifikat
  456. days_valid: Gültigkeitsdauer
  457. Returns:
  458. Pfad zum signierten Zertifikat
  459. """
  460. if not self.is_openssl_available():
  461. raise RuntimeError("OpenSSL erforderlich für Zertifikatssignierung")
  462. subprocess.run(
  463. [
  464. self._openssl_path, "x509",
  465. "-req",
  466. "-in", str(csr_path),
  467. "-CA", str(ca_cert_path),
  468. "-CAkey", str(ca_key_path),
  469. "-CAcreateserial",
  470. "-out", str(output_path),
  471. "-days", str(days_valid),
  472. "-sha256"
  473. ],
  474. check=True,
  475. capture_output=True
  476. )
  477. return output_path