Code source de mirza.server.sslManager

"""
Module de generation et gestion automatique des certificats SSL/TLS pour l'acces HTTPS sur reseau local.
Permet a Safari iOS et Android Chrome d'autoriser l'acces au microphone du smartphone.
"""

from __future__ import annotations

import ipaddress
import os
from datetime import UTC, datetime, timedelta

from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID

from ..core.logger import getMirzaLogger

logger = getMirzaLogger("mirza.server.ssl")


[docs] class SslManager: """ Gestionnaire de certificats SSL auto-signes pour HTTPS/WSS sur reseau local. """
[docs] @staticmethod def ensureCertificates( certsDir: str = "certs", localIp: str = "127.0.0.1", additionalIps: list[str] | None = None, ) -> tuple[str, str]: """ Verifie l'existence des certificats SSL et les genere si necessaire. Ajoute les extensions SAN (Subject Alternative Name) pour localhost, l'adresse IP locale du Mac et le reseau Tailscale. Parameters ---------- certsDir : str Dossier de stockage des cles et certificats (defaut: "certs"). localIp : str Adresse IP locale de la machine sur le LAN (defaut: "127.0.0.1"). additionalIps : list[str] | None Adresses IP ou noms d'hotes additionnels (ex: IP Tailscale). Returns ------- tuple[str, str] Chemins des fichiers (certPath, keyPath). """ os.makedirs(certsDir, exist_ok=True) certPath = os.path.join(certsDir, "cert.pem") keyPath = os.path.join(certsDir, "key.pem") targetIps = set() if localIp and localIp != "127.0.0.1": targetIps.add(localIp) if additionalIps: for ip in additionalIps: if ip and ip != "127.0.0.1": targetIps.add(ip) # Un certificat existant sans toutes les IP requises doit etre regenere. if os.path.exists(certPath) and os.path.exists(keyPath): try: with open(certPath, "rb") as f: existingCert = x509.load_pem_x509_certificate(f.read()) sanExt = existingCert.extensions.get_extension_for_oid(x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME) existingSans = set(str(name.value) for name in sanExt.value) if targetIps.issubset(existingSans): logger.debug("Certificat SSL existant valide pour les adresses: %s", existingSans) return certPath, keyPath except Exception as exc: logger.debug("Re-generation requise du certificat SSL: %s", exc) logger.info("Generation d'un nouveau certificat SSL auto-signe (SAN: localhost, %s)...", targetIps) privateKey = rsa.generate_private_key( public_exponent=65537, key_size=2048, ) subject = issuer = x509.Name([ x509.NameAttribute(NameOID.COUNTRY_NAME, "FR"), x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "IDF"), x509.NameAttribute(NameOID.LOCALITY_NAME, "Paris"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Mirza AI"), x509.NameAttribute(NameOID.COMMON_NAME, localIp), ]) sanList = [ x509.DNSName("localhost"), x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")), ] for ip in targetIps: try: sanList.append(x509.IPAddress(ipaddress.IPv4Address(ip))) except ValueError: sanList.append(x509.DNSName(ip)) nowUtc = datetime.now(UTC) cert = ( x509.CertificateBuilder() .subject_name(subject) .issuer_name(issuer) .public_key(privateKey.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(nowUtc - timedelta(days=1)) .not_valid_after(nowUtc + timedelta(days=365)) .add_extension( x509.SubjectAlternativeName(sanList), critical=False, ) .sign(privateKey, hashes.SHA256()) ) with open(keyPath, "wb") as f: f.write(privateKey.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), )) try: os.chmod(keyPath, 0o600) except OSError as exc: # Une cle privee lisible par tous est une exposition reelle, pas un echec cosmetique. logger.error("Impossible de restreindre la cle privee %s a 0600 : %s", keyPath, exc) with open(certPath, "wb") as f: f.write(cert.public_bytes(serialization.Encoding.PEM)) logger.info("Certificats SSL generes avec succes dans %s", certsDir) return certPath, keyPath