feat: proteger les cles privees via ACL NTFS (icacls) sous Windows

chmod(0o600) n'a aucun effet sur les permissions NTFS reelles : sous Windows,
une cle privee generee restait accessible a tout compte ayant acces au
dossier (permissions heritees usuelles : Users/Authenticated Users).

app/security/key_protection.py : protect_private_key() verrouille l'ACL du
fichier via icacls (/inheritance:r puis /grant:r <utilisateur>:F), plutot que
d'ajouter une dependance pywin32 - plus lourde a empaqueter avec PyInstaller
pour un simple changement de permissions. Repli chmod(0o600) sous Linux/macOS.
Echec silencieux (icacls absent, systeme de fichiers sans ACL...), comme le
faisait deja l'appel chmod qu'elle remplace.

Branchee dans KeyManager.generate_key() (cf commit suivant). Non appliquee a
import_key() : une cle importee pointe potentiellement vers un chemin hors du
controle de l'application (~/.ssh partage avec d'autres outils), et la
consigne est de ne jamais rendre une cle moins accessible qu'elle ne l'etait -
ni, par prudence, de modifier sans le demander des permissions definies par
l'utilisateur lui-meme.

Verifie manuellement avec icacls apres generation reelle : seul le compte
Windows courant apparait dans l'ACL du fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Florian
2026-09-05 01:43:12 +02:00
parent 23f5aaccd1
commit d212e6ab44
3 changed files with 146 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
"""Protection des cles privees : verifie la sequence d'appels icacls sous Windows
(sans dependre des ACL reelles du systeme de fichiers, pour rester fiable quelle
que soit la machine qui execute le test) et le bon aiguillage vers le repli chmod
ailleurs que sous Windows."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import os
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import patch
from app.security.key_protection import protect_private_key, _protect_windows
tmp = Path(tempfile.mkdtemp())
key_file = tmp / "id_test"
key_file.write_text("contenu-cle-privee", encoding="utf-8")
print("=== 1. Sous Windows : /inheritance:r puis /grant:r pour l'utilisateur courant ===")
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0)
with patch.dict(os.environ, {"USERDOMAIN": "MACHINE", "USERNAME": "florian"}):
with patch("subprocess.run", side_effect=fake_run):
_protect_windows(key_file)
print(" commandes executees:")
for c in calls:
print(" ", c)
assert len(calls) == 2, calls
assert calls[0] == ["icacls", str(key_file), "/inheritance:r"], calls[0]
assert calls[1] == ["icacls", str(key_file), "/grant:r", "MACHINE\\florian:F"], calls[1]
print(" OK")
print()
print("=== 2. Sans domaine (poste hors domaine) : compte = USERNAME seul ===")
calls.clear()
with patch.dict(os.environ, {"USERDOMAIN": "", "USERNAME": "florian"}):
with patch("subprocess.run", side_effect=fake_run):
_protect_windows(key_file)
assert calls[1][-1] == "florian:F", calls[1]
print(" OK")
print()
print("=== 3. Un echec icacls ne remonte jamais (best effort, comme l'ancien chmod) ===")
with patch("subprocess.run", side_effect=OSError("icacls introuvable")):
_protect_windows(key_file) # ne doit lever aucune exception
print(" aucune exception propagee : OK")
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "icacls")):
_protect_windows(key_file)
print(" code de sortie non nul : aucune exception propagee non plus : OK")
print()
print("=== 4. protect_private_key() aiguille vers icacls sous Windows ===")
with patch("os.name", "nt"), patch("app.security.key_protection._protect_windows") as windows_impl:
protect_private_key(key_file)
assert windows_impl.called
print(" os.name == 'nt' -> _protect_windows() appele : OK")
print()
print("=== 5. protect_private_key() retombe sur chmod(0o600) hors Windows ===")
with patch("os.name", "posix"), patch("pathlib.Path.chmod") as chmod_mock:
protect_private_key(key_file)
chmod_mock.assert_called_once_with(0o600)
print(" os.name != 'nt' -> chmod(0o600) appele : OK")
print()
print("=== 6. Ne rend jamais la cle MOINS protegee : jamais d'octroi a un groupe large ===")
all_granted_users = set()
with patch.dict(os.environ, {"USERDOMAIN": "MACHINE", "USERNAME": "florian"}):
with patch("subprocess.run", side_effect=fake_run):
calls.clear()
_protect_windows(key_file)
for cmd in calls:
if "/grant:r" in cmd:
all_granted_users.add(cmd[-1].split(":")[0])
print(" comptes ayant reçu un acces:", all_granted_users)
assert all_granted_users == {"MACHINE\\florian"}, all_granted_users
print(" OK")
print()
print("KEY_PROTECTION_WINDOWS_ACL : TOUS LES TESTS OK")