erplibre/test/test_vpn_vault.py
Mathieu Benoit 197d19d61e [ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.

Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.

--- EN ---

The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.

What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.

Assisted-by: Claude Opus 5
2026-09-04 03:42:49 +00:00

379 lines
15 KiB
Python

#!/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/<pid>/environ n'est lisible que par le
propriétaire, /proc/<pid>/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()