chore(release): v0.7.19

Sécurité : la clé privée ne subsiste plus en clair dans %APPDATA%.
Le .conf généré est écrasé puis supprimé dès que wireguard.exe l'a
consommé (il en garde sa propre copie chiffrée DPAPI), et l'import
propose d'effacer le fichier source. Windows uniquement : sous Linux
/etc/wireguard/<iface>.conf reste requis par wg-quick down.

Corrige aussi ImpersonateNamedPipeClient, qui appartient à
win32security et non à win32pipe (vérifié par introspection).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 08:36:16 +02:00
co-authored by Claude Sonnet 5
parent e96f829f8b
commit e272b9397e
9 changed files with 100 additions and 15 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
__version__ = "0.7.18"
__version__ = "0.7.19"
APP_NAME = "WGSecure"
APP_SHORT = "WGS"
+24 -8
View File
@@ -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/<iface>.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):
+1 -1
View File
@@ -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:
+31 -1
View File
@@ -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()
+28
View File
@@ -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")