diff --git a/app/app_info.py b/app/app_info.py index 957782d..54cf4d6 100644 --- a/app/app_info.py +++ b/app/app_info.py @@ -118,6 +118,264 @@ TECH_STACK = [ ] +# ── Mode d'emploi ──────────────────────────────────────────────────────── +HOWTO_FIRST_INTRO = ( + "Huit étapes entre une installation nue et un tunnel qui monte. " + "L'ordre compte : le serveur ne peut pas accepter le poste tant qu'il " + "n'a pas reçu sa clé publique." +) +HOWTO_FIRST = [ + "Ouvrir ⚙️ Administrateur depuis la fenêtre principale.", + "Onglet WireGuard → Tunnel & profils : renseigner l'adresse du " + "serveur, le port UDP et la clé publique du serveur.", + "Onglet WireGuard → Clés : cliquer sur Générer une nouvelle " + "paire de clés, puis transmettre la clé publique affichée à " + "l'administrateur du serveur — sans elle, le serveur refusera la " + "connexion.", + "Retour sur Tunnel & profils : saisir l'adresse IP du client " + "et les IPs autorisées fournies par l'administrateur.", + "Cocher Le serveur utilise une clé pré-partagée uniquement si le " + "serveur en déclare une pour ce poste.", + "Toujours sur Tunnel & profils, renseigner l'Identifiant " + "VPN : le nom du compte tel qu'il figure sur le serveur. Laissé " + "vide, aucun code d'accès n'est demandé à la connexion — le serveur est " + "alors traité comme permissif.", + "Cliquer sur Enregistrer et fermer.", + "Se connecter depuis la fenêtre principale ou le menu du systray, puis " + "saisir le code d'accès à 6 chiffres quand il est demandé.", +] + +HOWTO_DAILY = [ + ( + "Changer de serveur", + "Onglet WireGuard → Tunnel & profils : sélectionner un " + "profil puis ✅ Charger.", + ), + ( + "Reprendre une configuration reçue", + "Même onglet, 📂 Importer un .conf : clés, adresses et clé " + "pré-partagée sont repris automatiquement. Les champs issus de " + "l'import se verrouillent, pour ne pas diverger en silence du pair " + "déjà déclaré côté serveur.", + ), + ( + "Configurer un téléphone", + "📱 QR Code, à scanner depuis l'application WireGuard mobile.", + ), +] + +HOWTO_TROUBLE = [ + ( + "Commencer toujours par le diagnostic", + "Onglet Test connexion → Chaîne de connexion, puis 🚀 Lancer " + "le test de connexion. Le rapport nomme l'étape qui bloque au " + "lieu de laisser deviner.", + ), + ( + "Le tunnel monte mais rien ne circule", + "Vérifier que les IPs autorisées ne contiennent pas le réseau " + "local par lequel le serveur est joint : la passerelle serait routée " + "dans le tunnel, qui a justement besoin d'elle.", + ), + ( + "Plus aucun nom ne se résout après une coupure", + "Onglet Test connexion → Fuite DNS, puis 🛠️ Réparer le DNS " + "(tunnel arrêté).", + ), + ( + "Une fenêtre d'authentification à chaque connexion", + "Onglet Sécurité, sous-onglet Droits sudo sous Linux ou " + "Élévation sous Windows : il nomme ce qui manque et comment " + "l'accorder.", + ), + ( + "Le code d'accès est refusé", + "Le code est validé par le serveur, pas par ce poste : un refus vient " + "du serveur et son message est repris tel quel dans le journal.", + ), +] + +# ── Prérequis ──────────────────────────────────────────────────────────── +REQUIREMENTS = [ + ("🐧 Linux — paquets", [ + ( + "wireguard-tools (indispensable)", + "Fournit wg et wg-quick, qui montent et " + "démontent le tunnel.
" + "sudo apt install wireguard-tools — Debian, Ubuntu" + "
sudo dnf install wireguard-tools — Fedora", + ), + ( + "iproute2 (indispensable)", + "ip sert à supprimer une interface restée en place " + "après un arrêt brutal.", + ), + ( + "coreutils (indispensable)", + "install dépose la configuration dans " + "/etc/wireguard avec le bon propriétaire et les bonnes " + "permissions, en une seule commande privilégiée.", + ), + ( + "resolvconf ou systemd-resolved (optionnel)", + "Nécessaire pour que la directive DNS du tunnel soit appliquée. " + "En leur absence, le tunnel monte sans toucher au DNS du système " + "plutôt que d'échouer à mi-course.", + ), + ]), + ("🪟 Windows", [ + ( + "WireGuard for Windows (indispensable)", + "wireguard.exe installe et retire le service du " + "tunnel. L'installeur officiel est embarqué dans le programme " + "d'installation de WGSecure, qui le lance si WireGuard est absent " + "du poste.", + ), + ( + "Droits administrateur", + "Exigés pour installer ou retirer le service. Le service n'est " + "installé qu'une fois : les démarrages suivants n'ouvrent plus " + "de fenêtre UAC.", + ), + ]), + ("🔌 Ports réseau", [ + ( + "UDP 51820 — sortant, poste client", + "Port par défaut du serveur WireGuard ; ajustable dans l'onglet " + "Tunnel & profils. C'est le seul flux que l'application émet " + "vers le serveur.", + ), + ( + "UDP 51820 — entrant, côté serveur", + "Le serveur doit écouter dessus, et le routeur doit y rediriger " + "le trafic venant d'Internet. Sans cette redirection, le " + "handshake n'aboutit jamais et rien ne le signale : WireGuard " + "ignore silencieusement les paquets qu'il ne peut pas " + "authentifier.", + ), + ( + "Filtrage sur le port source", + "Un pare-feu ne doit pas exiger un port source particulier : un " + "client WireGuard émet depuis un port éphémère aléatoire. Le " + "diagnostic détecte ce cas.", + ), + ( + "UDP/TCP 53 — DNS", + "Résolution du nom du serveur avant connexion, puis résolutions " + "dans le tunnel une fois monté.", + ), + ]), + ("🔑 Privilèges", [ + ( + "Linux — root, sudo ou pkexec", + "Demandés commande par commande, au moment du besoin. Une règle " + "sudo évite la fenêtre d'authentification répétée ; le sous-onglet " + "Sécurité → Droits sudo indique lesquelles manquent.", + ), + ( + "Windows — administrateur", + "Pour l'installation et le retrait du service du tunnel.", + ), + ]), + ("🐍 Depuis les sources", [ + ( + "Python 3.11 ou supérieur", + "Dépendances : PyQt6, cryptography, qrcode, Pillow, bcrypt. Les " + "binaires distribués les embarquent : rien à installer.", + ), + ]), +] + +# ── Sécurité ───────────────────────────────────────────────────────────── +SECURITY = [ + ("Authentification", [ + ( + "Mot de passe administrateur haché avec bcrypt, coût 12", + "Le mot de passe n'est jamais stocké, même chiffré : seule son " + "empreinte l'est, avec un sel propre à chaque enregistrement.", + ), + ( + "Vérification résistante à l'analyse temporelle", + "bcrypt.checkpw compare l'empreinte sans laisser sa " + "durée trahir le nombre de caractères corrects. Les anciennes " + "empreintes SHA-256, encore acceptées le temps d'une migration, " + "passent par secrets.compare_digest pour la même " + "raison.", + ), + ( + "Code d'accès validé par le serveur", + "Le code à 6 chiffres part au serveur, seul détenteur du secret " + "et seul point où l'accès est réellement ouvert. L'ancien " + "vérificateur local ne verrouillait que sa propre fenêtre : le " + "secret vivait sur le poste, et le contourner suffisait. Un " + "secret hérité d'une version ≤ 0.8.0 est effacé au démarrage.", + ), + ]), + ("Cryptographie", [ + ( + "Clés Curve25519 générées par la bibliothèque cryptography", + "Échange X25519, l'algorithme même de WireGuard. L'application ne " + "dépend pas du binaire wg pour produire ses clés.", + ), + ( + "Aléa fourni par le système d'exploitation", + "Clés pré-partagées et index de session tirés de " + "os.urandom, jamais d'un générateur pseudo-aléatoire " + "ordinaire.", + ), + ( + "Clé pré-partagée de 32 octets", + "Longueur imposée par WireGuard ; le format est vérifié avant " + "d'être accepté.", + ), + ]), + ("Protection des secrets au repos", [ + ( + "Configuration du tunnel en 0600", + "Le fichier contenant la clé privée n'est lisible que par son " + "propriétaire, y compris le fichier temporaire employé pour " + "l'installer dans /etc/wireguard — créé directement en 0600, " + "jamais lisible par d'autres, même brièvement.", + ), + ( + "Clés privées masquées à l'écran", + "Affichées en points, révélées seulement sur demande explicite.", + ), + ( + "Export .conf également restreint", + "Un fichier exporté reçoit les mêmes permissions que la " + "configuration d'origine.", + ), + ]), + ("Réduction de la surface d'attaque", [ + ( + "Privilèges demandés au coup par coup", + "L'application ne tourne pas en permanence avec les droits " + "d'administration : chaque commande privilégiée est élevée " + "séparément, par sudo puis pkexec.", + ), + ( + "Aucun appel passé par un interpréteur de commandes", + "Les commandes système sont invoquées sous forme de liste " + "d'arguments, ce qui exclut l'injection par le contenu d'un champ " + "de configuration.", + ), + ( + "Sonde de handshake sans état", + "Le test de connexion négocie une poignée de main puis jette " + "immédiatement la session : il ne monte aucun tunnel et ne " + "conserve aucun secret dérivé.", + ), + ( + "Nettoyage après un arrêt brutal", + "Interface orpheline et configuration DNS résiduelle sont " + "retirées, pour ne pas laisser le système dans un état " + "intermédiaire.", + ), + ]), +] + + def parse_changelog() -> list[dict]: """Lit CHANGELOG.md : titres `## [x.y.z] — date`, sous-titres `### ...` et puces `- ...` (avec leurs lignes de continuation) uniquement — le diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index d3d22c9..fa2d4bb 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -958,7 +958,7 @@ class AdminWindow(QDialog): # Onglet À propos # ------------------------------------------------------------------ # def _tab_about(self) -> QWidget: - """Page « À propos », en cinq sous-onglets (modèle QuiOulé/TuxMarket). + """Page « À propos », en huit sous-onglets (modèle QuiOulé/TuxMarket). Le contenu (fonctions, garanties, pile technique, auteur) vit dans `app/app_info.py`, séparé de sa présentation ici — le rendre @@ -974,11 +974,14 @@ class AdminWindow(QDialog): sub = QTabWidget() sub.setStyleSheet(theme.TABBAR_CSS) - sub.addTab(self._subtab_about_features(app_info), "⭐ Fonctions") - sub.addTab(self._subtab_about_guarantees(app_info), "🛡️ Garanties") - sub.addTab(self._subtab_about_tech(app_info), "🧱 Technique") - sub.addTab(self._subtab_about_changelog(app_info), "🕓 Journal") - sub.addTab(self._subtab_about_author(app_info), "👤 Auteur") + sub.addTab(self._subtab_about_features(app_info), "⭐ Fonctions") + sub.addTab(self._subtab_about_howto(app_info), "📖 Mode d'emploi") + sub.addTab(self._subtab_about_requirements(app_info), "📦 Prérequis") + sub.addTab(self._subtab_about_security(app_info), "🔐 Sécurité") + sub.addTab(self._subtab_about_guarantees(app_info), "🛡️ Garanties") + sub.addTab(self._subtab_about_tech(app_info), "🧱 Technique") + sub.addTab(self._subtab_about_changelog(app_info), "🕓 Journal") + sub.addTab(self._subtab_about_author(app_info), "👤 Auteur") lay.addWidget(sub) return w @@ -1105,6 +1108,29 @@ class AdminWindow(QDialog): area.setWidget(inner) return area + def _about_group_label(self, text: str) -> QLabel: + """Intitulé séparant deux groupes de cartes dans une même liste. + + Prérequis et Sécurité alignent une vingtaine de cartes chacun : sans + rien pour marquer où finit « Linux » et où commence « Windows », la + liste se lit comme un seul bloc indifférencié. + """ + lbl = QLabel(text) + lbl.setWordWrap(True) + lbl.setStyleSheet( + f"color: {theme.ACCENT_LIGHT}; font-size: 13px; font-weight: bold;" + " background: transparent; margin-top: 6px;" + ) + return lbl + + def _about_grouped(self, groups: list[tuple[str, list[tuple[str, str]]]]) -> QWidget: + """Liste de cartes découpée en groupes titrés.""" + items: list[QWidget] = [] + for title, entries in groups: + items.append(self._about_group_label(title)) + items += [self._about_card(body, title=head) for head, body in entries] + return self._about_card_list(items) + # ── Sous-onglet : fonctions ─────────────────────────────────────────── def _subtab_about_features(self, info) -> QWidget: page, v = self._test_page() @@ -1112,6 +1138,33 @@ class AdminWindow(QDialog): v.addWidget(self._about_card_list(cards)) return page + # ── Sous-onglet : mode d'emploi ─────────────────────────────────────── + def _subtab_about_howto(self, info) -> QWidget: + page, v = self._test_page() + v.addWidget(self._hint(info.HOWTO_FIRST_INTRO)) + items: list[QWidget] = [ + self._about_card(step, icon=f"{i}\u20e3") + for i, step in enumerate(info.HOWTO_FIRST, 1) + ] + items.append(self._about_group_label("Au quotidien")) + items += [self._about_card(body, title=head) for head, body in info.HOWTO_DAILY] + items.append(self._about_group_label("Quand la connexion échoue")) + items += [self._about_card(body, title=head) for head, body in info.HOWTO_TROUBLE] + v.addWidget(self._about_card_list(items)) + return page + + # ── Sous-onglet : prérequis ─────────────────────────────────────────── + def _subtab_about_requirements(self, info) -> QWidget: + page, v = self._test_page() + v.addWidget(self._about_grouped(info.REQUIREMENTS)) + return page + + # ── Sous-onglet : sécurité ──────────────────────────────────────────── + def _subtab_about_security(self, info) -> QWidget: + page, v = self._test_page() + v.addWidget(self._about_grouped(info.SECURITY)) + return page + # ── Sous-onglet : garanties ─────────────────────────────────────────── def _subtab_about_guarantees(self, info) -> QWidget: page, v = self._test_page()