2026-08-10 03:10:50 -04:00
|
|
|
#!/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)
|
|
|
|
|
"""
|
[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 13:30:31 -04:00
|
|
|
|
2026-08-10 03:10:50 -04:00
|
|
|
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}")
|