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:
@@ -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,
|
||||
|
||||
106
tests/unit/test_wpc_enroll_token.py
Normal file
106
tests/unit/test_wpc_enroll_token.py
Normal 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
|
||||
Reference in New Issue
Block a user