diff --git a/agent_v0/server_v1/agent_registry.py b/agent_v0/server_v1/agent_registry.py index 6903845fa..37880ba7e 100644 --- a/agent_v0/server_v1/agent_registry.py +++ b/agent_v0/server_v1/agent_registry.py @@ -28,13 +28,15 @@ Schema de la table `enrolled_agents` : from __future__ import annotations +import hashlib import logging import os +import secrets import sqlite3 import threading from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) @@ -48,6 +50,19 @@ def _utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat() +def _new_token() -> Tuple[str, str]: + """WP-C : genere un token poste (clair) et son empreinte SHA-256. + + Le clair est retourne UNE seule fois a l'appelant (resultat de enroll) ; seul + le hash est persiste dans `token_hash`. Le clair n'est jamais journalise ni + stocke. L'auth runtime reste inchangee (aucun branchement ici sur la + verification de token cote api_stream). + """ + clear = secrets.token_hex(32) + token_hash = hashlib.sha256(clear.encode("utf-8")).hexdigest() + return clear, token_hash + + def _fleet_enroll_locked() -> bool: """WP-B : parc verrouille -> aucun NOUVEAU machine_id ne peut s'enroler. @@ -206,6 +221,8 @@ class AgentRegistry: if not allow_reactivate: raise AgentAlreadyEnrolledError(dict(existing)) + # WP-C : rotation du token a chaque (re)enrolement. + token, token_hash = _new_token() conn.execute( """ UPDATE enrolled_agents @@ -219,13 +236,17 @@ class AgentRegistry: enrolled_at = ?, last_seen_at = ?, uninstalled_at = NULL, - uninstall_reason = NULL + uninstall_reason = NULL, + token_hash = ?, + token_issued_at = ? WHERE machine_id = ? """, ( user_name, user_email, user_id, hostname, os_info, version, - now, now, machine_id, + now, now, + token_hash, now, + machine_id, ), ) conn.commit() @@ -233,23 +254,32 @@ class AgentRegistry: "SELECT * FROM enrolled_agents WHERE machine_id = ?", (machine_id,), ).fetchone() - return {"created": False, "reactivated": True, "agent": dict(row)} + return { + "created": False, + "reactivated": True, + "agent": dict(row), + "token": token, + } # Nouvelle inscription — WP-B : refusee si le parc est verrouille if _fleet_enroll_locked(): raise FleetEnrollLockedError(machine_id) + # WP-C : token poste genere a la creation. + token, token_hash = _new_token() conn.execute( """ INSERT INTO enrolled_agents ( machine_id, user_name, user_email, user_id, hostname, os_info, version, - status, enrolled_at, last_seen_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) + status, enrolled_at, last_seen_at, + token_hash, token_issued_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?) """, ( machine_id, user_name, user_email, user_id, hostname, os_info, version, now, now, + token_hash, now, ), ) conn.commit() @@ -257,7 +287,12 @@ class AgentRegistry: "SELECT * FROM enrolled_agents WHERE machine_id = ?", (machine_id,), ).fetchone() - return {"created": True, "reactivated": False, "agent": dict(row)} + return { + "created": True, + "reactivated": False, + "agent": dict(row), + "token": token, + } def uninstall( self, diff --git a/tests/unit/test_wpc_enroll_token.py b/tests/unit/test_wpc_enroll_token.py new file mode 100644 index 000000000..672987e3b --- /dev/null +++ b/tests/unit/test_wpc_enroll_token.py @@ -0,0 +1,106 @@ +"""WP-C Patch 2 — génération d'un token par poste à l'enrôlement (TDD). + +À chaque enrôlement (création OU réactivation), le registre génère un token +unique (`secrets.token_hex(32)`), persiste UNIQUEMENT son empreinte SHA-256 dans +`token_hash`, renseigne `token_issued_at`, et retourne le token clair une seule +fois dans le résultat de `enroll`. Le clair n'est jamais persisté ni journalisé. + +Auth runtime inchangée : aucun branchement sur `api_stream._verify_token` (ce +sera l'objet de patchs WP-C ultérieurs). Voir PLAN-WPC-TDD-EXECUTABLE / DETTE-015. +""" +from __future__ import annotations + +import hashlib +import logging +import sqlite3 + +import pytest + +from agent_v0.server_v1.agent_registry import AgentRegistry + + +@pytest.fixture +def registry(tmp_path): + return AgentRegistry(db_path=tmp_path / "fleet.db") + + +def _sha256(s: str) -> str: + return hashlib.sha256(s.encode("utf-8")).hexdigest() + + +def _persisted_row(db_path, machine_id): + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT * FROM enrolled_agents WHERE machine_id = ?", (machine_id,) + ).fetchone() + return dict(row) if row else None + finally: + conn.close() + + +def test_enroll_returns_clear_token(registry): + """L'enrôlement retourne un token clair (hex, 64 caractères).""" + res = registry.enroll(machine_id="PC-1") + token = res["token"] + assert isinstance(token, str) + assert len(token) == 64 + int(token, 16) # doit être hexadécimal valide + + +def test_token_unique_per_enroll(registry): + """Deux enrôlements distincts produisent deux tokens différents (critère 1).""" + t1 = registry.enroll(machine_id="PC-1")["token"] + t2 = registry.enroll(machine_id="PC-2")["token"] + assert t1 != t2 + + +def test_clear_token_not_persisted(registry, tmp_path): + """Le token clair n'est jamais persisté en base (critère 3).""" + token = registry.enroll(machine_id="PC-1")["token"] + row = _persisted_row(tmp_path / "fleet.db", "PC-1") + for value in row.values(): + assert value != token + + +def test_token_hash_persisted_as_sha256(registry, tmp_path): + """token_hash persisté = SHA-256 du clair (critère 4).""" + token = registry.enroll(machine_id="PC-1")["token"] + row = _persisted_row(tmp_path / "fleet.db", "PC-1") + assert row["token_hash"] == _sha256(token) + + +def test_token_issued_at_set(registry, tmp_path): + """token_issued_at est renseigné à l'enrôlement (critère 5).""" + registry.enroll(machine_id="PC-1") + row = _persisted_row(tmp_path / "fleet.db", "PC-1") + assert row["token_issued_at"] # non NULL / non vide + + +def test_get_never_exposes_clear_token(registry): + """get() ne renvoie jamais le token clair (retourné une seule fois — critère 2).""" + token = registry.enroll(machine_id="PC-1")["token"] + agent = registry.get("PC-1") + assert "token" not in agent # pas de clé clair persistée + for value in agent.values(): + assert value != token + + +def test_reactivation_rotates_token(registry, tmp_path): + """La réactivation génère un nouveau token + nouveau hash (critère 1).""" + t1 = registry.enroll(machine_id="PC-1")["token"] + h1 = _persisted_row(tmp_path / "fleet.db", "PC-1")["token_hash"] + registry.uninstall(machine_id="PC-1", reason="user_uninstall") + t2 = registry.enroll(machine_id="PC-1")["token"] + h2 = _persisted_row(tmp_path / "fleet.db", "PC-1")["token_hash"] + assert t2 != t1 + assert h2 != h1 + assert h2 == _sha256(t2) + + +def test_clear_token_never_logged(registry, caplog): + """Le token clair n'apparaît jamais dans les logs (critère 6).""" + with caplog.at_level(logging.DEBUG): + token = registry.enroll(machine_id="PC-1")["token"] + assert token not in caplog.text