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