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

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