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:<id>), 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 <noreply@anthropic.com>
This commit is contained in:
Florian
2026-09-05 03:21:32 +02:00
parent b615ed52af
commit 9216f636bd
9 changed files with 369 additions and 5 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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())

128
app/credentials.py Normal file
View File

@@ -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:<id>",
"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()

View File

@@ -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()

View File

@@ -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,

View File

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

View File

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

160
tests/test_credentials.py Normal file
View File

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