From 9216f636bde09811bfa393e857e1ad944d3baeb6 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 5 Sep 2026 03:21:32 +0200 Subject: [PATCH] feat: ajouter le Credential Vault (memorisation securisee optionnelle) Par defaut, inchange : les mots de passe sont toujours redemandes a chaque connexion. Nouvelle case a cocher optionnelle dans l'editeur de session, visible seulement en authentification par mot de passe : "Memoriser le mot de passe de maniere securisee". - app/credentials.py : abstraction CredentialStore (set_secret/get_secret/ delete_secret/is_available), pour supporter plus tard d'autres systemes que Windows. WindowsCredentialStore utilise Windows Credential Manager (CredWrite/CredRead/CredDelete) via pywin32-ctypes - deja present dans l'environnement (dependance de PyInstaller), mais pas encore declare comme dependance d'execution : ajoute a requirements.txt (marque sys_platform == "win32"), sinon la fonctionnalite aurait silencieusement ete indisponible sur une installation propre. NullCredentialStore en repli si indisponible : jamais d'ecriture en clair a la place. - Session.password_storage ("none" par defaut | "secure") : seulement le mode est stocke dans sessions.json, jamais le secret. Le mot de passe est ecrit/ lu dans le trousseau via une cle applicative (session:), et supprime automatiquement si l'utilisateur desactive la memorisation ou supprime la session. - Un bastion en mode "session PySSH" reutilise un mot de passe deja memorise pour cette session (lecture seule depuis ce chemin : l'ecriture ne se fait que via l'ouverture directe de la session concernee). tests/test_credentials.py : NullCredentialStore, aller-retour reel avec Windows Credential Manager (nettoye et verifie apres coup, aucune entree residuelle), integration ConnectionController (memorise puis reutilise sans redemander), suppression en cascade, reutilisation par un bastion, et verification que le secret n'apparait jamais dans la serialisation JSON de la session. Co-Authored-By: Claude Sonnet 5 --- app/bastion.py | 11 +- app/controllers/connection_controller.py | 21 ++- app/controllers/session_controller.py | 8 +- app/credentials.py | 128 ++++++++++++++++++ app/dialogs/session_dialog.py | 29 ++++ app/main_window.py | 6 +- app/session_manager.py | 10 ++ requirements.txt | 1 + tests/test_credentials.py | 160 +++++++++++++++++++++++ 9 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 app/credentials.py create mode 100644 tests/test_credentials.py diff --git a/app/bastion.py b/app/bastion.py index d0e560e..bc4d81b 100644 --- a/app/bastion.py +++ b/app/bastion.py @@ -14,6 +14,7 @@ from app.session_manager import ( BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL, AUTH_AUTO, AUTH_KEY, ) +from app.credentials import session_credential_key class BastionResolutionError(Exception): @@ -29,6 +30,11 @@ class BastionTarget: username: str auth_type: str key_path: str = "" + # Non vide seulement pour un bastion en mode "session PySSH" : permet de + # reutiliser un mot de passe deja memorise pour cette session (voir + # app.credentials), sans que ce module ait besoin de connaitre le + # CredentialStore lui-meme. + credential_key: str = "" @property def label(self) -> str: @@ -57,7 +63,10 @@ def resolve_bastion_target( raise BastionResolutionError( "La session utilisee comme bastion est introuvable (supprimee ou renommee)." ) - return BastionTarget(target.host, target.port, target.username, target.auth_type, target.key_path) + return BastionTarget( + target.host, target.port, target.username, target.auth_type, target.key_path, + credential_key=session_credential_key(target.id), + ) if bastion.mode == BASTION_OPENSSH: from app.openssh.config import load_openssh_hosts diff --git a/app/controllers/connection_controller.py b/app/controllers/connection_controller.py index 676f054..3684fd7 100644 --- a/app/controllers/connection_controller.py +++ b/app/controllers/connection_controller.py @@ -5,7 +5,7 @@ from __future__ import annotations from PySide6.QtCore import Qt, Signal, QObject from PySide6.QtWidgets import QTabWidget, QWidget, QMenu, QMessageBox, QInputDialog -from app.session_manager import SessionStore, Session, AUTH_PASSWORD +from app.session_manager import SessionStore, Session, AUTH_PASSWORD, PASSWORD_STORAGE_SECURE from app.key_manager import KeyManager from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS from app.dialogs.session_dialog import SessionDialog @@ -13,6 +13,7 @@ from app.dialogs.connect_prompt import ask_secret from app.services.ssh_target import detect_ssh_target from app.openssh.config import OpenSSHHost, session_from_openssh_host from app.bastion import BastionTarget, resolve_bastion_target, BastionResolutionError +from app.credentials import CredentialStore, NullCredentialStore, session_credential_key from app.controllers.broadcast_manager import BroadcastManager from app.ui.tabs import BaseTerminalTab, SessionTabWidget, SFTPTabWidget, LocalTerminalTabWidget from app.ui.split_pane import SplitContainer, PaneSlot, LAYOUT_LABELS @@ -31,6 +32,7 @@ class ConnectionController(QObject): key_manager: KeyManager, broadcast: BroadcastManager, snippet_store=None, + credential_store: CredentialStore | None = None, parent_widget: QWidget | None = None, ): super().__init__(parent_widget) @@ -38,6 +40,7 @@ class ConnectionController(QObject): self.key_manager = key_manager self.broadcast = broadcast self.snippet_store = snippet_store + self.credential_store = credential_store or NullCredentialStore() self._parent_widget = parent_widget self.tabs = QTabWidget(parent_widget) @@ -62,6 +65,13 @@ class ConnectionController(QObject): return True, None, "" if target.auth_type != AUTH_PASSWORD: return True, target, "" + # Un bastion "session PySSH" reutilise un mot de passe deja memorise pour + # cette session (s'il en existe un) : jamais ecrit depuis ce chemin, on ne + # fait que lire ce que l'ouverture directe de cette session aurait memorise. + if target.credential_key: + stored = self.credential_store.get_secret(target.credential_key) + if stored is not None: + return True, target, stored text, ok = ask_secret( self._parent_widget, "Mot de passe du bastion", f"Mot de passe pour {target.label} (bastion) :", ) @@ -72,9 +82,17 @@ class ConnectionController(QObject): def _ask_session_password(self, session: Session) -> tuple[bool, str]: if session.auth_type != AUTH_PASSWORD: return True, "" + remember = session.password_storage == PASSWORD_STORAGE_SECURE + key = session_credential_key(session.id) + if remember: + stored = self.credential_store.get_secret(key) + if stored is not None: + return True, stored text, ok = ask_secret( self._parent_widget, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :" ) + if ok and remember: + self.credential_store.set_secret(key, text) return ok, (text if ok else "") def build_session_tab(self, session: Session) -> SessionTabWidget | None: @@ -176,6 +194,7 @@ class ConnectionController(QObject): connect_label="Se connecter", title="Connexion SFTP" if sftp else "Connexion SSH", session_store=self.session_store, + credential_store=self.credential_store, ) if not dialog.exec(): return diff --git a/app/controllers/session_controller.py b/app/controllers/session_controller.py index fb15047..4289fe4 100644 --- a/app/controllers/session_controller.py +++ b/app/controllers/session_controller.py @@ -15,6 +15,7 @@ from PySide6.QtWidgets import ( from app.session_manager import SessionStore, Session from app.key_manager import KeyManager from app.groups import GroupSettingsStore +from app.credentials import CredentialStore, NullCredentialStore, session_credential_key from app.local_terminal import SHELL_LABELS from app.dialogs.session_dialog import SessionDialog from app.dialogs.group_settings_dialog import GroupSettingsDialog @@ -45,11 +46,13 @@ class SessionController(QObject): parent_widget: QWidget | None = None, openssh_config_path=None, group_store: GroupSettingsStore | None = None, + credential_store: CredentialStore | None = None, ): super().__init__(parent_widget) self.session_store = session_store self.key_manager = key_manager self.group_store = group_store + self.credential_store = credential_store or NullCredentialStore() self._parent_widget = parent_widget self._openssh_config_path = openssh_config_path or default_config_path() @@ -322,6 +325,7 @@ class SessionController(QObject): dialog = SessionDialog( self.key_manager, session=prefill, parent=self._parent_widget, session_store=self.session_store, group_store=self.group_store, + credential_store=self.credential_store, ) if dialog.exec(): self.session_store.add(dialog.result_session()) @@ -331,6 +335,7 @@ class SessionController(QObject): dialog = SessionDialog( self.key_manager, session=session, parent=self._parent_widget, session_store=self.session_store, group_store=self.group_store, + credential_store=self.credential_store, ) if dialog.exec(): self.session_store.update(dialog.result_session()) @@ -348,6 +353,7 @@ class SessionController(QObject): self._parent_widget, "Supprimer", f"Supprimer la session '{session.name}' ?" ) == QMessageBox.Yes: self.session_store.delete(session.id) + self.credential_store.delete_secret(session_credential_key(session.id)) self.reload() # ---------- OpenSSH config ---------- @@ -357,7 +363,7 @@ class SessionController(QObject): dialog = SessionDialog( self.key_manager, session=session_from_openssh_host(host), parent=self._parent_widget, title=f"Importer « {host.alias} » comme session", session_store=self.session_store, - group_store=self.group_store, + group_store=self.group_store, credential_store=self.credential_store, ) if dialog.exec(): self.session_store.add(dialog.result_session()) diff --git a/app/credentials.py b/app/credentials.py new file mode 100644 index 0000000..7299475 --- /dev/null +++ b/app/credentials.py @@ -0,0 +1,128 @@ +"""Stockage securise optionnel des secrets (mots de passe de session, cles API +de l'assistant IA...). + +Par defaut, PySSH ne memorise aucun secret : ils sont redemandes a chaque +usage. CredentialStore permet d'activer, au cas par cas, une memorisation via +le trousseau du systeme - jamais en clair dans un fichier JSON, jamais logue. + +Sous Windows : Windows Credential Manager, via les fonctions CredWrite/ +CredRead/CredDelete exposees par pywin32-ctypes (deja une dependance +existante de l'application - inutile d'ajouter pywin32 ou keyring rien que +pour ceci). Le secret est protege par DPAPI au niveau du systeme +d'exploitation ; PySSH ne manipule jamais la cle de chiffrement elle-meme. + +Ailleurs (Linux/macOS) : aucun trousseau integre equivalent sans dependance +supplementaire pour l'instant. NullCredentialStore signale simplement +l'indisponibilite plutot que d'ecrire un secret en clair, pour ne jamais +donner une fausse impression de securite. C'est le point d'extension prevu +pour un futur backend (ex: Secret Service / Keychain). +""" +from __future__ import annotations + +import abc +import os + +_TARGET_PREFIX = "PySSHClient/" + + +def session_credential_key(session_id: str) -> str: + return f"session:{session_id}" + + +class CredentialStore(abc.ABC): + """Une `key` est un identifiant applicatif (ex: "session:", + "ai:api_key") : jamais le secret lui-meme.""" + + @abc.abstractmethod + def is_available(self) -> bool: + """False si aucun trousseau securise n'est utilisable sur ce poste.""" + + @abc.abstractmethod + def set_secret(self, key: str, secret: str) -> bool: + """Memorise `secret`. Renvoie False en cas d'echec (jamais d'exception).""" + + @abc.abstractmethod + def get_secret(self, key: str) -> str | None: + """None si absent ou si le trousseau est indisponible.""" + + @abc.abstractmethod + def delete_secret(self, key: str) -> None: + """Ne leve jamais si la cle est deja absente.""" + + +class NullCredentialStore(CredentialStore): + """Repli quand aucun trousseau securise n'est disponible.""" + + def is_available(self) -> bool: + return False + + def set_secret(self, key: str, secret: str) -> bool: + return False + + def get_secret(self, key: str) -> str | None: + return None + + def delete_secret(self, key: str) -> None: + pass + + +class WindowsCredentialStore(CredentialStore): + """Windows Credential Manager (CRED_TYPE_GENERIC).""" + + def __init__(self): + try: + import win32ctypes.pywin32.win32cred as win32cred + self._win32cred = win32cred + except ImportError: + self._win32cred = None + + def is_available(self) -> bool: + return self._win32cred is not None + + def _target(self, key: str) -> str: + return _TARGET_PREFIX + key + + def set_secret(self, key: str, secret: str) -> bool: + if self._win32cred is None: + return False + try: + self._win32cred.CredWrite({ + "Type": self._win32cred.CRED_TYPE_GENERIC, + "TargetName": self._target(key), + "UserName": os.environ.get("USERNAME", "pyssh"), + "CredentialBlob": secret, + "Comment": "PySSH Client", + "Persist": self._win32cred.CRED_PERSIST_LOCAL_MACHINE, + }, 0) + return True + except Exception: # noqa: BLE001 - jamais bloquant : au pire, on redemandera le secret + return False + + def get_secret(self, key: str) -> str | None: + if self._win32cred is None: + return None + try: + cred = self._win32cred.CredRead(self._target(key), self._win32cred.CRED_TYPE_GENERIC, 0) + except Exception: # noqa: BLE001 - absent ou illisible : redemander est le repli correct + return None + blob = cred.get("CredentialBlob") or b"" + try: + return blob.decode("utf-16-le") + except UnicodeDecodeError: + return None + + def delete_secret(self, key: str) -> None: + if self._win32cred is None: + return + try: + self._win32cred.CredDelete(self._target(key), self._win32cred.CRED_TYPE_GENERIC, 0) + except Exception: # noqa: BLE001 - deja absent, ou trousseau indisponible : rien a faire + pass + + +def default_credential_store() -> CredentialStore: + if os.name == "nt": + store = WindowsCredentialStore() + if store.is_available(): + return store + return NullCredentialStore() diff --git a/app/dialogs/session_dialog.py b/app/dialogs/session_dialog.py index 6ceb896..36aef25 100644 --- a/app/dialogs/session_dialog.py +++ b/app/dialogs/session_dialog.py @@ -11,10 +11,12 @@ from PySide6.QtWidgets import ( from app.session_manager import ( Session, SessionStore, BastionConfig, AUTH_AUTO, AUTH_KEY, AUTH_PASSWORD, AUTH_AGENT, BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL, + PASSWORD_STORAGE_NONE, PASSWORD_STORAGE_SECURE, ) from app.key_manager import KeyManager from app.openssh.config import load_openssh_hosts from app.groups import GroupSettingsStore +from app.credentials import CredentialStore, NullCredentialStore, session_credential_key class SessionDialog(QDialog): @@ -25,11 +27,13 @@ class SessionDialog(QDialog): self, key_manager: KeyManager, session: Session | None = None, parent=None, connect_label: str = "", title: str = "", session_store: SessionStore | None = None, group_store: GroupSettingsStore | None = None, + credential_store: CredentialStore | None = None, ): super().__init__(parent) self.key_manager = key_manager self.session_store = session_store self.group_store = group_store + self.credential_store = credential_store or NullCredentialStore() self.session = session or Session() self.quick_mode = bool(connect_label) self.setWindowTitle(title or ("Nouvelle session" if session is None else "Modifier la session")) @@ -94,13 +98,30 @@ class SessionDialog(QDialog): form.addRow("Nom :", self.name_edit) form.addRow("Dossier :", group_row) form.addRow("Tags :", self.tags_edit) + self.remember_password_check = QCheckBox("Memoriser le mot de passe de maniere securisee") + self.remember_password_check.setChecked(self.session.password_storage == PASSWORD_STORAGE_SECURE) + if self.credential_store.is_available(): + self.remember_password_check.setToolTip( + "Stocke dans le trousseau securise du systeme (Windows Credential Manager), " + "jamais en clair dans les fichiers de PySSH." + ) + else: + self.remember_password_check.setEnabled(False) + self.remember_password_check.setChecked(False) + self.remember_password_check.setToolTip( + "Aucun trousseau securise disponible sur ce systeme : le mot de passe sera " + "toujours redemande a la connexion." + ) + form.addRow("Hote :", self.host_edit) form.addRow("Port :", self.port_spin) form.addRow("Utilisateur :", self.user_edit) form.addRow("Authentification :", self.auth_combo) form.addRow("Cle privee :", key_row) + form.addRow("", self.remember_password_check) form.addRow("Notes :", self.notes_edit) + self.form = form layout.addLayout(form) layout.addWidget(self._build_bastion_group()) layout.addWidget(self._build_keepalive_group()) @@ -141,6 +162,7 @@ class SessionDialog(QDialog): is_key_auth = self.auth_combo.currentData() == AUTH_KEY self.key_combo.setEnabled(is_key_auth) self.browse_key_btn.setEnabled(is_key_auth) + self.form.setRowVisible(self.remember_password_check, self.auth_combo.currentData() == AUTH_PASSWORD) def _browse_key(self) -> None: path, _ = QFileDialog.getOpenFileName(self, "Choisir une cle privee") @@ -439,6 +461,13 @@ class SessionDialog(QDialog): seen.add(tag.lower()) tags.append(tag) self.session.tags = tags + remember = self.auth_combo.currentData() == AUTH_PASSWORD and self.remember_password_check.isChecked() + new_storage = PASSWORD_STORAGE_SECURE if remember else PASSWORD_STORAGE_NONE + if self.session.password_storage == PASSWORD_STORAGE_SECURE and new_storage == PASSWORD_STORAGE_NONE: + # la memorisation vient d'etre desactivee (ou l'authentification changee) : + # ne pas laisser un secret perimer dans le trousseau + self.credential_store.delete_secret(session_credential_key(self.session.id)) + self.session.password_storage = new_storage self.session.bastion = self._result_bastion() self.session.keepalive_enabled = self.keepalive_check.isChecked() self.session.keepalive_interval = self.keepalive_interval_spin.value() diff --git a/app/main_window.py b/app/main_window.py index e73f1f6..605ba4e 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -25,6 +25,7 @@ from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme from app.workspace import WorkspaceStore from app.snippets import SnippetStore from app.groups import GroupSettingsStore +from app.credentials import default_credential_store from app.controllers.broadcast_manager import BroadcastManager from app.controllers.session_controller import SessionController from app.controllers.connection_controller import ConnectionController @@ -46,15 +47,16 @@ class MainWindow(QMainWindow): self.workspace_store = WorkspaceStore() self.snippet_store = SnippetStore() self.group_store = GroupSettingsStore() + self.credential_store = default_credential_store() self.broadcast = BroadcastManager(self._iter_tabs, self) self.connection_controller = ConnectionController( self.session_store, self.key_manager, self.broadcast, - snippet_store=self.snippet_store, parent_widget=self, + snippet_store=self.snippet_store, credential_store=self.credential_store, parent_widget=self, ) self.session_controller = SessionController( self.session_store, self.key_manager, self.broadcast.button, parent_widget=self, - group_store=self.group_store, + group_store=self.group_store, credential_store=self.credential_store, ) self.workspace_manager = WorkspaceManager( self.workspace_store, self.session_store, self.connection_controller, parent_widget=self, diff --git a/app/session_manager.py b/app/session_manager.py index d503c72..bb3d642 100644 --- a/app/session_manager.py +++ b/app/session_manager.py @@ -16,6 +16,11 @@ AUTH_KEY = "key" # cle privee explicitement choisie AUTH_PASSWORD = "password" AUTH_AGENT = "agent" +# Memorisation du mot de passe : jamais dans sessions.json (voir app.credentials), +# uniquement dans le trousseau securise du systeme, et seulement si demande. +PASSWORD_STORAGE_NONE = "none" +PASSWORD_STORAGE_SECURE = "secure" + # Sources possibles d'un jump host (ProxyJump / bastion) - voir BastionConfig. BASTION_NONE = "none" # connexion directe, sans bastion BASTION_SESSION = "session" # reutilise une session PySSH existante (par id) @@ -81,6 +86,11 @@ class Session: tags: list[str] = field(default_factory=list) # ex: ["prod", "docker"] + # "none" (defaut) : jamais memorise, redemande a chaque connexion. + # "secure" : memorise dans le trousseau securise du systeme (voir app.credentials) - + # jamais dans ce fichier JSON. + password_storage: str = PASSWORD_STORAGE_NONE + def to_dict(self) -> dict: return asdict(self) diff --git a/requirements.txt b/requirements.txt index 724028b..eeaf0a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ paramiko>=3.4 pyte>=0.8.2 cryptography>=42.0 pywinpty>=2.0 +pywin32-ctypes>=0.2.2 ; sys_platform == "win32" diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..0ca84f5 --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,160 @@ +"""Credential Vault : abstraction CredentialStore, backend Windows Credential +Manager reel (nettoye apres coup), et integration avec les flux de mot de +passe (ConnectionController, bastion, suppression de session). + +SSHConnection.start() est neutralisee pour les tests d'integration qui +construisent un onglet reel (aucune connexion reseau necessaire).""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +from PySide6.QtWidgets import QApplication, QWidget, QMessageBox + +from app.credentials import ( + CredentialStore, NullCredentialStore, WindowsCredentialStore, session_credential_key, + default_credential_store, +) +from app.session_manager import Session, SessionStore, AUTH_PASSWORD, PASSWORD_STORAGE_SECURE +from app.ssh_connection import SSHConnection +from app.controllers.broadcast_manager import BroadcastManager +from app.controllers.connection_controller import ConnectionController +from app.controllers.session_controller import SessionController +from app.bastion import BastionTarget + +app = QApplication.instance() or QApplication([]) + +tmp = Path(tempfile.mkdtemp()) +no_ssh_thread = patch.object(SSHConnection, "start", lambda self: None) +no_ssh_thread.start() + + +class FakeCredentialStore(CredentialStore): + """Trousseau en memoire : deterministe, sans dependre du vrai Windows + Credential Manager pour les tests qui ne visent pas specifiquement a le + verifier lui-meme.""" + + def __init__(self): + self._data: dict[str, str] = {} + + def is_available(self) -> bool: + return True + + def set_secret(self, key: str, secret: str) -> bool: + self._data[key] = secret + return True + + def get_secret(self, key: str): + return self._data.get(key) + + def delete_secret(self, key: str) -> None: + self._data.pop(key, None) + + +print("=== 1. NullCredentialStore : jamais disponible, jamais memorise ===") +null_store = NullCredentialStore() +assert not null_store.is_available() +assert null_store.set_secret("x", "y") is False +assert null_store.get_secret("x") is None +null_store.delete_secret("x") # ne doit pas lever +print(" OK") + +print() +print("=== 2. WindowsCredentialStore : aller-retour reel via Windows Credential Manager ===") +win_store = WindowsCredentialStore() +if win_store.is_available(): + test_key = "test-vault-phase11" + win_store.delete_secret(test_key) # nettoyage prealable si un run precedent a echoue + assert win_store.get_secret(test_key) is None + ok = win_store.set_secret(test_key, "s3cret-valeur-de-test") + assert ok + assert win_store.get_secret(test_key) == "s3cret-valeur-de-test" + win_store.delete_secret(test_key) + assert win_store.get_secret(test_key) is None + print(" ecriture/lecture/suppression reelles : OK") +else: + print(" Windows Credential Manager indisponible sur ce poste : test saute") +print(" OK") + +print() +print("=== 3. default_credential_store() ne leve jamais, meme si indisponible ===") +store = default_credential_store() +assert isinstance(store, CredentialStore) +print(" OK") + +print() +print("=== 4. Session.to_dict() ne contient jamais le secret, seulement le mode ===") +session = Session(name="prod", host="10.0.0.1", username="root", auth_type=AUTH_PASSWORD, + password_storage=PASSWORD_STORAGE_SECURE) +serialized = json.dumps(session.to_dict()) +print(" cle presente:", "password_storage" in serialized, "| secret absent: 's3cret' not in json") +assert "password_storage" in serialized +assert "s3cret" not in serialized +assert "password" not in session.to_dict() or True # aucun champ "password" n'existe sur Session +assert not hasattr(session, "password") +print(" OK") + +print() +print("=== 5. ConnectionController : memorise puis reutilise le mot de passe ===") +session_store = SessionStore(path=tmp / "sessions.json") +key_manager_broadcast_widget = QWidget() +broadcast = BroadcastManager(lambda: [key_manager_broadcast_widget]) +from app.key_manager import KeyManager +key_manager = KeyManager(registry_path=tmp / "keys.json") +fake_store = FakeCredentialStore() +cc = ConnectionController(session_store, key_manager, broadcast, credential_store=fake_store, + parent_widget=key_manager_broadcast_widget) + +remembered = Session(name="r", host="10.0.0.2", username="root", auth_type=AUTH_PASSWORD, + password_storage=PASSWORD_STORAGE_SECURE) +session_store.add(remembered) + +with patch("app.controllers.connection_controller.ask_secret", return_value=("motdepasse123", True)) as asked: + ok, secret = cc._ask_session_password(remembered) + assert ok and secret == "motdepasse123" + assert asked.called, "premiere fois : doit demander" +print(" premiere connexion -> mot de passe demande puis memorise") + +assert fake_store.get_secret(session_credential_key(remembered.id)) == "motdepasse123" + +with patch("app.controllers.connection_controller.ask_secret") as asked2: + ok2, secret2 = cc._ask_session_password(remembered) + assert ok2 and secret2 == "motdepasse123" + assert not asked2.called, "deuxieme fois : ne doit pas redemander" +print(" deuxieme connexion -> mot de passe reutilise sans redemander : OK") + +print() +print("=== 6. Suppression de session : le secret memorise est aussi supprime ===") +sc = SessionController(session_store, key_manager, broadcast.button, + parent_widget=key_manager_broadcast_widget, credential_store=fake_store, + openssh_config_path=tmp / "no-ssh-config") +with patch("app.controllers.session_controller.QMessageBox.question", return_value=QMessageBox.Yes): + sc.delete_session(remembered) +assert fake_store.get_secret(session_credential_key(remembered.id)) is None +print(" OK") + +print() +print("=== 7. Bastion en mode session reutilise le mot de passe deja memorise ===") +bastion_session = Session(name="bastion", host="10.0.0.3", username="jump", auth_type=AUTH_PASSWORD, + password_storage=PASSWORD_STORAGE_SECURE) +session_store.add(bastion_session) +fake_store.set_secret(session_credential_key(bastion_session.id), "bastion-secret") +target = BastionTarget( + host=bastion_session.host, port=22, username=bastion_session.username, auth_type=AUTH_PASSWORD, + credential_key=session_credential_key(bastion_session.id), +) +with patch("app.controllers.connection_controller.ask_secret") as asked3: + session2 = Session(name="cible", host="10.0.0.4", username="root") + with patch("app.controllers.connection_controller.resolve_bastion_target", return_value=target): + ok3, resolved_target, password3 = cc._resolve_bastion(session2) + assert ok3 and password3 == "bastion-secret" + assert not asked3.called, "le mot de passe du bastion doit etre reutilise, pas redemande" +print(" OK") + +no_ssh_thread.stop() + +print() +print("CREDENTIAL VAULT : TOUS LES TESTS OK")