diff --git a/CHANGELOG.md b/CHANGELOG.md index ce25781..faf7cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/). --- +## [0.7.19] — 2026-09-05 + +### Sécurité +- **La clé privée ne subsiste plus en clair dans `%APPDATA%\WGSecure`.** Le fichier `.conf` généré pour monter le tunnel y restait indéfiniment, lisible par l'utilisateur : il est désormais écrasé puis supprimé dès que `wireguard.exe` l'a consommé (celui-ci en conserve sa propre copie, chiffrée par DPAPI, dans `ProgramData\WireGuard\Configurations`, et la désinstallation du tunnel ne prend que le nom de l'interface). Windows uniquement : sous Linux, `/etc/wireguard/.conf` reste indispensable à `wg-quick down`. +- **Le `.conf` importé peut être effacé dans la foulée.** Après un import, l'application propose d'écraser et supprimer le fichier source, qui contient lui aussi la clé privée en clair — il traîne sinon dans Téléchargements ou sur le Bureau. Proposé et non imposé : c'est un fichier choisi par l'utilisateur, le détruire sans confirmation serait irrattrapable si l'import s'avérait incorrect. + +### Corrigé +- **« Erreur interne : module win32pipe has no attribute ImpersonateNamedPipeClient » à la connexion.** Cette fonction appartient à `win32security`, pas à `win32pipe` (vérifié par introspection de pywin32, et non plus supposé). + +--- + ## [0.7.18] — 2026-09-05 ### Corrigé diff --git a/Makefile b/Makefile index c7d8994..21f8543 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ## ────────────────────────────────────────────── APP := wgsecure -VERSION := 0.7.18 +VERSION := 0.7.19 VENV := .venv PYTHON := $(VENV)/bin/python3 PIP := $(VENV)/bin/pip diff --git a/README.md b/README.md index cb67ce6..352f9aa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🛡️ WGSecure (WGS) -🚀 v0.7.18 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard +🚀 v0.7.19 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard **WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard avec une surcouche MFA (authentification à deux facteurs TOTP). @@ -210,4 +210,4 @@ WGSecure/ ## 👤 Auteur Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools) -Version : ![v0.7.18](https://img.shields.io/badge/v0.7.18-septembre%202026-2980b9?style=flat-square) — Septembre 2026 +Version : ![v0.7.19](https://img.shields.io/badge/v0.7.19-septembre%202026-2980b9?style=flat-square) — Septembre 2026 diff --git a/app/__init__.py b/app/__init__.py index 5490bac..72632b3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.7.18" +__version__ = "0.7.19" APP_NAME = "WGSecure" APP_SHORT = "WGS" diff --git a/app/core/wireguard.py b/app/core/wireguard.py index ab40bf6..9e15233 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -16,6 +16,7 @@ from app.utils.platform_utils import ( run_command, run_privileged, run_privileged_readonly, + secure_delete, wg_available, wg_exe, wg_quick_available, @@ -367,14 +368,29 @@ def connect(cfg: Config) -> tuple[bool, str]: if dns_util.interface_exists(name): force_cleanup(cfg) cmd = [wireguard_exe(), "/installtunnelservice", result] - code, _, err = _run_wireguard_service_cmd(cmd, timeout=90) - # /installtunnelservice enregistre le service et rend la main : le - # démarrage effectif du tunnel est asynchrone. Tester l'état - # immédiatement conclurait « échec » sur un tunnel qui monte. - if code == 0 and _wait_connected(cfg, timeout=15): - return True, _post_connect_message(cfg) - force_cleanup(cfg) - return False, err or f"Échec de l'installation du tunnel (code {code})" + try: + code, _, err = _run_wireguard_service_cmd(cmd, timeout=90) + # /installtunnelservice enregistre le service et rend la main : le + # démarrage effectif du tunnel est asynchrone. Tester l'état + # immédiatement conclurait « échec » sur un tunnel qui monte. + if code == 0 and _wait_connected(cfg, timeout=15): + return True, _post_connect_message(cfg) + force_cleanup(cfg) + return False, err or f"Échec de l'installation du tunnel (code {code})" + finally: + # Ce .conf porte la clé privée en clair, dans un dossier lisible + # par l'utilisateur (%APPDATA%\WGSecure) : il ne doit pas y + # subsister une fois consommé. wireguard.exe en a déjà pris sa + # propre copie, chiffrée par DPAPI, dans son magasin + # (ProgramData\WireGuard\Configurations) — et + # /uninstalltunnelservice ne prend que le nom de l'interface, + # jamais un chemin : plus rien ne le relit ensuite. Il est + # réécrit à chaque connexion. + # + # Sous Linux au contraire, /etc/wireguard/.conf est + # indispensable à `wg-quick down` : on n'y touche pas (branche + # Windows uniquement). + secure_delete(result) # Déjà monté et fonctionnel : rien à faire. if is_connected(cfg): diff --git a/app/service/helper_service.py b/app/service/helper_service.py index 534b996..3afa5a3 100644 --- a/app/service/helper_service.py +++ b/app/service/helper_service.py @@ -141,7 +141,7 @@ def _validate_config_path(config_path, name: str, handle) -> str: # appartient pas / ne lui est pas lisible. Fail closed — si # l'usurpation elle-même échoue, on refuse. try: - win32pipe.ImpersonateNamedPipeClient(handle) + win32security.ImpersonateNamedPipeClient(handle) except pywintypes.error as e: raise ValidationError(f"Identité de l'appelant non vérifiable : {e}") from None try: diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index 14979fb..d79ef9b 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -15,7 +15,9 @@ from app.core import dns as dns_util from app.core import mfa as mfa_core from app.ui import theme from app.ui.worker import ValueWorker -from app.utils.platform_utils import is_windows, run_command, run_privileged +from app.utils.platform_utils import ( + is_windows, run_command, run_privileged, secure_delete, +) class AdminWindow(QDialog): def __init__(self, config: Config, parent=None): @@ -1404,6 +1406,34 @@ class AdminWindow(QDialog): "accidentelle — déverrouillez-les dans l'onglet Tunnel pour les " "éditer." ) + self._offer_source_shred(path) + + def _offer_source_shred(self, path: str): + """Propose d'effacer le .conf source une fois importé. + + Ce fichier porte la clé privée en clair : le laisser traîner dans + Téléchargements ou sur le Bureau annule une bonne part de l'intérêt + de l'application. La suppression est proposée plutôt qu'imposée — + c'est un fichier que l'utilisateur a choisi lui-même, et le détruire + sans demander serait irrattrapable si l'import s'avérait incorrect. + """ + reply = QMessageBox.question( + self, "Effacer le fichier source ?", + f"Le fichier importé contient votre clé privée en clair :\n{path}\n\n" + "Il n'est plus nécessaire : la configuration est enregistrée dans " + "le profil.\n\nL'effacer maintenant ?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + if secure_delete(path): + QMessageBox.information(self, "Fichier effacé", + f"Fichier source supprimé :\n{path}") + else: + QMessageBox.warning( + self, "Suppression impossible", + f"Le fichier n'a pas pu être supprimé :\n{path}\n\n" + "Supprimez-le manuellement : il contient votre clé privée.") def _export_conf(self): self._save_values() diff --git a/app/utils/platform_utils.py b/app/utils/platform_utils.py index bc28d64..e479a32 100644 --- a/app/utils/platform_utils.py +++ b/app/utils/platform_utils.py @@ -22,6 +22,34 @@ def get_config_dir() -> str: return path +def secure_delete(path: str) -> bool: + """Écrase puis supprime un fichier contenant un secret. + + Sert aux fichiers `.conf` WireGuard, qui portent la clé privée en + clair : ils ne doivent pas subsister une fois consommés. + + L'écrasement préalable est explicitement « au mieux » : sur SSD (wear + levelling, TRIM) ou sur un système de fichiers journalisé ou + copy-on-write, l'ancien contenu peut survivre dans des blocs que le + système n'expose plus. Il protège d'une récupération simple de + l'espace libre, pas d'une analyse forensique — la seule protection + solide reste le chiffrement du disque. + """ + try: + size = os.path.getsize(path) + with open(path, "r+b") as f: + f.write(b"\0" * size) + f.flush() + os.fsync(f.fileno()) + except OSError: + pass # fichier absent ou verrouillé : on tente quand même la suppression + try: + os.remove(path) + return True + except OSError: + return False + + def get_wg_config_dir() -> str: if is_windows(): base = os.environ.get("PROGRAMDATA", r"C:\ProgramData") diff --git a/wgsecure.iss b/wgsecure.iss index a608119..824b2b2 100644 --- a/wgsecure.iss +++ b/wgsecure.iss @@ -5,7 +5,7 @@ ; ────────────────────────────────────────────── #define MyAppName "WGSecure" -#define MyAppVersion "0.7.18" +#define MyAppVersion "0.7.19" #define MyAppPublisher "WGSecure" #define MyAppExeName "wgsecure.exe"