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:
160
tests/test_credentials.py
Normal file
160
tests/test_credentials.py
Normal 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")
|
||||
Reference in New Issue
Block a user