feat(security): retire le MFA local, le code d'accès est celui du serveur (v0.8.1)

La v0.8.0 laissait cohabiter deux vérifications du code à 6 chiffres : la
fenêtre MFA locale, qui contrôlait un code contre un secret détenu par le
poste lui-même — et ne verrouillait donc que sa propre interface — puis la
validation par le serveur, seule à décider de l'accès réseau. L'ancienne
disparaît, avec le secret TOTP qu'elle stockait en clair dans config.json
(effacé au premier lancement) et l'onglet MFA du panneau Administrateur.

La saisie passe par une fenêtre dédiée qui relaie le refus exact du serveur
et laisse réessayer, la requête partant dans un thread pour qu'un serveur
muet ne fige pas l'application. Une ligne « Accès distant » distingue
désormais l'état de l'autorisation de celui du tunnel : monté sans code
validé, celui-ci ne mène nulle part.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 13:06:17 +02:00
co-authored by Claude Opus 5
parent dab6c1548b
commit cc9f521a95
15 changed files with 296 additions and 345 deletions
+17 -20
View File
@@ -49,8 +49,6 @@ _DEFAULT: dict[str, Any] = {
"version": "0.7.3",
"admin_password_hash": "",
"admin_salt": "",
"mfa_enabled": False,
"mfa_secret": "",
"active_profile": "H3",
"profiles": {},
"wg": deepcopy(_WG_DEFAULT),
@@ -76,6 +74,7 @@ class Config:
with open(self._path, "r", encoding="utf-8") as f:
saved = json.load(f)
self._data = self._merge(_DEFAULT, saved)
self._drop_local_mfa()
except Exception:
self._data = deepcopy(_DEFAULT)
else:
@@ -85,6 +84,22 @@ class Config:
self._data = deepcopy(_DEFAULT)
self._apply_autostart(self._data["ui"]["autostart"])
def _drop_local_mfa(self) -> None:
"""Efface le secret TOTP local hérité des versions ≤ 0.8.0.
Ce secret ne sert plus : le code à 6 chiffres est désormais validé
par le serveur, contre un secret que le poste client ne détient pas.
Le laisser en place serait un secret d'authentification en clair
dans `config.json`, sans rien qui le justifie — d'où l'effacement
au chargement plutôt qu'un simple abandon des clés.
"""
stale = [k for k in ("mfa_enabled", "mfa_secret") if k in self._data]
if not stale:
return
for key in stale:
del self._data[key]
self.save()
def save(self):
os.makedirs(os.path.dirname(self._path), exist_ok=True)
with open(self._path, "w", encoding="utf-8") as f:
@@ -151,24 +166,6 @@ class Config:
wg = self._data["wg"]
return bool(wg.get("server_endpoint") and wg.get("client_private_key"))
# ── MFA ───────────────────────────────────────────────────────────────
@property
def mfa_enabled(self) -> bool:
return bool(self._data.get("mfa_enabled", False))
@mfa_enabled.setter
def mfa_enabled(self, value: bool):
self._data["mfa_enabled"] = value
@property
def mfa_secret(self) -> str:
return self._data.get("mfa_secret", "")
@mfa_secret.setter
def mfa_secret(self, value: str):
self._data["mfa_secret"] = value
# ── Multi-profils ─────────────────────────────────────────────────────
@property
-48
View File
@@ -1,48 +0,0 @@
import pyotp
import qrcode
import io
from PyQt6.QtGui import QPixmap, QImage
def generate_secret() -> str:
return pyotp.random_base32()
def get_totp(secret: str) -> pyotp.TOTP:
return pyotp.TOTP(secret)
def verify_code(secret: str, code: str) -> bool:
if not secret or not code:
return False
totp = pyotp.TOTP(secret)
return totp.verify(code.strip(), valid_window=1)
def get_current_code(secret: str) -> str:
return pyotp.TOTP(secret).now()
def time_remaining() -> int:
"""Secondes restantes avant rotation du code TOTP."""
import time
return 30 - (int(time.time()) % 30)
def get_provisioning_uri(secret: str, account: str = "WGSecure", issuer: str = "WGSecure") -> str:
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=account, issuer_name=issuer)
def generate_qr_pixmap(secret: str, account: str = "WGSecure") -> QPixmap:
uri = get_provisioning_uri(secret, account)
qr = qrcode.QRCode(box_size=6, border=2)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
data = buf.read()
qimage = QImage.fromData(data)
return QPixmap.fromImage(qimage)