erplibre/script/todo/mail/crypto.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

120 lines
4.1 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Scellement du cache courriel.
Une enveloppe AUTO-DESCRIPTIVE précède chaque donnée : le premier octet-paire
dit comment lire la suite. Conséquence voulue : une base écrite en clair reste
lisible après passage en mode chiffré, et l'inverse échoue bruyamment plutôt
que de rendre du charabia.
clair b"P0" + donnees
chiffre b"E1" + nonce(12) + AES-256-GCM(chiffre || tag)
"""
from __future__ import annotations
import os
from script.todo.todo_i18n import t
CLEAR_MAGIC = b"P0"
SEALED_MAGIC = b"E1"
NONCE_LEN = 12
KEY_LEN = 32
class CryptoError(Exception):
"""Clé absente, clé fausse, enveloppe inconnue ou donnée altérée."""
def new_key() -> bytes:
"""Une clé AES-256 tirée du générateur du système."""
return os.urandom(KEY_LEN)
class MailCrypto:
"""Interface commune. `open` sait toujours lire une enveloppe en clair."""
def seal(self, data: bytes) -> bytes:
raise NotImplementedError
def open(self, blob: bytes) -> bytes:
raise NotImplementedError
@staticmethod
def _split(blob: bytes) -> tuple[bytes, bytes]:
if not isinstance(blob, (bytes, bytearray)) or len(blob) < 2:
raise CryptoError(t("mail_err_envelope_too_short"))
return bytes(blob[:2]), bytes(blob[2:])
class NullCrypto(MailCrypto):
"""Mode `clear` : on marque, on ne chiffre pas."""
def seal(self, data: bytes) -> bytes:
return CLEAR_MAGIC + data
def open(self, blob: bytes) -> bytes:
magic, body = self._split(blob)
if magic == CLEAR_MAGIC:
return body
if magic == SEALED_MAGIC:
raise CryptoError(t("mail_err_sealed_in_clear_mode"))
raise CryptoError(f"{t('mail_err_unknown_envelope')} {magic!r}")
class AesGcmCrypto(MailCrypto):
"""Modes `encrypted` et `ephemeral` : AES-256-GCM, nonce neuf à chaque appel."""
def __init__(self, key: bytes) -> None:
if not isinstance(key, (bytes, bytearray)) or len(key) != KEY_LEN:
raise CryptoError(
f"{t('mail_err_key_wrong_length')} {KEY_LEN}"
f" {t('mail_err_octets_unit')}"
)
try:
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except ImportError as exc: # pragma: no cover - dépendance absente
raise CryptoError(
t("mail_err_cryptography_not_installed")
) from exc
self._aes = AESGCM(bytes(key))
self._invalid_tag = InvalidTag
def seal(self, data: bytes) -> bytes:
nonce = os.urandom(NONCE_LEN)
return SEALED_MAGIC + nonce + self._aes.encrypt(nonce, data, None)
def open(self, blob: bytes) -> bytes:
magic, body = self._split(blob)
if magic == CLEAR_MAGIC:
return body
if magic != SEALED_MAGIC:
raise CryptoError(f"{t('mail_err_unknown_envelope')} {magic!r}")
nonce, payload = body[:NONCE_LEN], body[NONCE_LEN:]
try:
return self._aes.decrypt(nonce, payload, None)
except self._invalid_tag as exc:
raise CryptoError(t("mail_err_decrypt_refused")) from exc
except ValueError as exc:
raise CryptoError(
f"{t('mail_err_envelope_unreadable')} {exc}"
) from exc
# Toute autre exception remonte telle quelle : un bug de programmation
# ne doit JAMAIS se déguiser en « mauvaise clé ».
def build_crypto(mode: str, key: bytes | None) -> MailCrypto:
"""La boîte qui correspond au mode de cache d'un compte."""
if mode == "clear":
return NullCrypto()
if mode in ("encrypted", "ephemeral"):
if key is None:
raise CryptoError(
f"{t('mail_err_mode_prefix')} {mode}"
f" {t('mail_err_mode_requires_key')}"
)
return AesGcmCrypto(key)
raise CryptoError(f"{t('mail_err_unknown_cache_mode')} {mode}")