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