erplibre/script/todo/mail/secrets.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

179 lines
6.4 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Où vivent les mots de passe courriel.
Deux coffres, dans cet ordre : le kdbx du dépôt (déjà utilisé par le CLI pour
OpenAI et les comptes Odoo), puis le trousseau du système.
Le trousseau système n'est accepté QUE si son backend chiffre vraiment. Sans
service de secrets — SSH, conteneur, poste sans session graphique — `keyring`
retombe sur `keyrings.alt`, qui écrit le mot de passe en clair dans un fichier.
L'accepter en silence serait un piège, donc on refuse et on le dit.
Référence de secret : "<coffre>:<chemin>"
kdbx:ERPLibre/Mail/perso -> groupe ERPLibre > Mail, entrée perso
kdbx:ERPLibre/Mail/perso/cache-key -> ... entrée cache-key
keyring:perso -> service "erplibre-mail", user perso
"""
from __future__ import annotations
import logging
from script.todo.todo_i18n import t
_logger = logging.getLogger(__name__)
KEYRING_SERVICE = "erplibre-mail"
# Backends dont on sait qu'ils chiffrent. Liste blanche volontaire : un
# backend inconnu est refusé, parce qu'on ne peut pas prouver qu'il chiffre.
SAFE_BACKENDS = {
("keyring.backends.SecretService", "Keyring"),
("keyring.backends.macOS", "Keyring"),
("keyring.backends.Windows", "WinVaultKeyring"),
("keyring.backends.kwallet", "DBusKeyring"),
}
class SecretError(Exception):
"""Aucun coffre utilisable, ou référence malformée."""
def keyring_backend_name() -> str:
"""Nom pleinement qualifié du backend keyring actif, "" s'il est absent."""
try:
import keyring
except ImportError:
return ""
backend = keyring.get_keyring()
cls = type(backend)
return f"{cls.__module__}.{cls.__qualname__}"
def keyring_is_safe() -> bool:
"""Vrai seulement si le backend actif chiffre pour de bon."""
try:
import keyring
except ImportError:
return False
cls = type(keyring.get_keyring())
return (cls.__module__, cls.__qualname__) in SAFE_BACKENDS
def create_kdbx(path: str, password: str) -> None:
"""Crée une base KeePass vide. Refuse d'écraser un fichier existant."""
import os
from pykeepass import create_database
if os.path.exists(path):
raise SecretError(f"{t('mail_err_file_already_exists')} {path}")
parent = os.path.dirname(os.path.abspath(path))
os.makedirs(parent, exist_ok=True)
os.chmod(parent, 0o700)
# `create_database` écrit d'abord un fichier `.tmp` — `construct` l'ouvre
# avec `open(filename, "w+b")`, donc à l'umask du process — avant un
# `shutil.move` vers `path` (pykeepass évite ainsi de corrompre une base
# existante en cas d'échec). Un `os.open` sur `path` ne fermerait donc
# PAS la fenêtre : c'est ce fichier intermédiaire, invisible d'ici, qui
# porterait le coffre en clair. Resserrer l'umask le temps de l'appel
# couvre les deux fichiers.
previous_umask = os.umask(0o077)
try:
create_database(path, password=password)
finally:
os.umask(previous_umask)
os.chmod(path, 0o600)
class SecretStore:
"""Lecture et écriture de secrets, par référence."""
def __init__(self, kdbx_manager=None, use_keyring: bool = True) -> None:
self._kdbx_manager = kdbx_manager
self._use_keyring = use_keyring
# -- API publique ---------------------------------------------------
def available_backends(self) -> list[str]:
found = []
if self._kdbx_manager is not None:
found.append("kdbx")
if self._use_keyring and keyring_is_safe():
found.append("keyring")
return found
def get(self, ref: str) -> str | None:
scheme, path = self._parse(ref)
if scheme == "kdbx":
entry = self._kdbx_entry(path, create=False)
return entry.password if entry else None
return self._keyring_call("get_password", path)
def set(self, ref: str, secret: str) -> None:
scheme, path = self._parse(ref)
if scheme == "kdbx":
entry = self._kdbx_entry(path, create=True)
entry.password = secret
self._kdbx().save()
return
self._keyring_call("set_password", path, secret)
def delete(self, ref: str) -> None:
scheme, path = self._parse(ref)
if scheme == "kdbx":
entry = self._kdbx_entry(path, create=False)
if entry:
self._kdbx().delete_entry(entry)
self._kdbx().save()
return
self._keyring_call("delete_password", path)
# -- Détail ---------------------------------------------------------
@staticmethod
def _parse(ref: str) -> tuple[str, str]:
scheme, sep, path = (ref or "").partition(":")
if not sep or scheme not in ("kdbx", "keyring") or not path:
raise SecretError(f"{t('mail_err_invalid_secret_ref')} {ref!r}")
return scheme, path
def _kdbx(self):
if self._kdbx_manager is None:
raise SecretError(t("mail_err_no_kdbx_configured"))
kp = self._kdbx_manager.get_kdbx()
if kp is None:
raise SecretError(t("mail_err_kdbx_unreadable"))
return kp
def _kdbx_entry(self, path: str, create: bool):
"""`path` = "Groupe/SousGroupe/Titre". Crée les groupes au besoin."""
kp = self._kdbx()
*group_names, title = path.split("/")
group = kp.root_group
for name in group_names:
found = next((g for g in group.subgroups if g.name == name), None)
if found is None:
if not create:
return None
found = kp.add_group(group, name)
group = found
entry = next((e for e in group.entries if e.title == title), None)
if entry is None and create:
entry = kp.add_entry(group, title, "", "")
return entry
def _keyring_call(self, func_name: str, *args):
if not self._use_keyring:
raise SecretError(t("mail_err_no_vault_available"))
if not keyring_is_safe():
raise SecretError(
f"{t('mail_err_keyring_plaintext')}"
f" {keyring_backend_name() or 'absent'})."
f" {t('mail_err_keyring_plaintext_hint')}"
)
import keyring
return getattr(keyring, func_name)(KEYRING_SERVICE, *args)