"""
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