#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Le coffre KeePassXC : aller-retour d'un PSK, et permissions. Un vrai fichier .kdbx est créé dans un répertoire temporaire — pykeepass fait tout hors ligne, donc ces tests ne demandent ni root, ni réseau, ni coffre de l'utilisateur. Ils sont ignorés (et le disent) si pykeepass n'est pas installé, plutôt que de passer en silence. """ import io import json import os import stat import sys import tempfile import unittest from contextlib import redirect_stdout from unittest.mock import patch sys.path.append( os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) ) from script.config.config_file import ConfigFile from script.todo.kdbx_manager import KdbxManager from script.vpn.vault import ( VaultError, VpnVault, secrets_from_env, secrets_to_env, ) try: from pykeepass import create_database except ModuleNotFoundError: create_database = None MASTER = "coffre-de-test" PSK = "cl3-Pr3-P4rt4g33!" PPP_PASSWORD = "m0tD3P4ss3-PPP" TITLE = "ERPLibre VPN / acme" @unittest.skipUnless(create_database, "pykeepass n'est pas installé") class VpnVaultRoundTrip(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.kdbx_path = os.path.join(self.tmp.name, "secrets.kdbx") create_database(self.kdbx_path, password=MASTER) # 0600 d'emblée : sinon chaque test verrait le coffre resserré à # l'ouverture et l'annoncerait, ce qui noierait la sortie de la # suite sous un avertissement qui n'est le sujet que d'un test. os.chmod(self.kdbx_path, 0o600) # Le mot de passe maître est mis dans la configuration UNIQUEMENT # ici : c'est ce qui permet au test de tourner sans saisie. Le CLI, # lui, signale cette situation à l'utilisateur. self.base = os.path.join(self.tmp.name, "todo.json") with open(self.base, "w") as fh: json.dump( {"kdbx": {"path": self.kdbx_path, "password": MASTER}}, fh ) self.private = os.path.join(self.tmp.name, "private.json") self.patches = [ patch("script.config.config_file.CONFIG_FILE", self.base), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE", os.path.join(self.tmp.name, "override.json"), ), patch( "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", self.private, ), ] for item in self.patches: item.start() config = ConfigFile() self.vault = VpnVault(config, KdbxManager(config)) def tearDown(self): for item in self.patches: item.stop() self.tmp.cleanup() def test_write_then_read(self): self.vault.write( TITLE, {"username": "ACME\\user", "password": "ppp", "psk": PSK}, ) values = self.vault.read(TITLE, fields=("psk", "password")) self.assertEqual(values["psk"], PSK) self.assertEqual(values["password"], "ppp") self.assertEqual(values["username"], "ACME\\user") def test_password_is_the_native_field_not_a_custom_one(self): """`password` est un champ NATIF de KeePassXC. Le chercher parmi les propriétés personnalisées le rendrait vide, alors qu'un pilote a le droit de le déclarer dans ses `secret_fields`.""" self.vault.write(TITLE, {"password": "ppp", "psk": PSK}) entry = self.vault._open().find_entries_by_title(TITLE, first=True) self.assertEqual(entry.password, "ppp") self.assertIsNone(entry.get_custom_property("password")) def test_psk_is_a_protected_property(self): """Protégée = chiffrée en mémoire et masquée dans KeePassXC, comme le champ mot de passe.""" self.vault.write(TITLE, {"psk": PSK}) entry = self.vault._open().find_entries_by_title(TITLE, first=True) self.assertTrue(entry.is_custom_property_protected("psk")) def test_entry_lands_in_its_own_group(self): self.vault.write(TITLE, {"psk": PSK}) entry = self.vault._open().find_entries_by_title(TITLE, first=True) self.assertEqual(entry.group.name, "ERPLibre VPN") def test_update_keeps_the_untouched_fields(self): """Une réponse vide dans le menu ne doit pas effacer l'autre secret : `write` ne touche que les clés qu'on lui donne.""" self.vault.write(TITLE, {"password": "ppp", "psk": PSK}) self.vault.write(TITLE, {"password": "nouveau"}) values = self.vault.read(TITLE, fields=("psk", "password")) self.assertEqual(values["password"], "nouveau") self.assertEqual(values["psk"], PSK) def test_missing_entry_reads_as_empty(self): """« Pas encore de secret » est un état normal, pas une panne.""" values = self.vault.read("ERPLibre VPN / inconnu", fields=("psk",)) self.assertEqual(values, {"username": "", "password": "", "psk": ""}) self.assertFalse(self.vault.exists("ERPLibre VPN / inconnu")) def test_the_vault_stays_owner_only_after_a_write(self): """LE test de non-régression. `PyKeePass.save()` réécrit le fichier et lui redonne le mode du umask — 0664 sur Ubuntu. Un chmod fait une fois à la création ne survivait pas au premier enregistrement : le coffre devenait lisible par toute la machine sans que personne ne touche à rien. """ os.chmod(self.kdbx_path, 0o600) self.vault.write(TITLE, {"password": "ppp", "psk": PSK}) mode = stat.S_IMODE(os.stat(self.kdbx_path).st_mode) self.assertEqual(mode, 0o600, oct(mode)) def test_a_loose_vault_is_tightened_and_it_says_so(self): """Trouvé desserré, il ne l'est pas resté — et ce n'est pas nous qui l'avions laissé ainsi, donc on le dit.""" os.chmod(self.kdbx_path, 0o664) buffer = io.StringIO() with redirect_stdout(buffer): self.vault.read(TITLE, fields=("psk",)) mode = stat.S_IMODE(os.stat(self.kdbx_path).st_mode) self.assertEqual(mode, 0o600, oct(mode)) self.assertIn("0600", buffer.getvalue()) def test_protect_leaves_an_already_tight_vault_alone(self): os.chmod(self.kdbx_path, 0o600) self.assertFalse(self.vault.protect()) def test_stored_master_password_is_reported(self): self.assertTrue(self.vault.master_password_is_stored()) @unittest.skipUnless(create_database, "pykeepass n'est pas installé") class VpnVaultBootstrap(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.base = os.path.join(self.tmp.name, "todo.json") with open(self.base, "w") as fh: json.dump({"kdbx": {"path": "", "password": ""}}, fh) self.private = os.path.join(self.tmp.name, "private.json") self.patches = [ patch("script.config.config_file.CONFIG_FILE", self.base), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE", os.path.join(self.tmp.name, "override.json"), ), patch( "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", self.private, ), ] for item in self.patches: item.start() config = ConfigFile() self.vault = VpnVault(config, KdbxManager(config)) self.target = os.path.join(self.tmp.name, "nouveau.kdbx") def tearDown(self): for item in self.patches: item.stop() self.tmp.cleanup() def _answers(self, *values): answers = iter(values) return lambda _prompt: next(answers) def test_creates_the_vault_and_remembers_where(self): with patch("getpass.getpass", return_value=MASTER): path = self.vault.ensure_vault(ask=self._answers(self.target, "o")) self.assertEqual(path, self.target) self.assertTrue(os.path.exists(self.target)) # Le chemin est retenu dans le fichier PRIVÉ, le seul gitignored. with open(self.private) as fh: self.assertEqual(json.load(fh)["kdbx"]["path"], self.target) def test_new_vault_is_owner_only(self): with patch("getpass.getpass", return_value=MASTER): self.vault.ensure_vault(ask=self._answers(self.target, "o")) mode = stat.S_IMODE(os.stat(self.target).st_mode) self.assertEqual(mode, 0o600, oct(mode)) def test_refusing_creates_nothing(self): path = self.vault.ensure_vault(ask=self._answers(self.target, "n")) self.assertEqual(path, "") self.assertFalse(os.path.exists(self.target)) def test_mismatched_confirmation_creates_nothing(self): with patch("getpass.getpass", side_effect=[MASTER, "autre"]): with self.assertRaises(VaultError): self.vault.ensure_vault(ask=self._answers(self.target, "o")) self.assertFalse(os.path.exists(self.target)) def test_reading_without_a_configured_vault_says_so(self): """Sans chemin, `KdbxManager` ouvrirait un sélecteur graphique et, sur un serveur sans tkinter, journaliserait une erreur au lieu de dire ce qui manque.""" with self.assertRaises(VaultError) as caught: self.vault.read(TITLE, fields=("psk",)) self.assertIn("coffre", str(caught.exception).lower()) class SecretsHandedOverByEnvironment(unittest.TestCase): """Le menu tient déjà le coffre ouvert quand il lance `vpn.py`. Sans ce passage, le mot de passe maître était redemandé DEUX fois par connexion — un essai à blanc précède le montage. Par l'environnement et non par un argument : /proc//environ n'est lisible que par le propriétaire, /proc//cmdline par tout le monde. """ def test_round_trip(self): env = secrets_to_env({"psk": PSK, "password": PPP_PASSWORD}) with patch.dict(os.environ, env, clear=False): got = secrets_from_env(("psk", "password")) self.assertEqual(got, {"psk": PSK, "password": PPP_PASSWORD}) def test_without_the_marker_nothing_is_claimed(self): """Un champ vide serait indistinguable d'un champ absent : le marqueur tranche, et on rouvre le coffre plutôt que de deviner.""" env = secrets_to_env({"psk": PSK}) del env["EL_VPN_SECRETS_PROVIDED"] with patch.dict(os.environ, env, clear=False): self.assertIsNone(secrets_from_env(("psk",))) def test_an_empty_secret_survives_the_trip(self): env = secrets_to_env({"psk": PSK, "wg_preshared_key": ""}) with patch.dict(os.environ, env, clear=False): got = secrets_from_env(("psk", "wg_preshared_key")) self.assertEqual(got["wg_preshared_key"], "") self.assertEqual(got["psk"], PSK) def test_no_secret_in_a_variable_name(self): """Les noms de variables partent dans l'environnement d'un sous-processus : ils ne doivent porter que la CLÉ, jamais la valeur.""" env = secrets_to_env({"psk": PSK}) for name in env: self.assertNotIn(PSK, name) @unittest.skipUnless(create_database, "pykeepass n'est pas installé") class VaultToTunnel(unittest.TestCase): """La couture complète : coffre → profil → plan de montage. Les autres tests injectent les secrets à la main. Celui-ci les fait VRAIMENT sortir d'un .kdbx, comme le CLI, et vérifie qu'ils n'ont pas fui en chemin. C'est le seul qui juge l'ensemble. """ def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.kdbx_path = os.path.join(self.tmp.name, "secrets.kdbx") create_database(self.kdbx_path, password=MASTER) # 0600 d'emblée : sinon chaque test verrait le coffre resserré à # l'ouverture et l'annoncerait, ce qui noierait la sortie de la # suite sous un avertissement qui n'est le sujet que d'un test. os.chmod(self.kdbx_path, 0o600) self.base = os.path.join(self.tmp.name, "todo.json") with open(self.base, "w") as fh: json.dump( { "kdbx": {"path": self.kdbx_path, "password": MASTER}, "vpn": [], }, fh, ) self.patches = [ patch("script.config.config_file.CONFIG_FILE", self.base), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE", os.path.join(self.tmp.name, "override.json"), ), patch( "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", os.path.join(self.tmp.name, "private.json"), ), ] for item in self.patches: item.start() def tearDown(self): for item in self.patches: item.stop() self.tmp.cleanup() def test_secret_goes_from_the_vault_to_the_plan_without_leaking(self): from script.vpn import profiles from script.vpn.drivers.l2tp_ipsec import L2tpIpsecDriver from script.vpn.runner import Runner from script.vpn.vault import redact config = ConfigFile() vault = VpnVault(config, KdbxManager(config)) profile = profiles.save( { "name": "acme", "driver": "l2tp_ipsec", "server": "127.0.0.1", "ppp_user": "ACME\\user", "routes": ["10.20.0.0/16"], } ) vault.write( profiles.secret_title("acme"), { "username": profile["ppp_user"], "password": PPP_PASSWORD, "psk": PSK, }, ) secrets = vault.read( profiles.secret_title("acme"), fields=("psk", "password") ) self.assertEqual(secrets["psk"], PSK) self.assertEqual(secrets["password"], PPP_PASSWORD) driver = L2tpIpsecDriver(profiles.load("acme"), secrets) runner = Runner( dry_run=True, quiet=True, redactor=lambda text: redact(text, driver.secret_values()), ) self.assertTrue(driver.up(runner)) # Le PSK atteint le fichier de secrets, en hexadécimal, et rien # d'autre. secret_writes = [ op for op in runner.ops if op["kind"] == "write" and op["secret"] ] hex_psk = PSK.encode("utf-8").hex() self.assertTrue( any(hex_psk in op["content"] for op in secret_writes), "le PSK n'a pas atteint le fichier de secrets", ) for op in runner.ops: if op["kind"] == "cmd": self.assertNotIn(PSK, op["cmd"]) self.assertNotIn(PPP_PASSWORD, op["cmd"]) # Le PSK en clair n'apparaît dans AUCUN contenu écrit : c'est sa # forme hexadécimale qui voyage. for op in secret_writes: self.assertNotIn(PSK, op["content"]) if __name__ == "__main__": unittest.main()