feat(wp-c): génération token par poste à l'enroll (patch 2, inerte runtime)

Génère un token unique (secrets.token_hex(32)) à chaque (ré)enrôlement,
persiste uniquement son empreinte SHA-256 dans token_hash, renseigne
token_issued_at, retourne le clair une seule fois dans le résultat de
enroll. Le clair n'est jamais journalisé ni persisté.

Inerte au runtime : api_stream.py intouché, l'endpoint /agents/enroll ne
propage ni le clair ni le hash (api_token global inchangé). Auth runtime
non modifiée. Aucun branchement _verify_token. TDD : 8 tests + non-régression
WP-B/WP-C (47 verts). Voir PLAN-WPC-TDD-EXECUTABLE / DETTE-015.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-06-10 11:36:44 +02:00
parent f7f6926410
commit 9fb2c7bfee
2 changed files with 148 additions and 7 deletions

View File

@@ -28,13 +28,15 @@ Schema de la table `enrolled_agents` :
from __future__ import annotations from __future__ import annotations
import hashlib
import logging import logging
import os import os
import secrets
import sqlite3 import sqlite3
import threading import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,6 +50,19 @@ def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat() 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: def _fleet_enroll_locked() -> bool:
"""WP-B : parc verrouille -> aucun NOUVEAU machine_id ne peut s'enroler. """WP-B : parc verrouille -> aucun NOUVEAU machine_id ne peut s'enroler.
@@ -206,6 +221,8 @@ class AgentRegistry:
if not allow_reactivate: if not allow_reactivate:
raise AgentAlreadyEnrolledError(dict(existing)) raise AgentAlreadyEnrolledError(dict(existing))
# WP-C : rotation du token a chaque (re)enrolement.
token, token_hash = _new_token()
conn.execute( conn.execute(
""" """
UPDATE enrolled_agents UPDATE enrolled_agents
@@ -219,13 +236,17 @@ class AgentRegistry:
enrolled_at = ?, enrolled_at = ?,
last_seen_at = ?, last_seen_at = ?,
uninstalled_at = NULL, uninstalled_at = NULL,
uninstall_reason = NULL uninstall_reason = NULL,
token_hash = ?,
token_issued_at = ?
WHERE machine_id = ? WHERE machine_id = ?
""", """,
( (
user_name, user_email, user_id, user_name, user_email, user_id,
hostname, os_info, version, hostname, os_info, version,
now, now, machine_id, now, now,
token_hash, now,
machine_id,
), ),
) )
conn.commit() conn.commit()
@@ -233,23 +254,32 @@ class AgentRegistry:
"SELECT * FROM enrolled_agents WHERE machine_id = ?", "SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,), (machine_id,),
).fetchone() ).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 # Nouvelle inscription — WP-B : refusee si le parc est verrouille
if _fleet_enroll_locked(): if _fleet_enroll_locked():
raise FleetEnrollLockedError(machine_id) raise FleetEnrollLockedError(machine_id)
# WP-C : token poste genere a la creation.
token, token_hash = _new_token()
conn.execute( conn.execute(
""" """
INSERT INTO enrolled_agents ( INSERT INTO enrolled_agents (
machine_id, user_name, user_email, user_id, machine_id, user_name, user_email, user_id,
hostname, os_info, version, hostname, os_info, version,
status, enrolled_at, last_seen_at status, enrolled_at, last_seen_at,
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) token_hash, token_issued_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
""", """,
( (
machine_id, user_name, user_email, user_id, machine_id, user_name, user_email, user_id,
hostname, os_info, version, hostname, os_info, version,
now, now, now, now,
token_hash, now,
), ),
) )
conn.commit() conn.commit()
@@ -257,7 +287,12 @@ class AgentRegistry:
"SELECT * FROM enrolled_agents WHERE machine_id = ?", "SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,), (machine_id,),
).fetchone() ).fetchone()
return {"created": True, "reactivated": False, "agent": dict(row)} return {
"created": True,
"reactivated": False,
"agent": dict(row),
"token": token,
}
def uninstall( def uninstall(
self, self,

View File

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