erplibre/script/todo/mail/crypto.py
Mathieu Benoit 16aecc2d33 [ADD] mail: read and send email from the TODO CLI
An IMAP/SMTP client in the menu, with its tests against real servers. Most
of the work went into refusals: an application password is named only when
the server actually refuses, an accented password is reported as never
having left the machine, and a refusal is recognised by what the server
SAYS rather than by matching its wording. A malformed date no longer takes
the whole folder down, and a received email is never read as markup.

--- FR ---

Un client IMAP/SMTP dans le menu, avec ses tests contre de vrais serveurs.
L'essentiel du travail porte sur les refus : le mot de passe d'application
n'est nommé que lorsque le serveur refuse vraiment, un mot de passe accentué
est signalé comme n'ayant jamais quitté la machine, et un refus se reconnaît
à ce que le serveur DIT plutôt qu'à ses mots. Une date illisible n'emporte
plus le dossier entier, et un courriel reçu n'est jamais lu comme du balisage.

Assisted-by: Claude Opus 5
2026-08-16 03:56:34 -04:00

119 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}")