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

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