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")