Files
GTI_GERENCIADOR_FISCAL/central/app/enrollment.py
T
gtiandClaude Sonnet 5 8d6122c096 feat: Agent distribution with secure enrollment from the control panel
One universal Windows Agent package per version (agent_releases: version/
platform/architecture/sha256/status DRAFT|PUBLISHED|REVOKED; the binary
lives on a local release-storage volume, never in PostgreSQL). An operator
creates a pending installation (agent_enrollments) from Agents / Servidores
-> + Nova instalação, then either downloads it directly or generates a
one-time HTTPS link (short TTL, single use). Either path streams the
package plus a freshly minted enrollment.json whose token is never written
to storage in plaintext, only its SHA-256 hash. On the client host,
gti-agent.ps1 install-auto verifies the bundle's SHA-256, refuses a non-
HTTPS Hub, enrolls via POST /v1/agents/enroll (deleting the enrollment
token immediately after, success or failure), writes an ACL-restricted
.env with the newly issued permanent Agent token, and registers the
Windows Service without starting it (Firebird stays unconfigured on
purpose). Idempotent, replay-proof, rate-limited per IP, fully audited,
never logs a secret.

Migration 0022_agent_releases_enrollment (verified fresh-DB ==
upgrade-from-0021 by pg_dump diff). GTI_FISCAL_VERSION/AGENT_VERSION
bumped to 1.1.0 as the single source of truth across code, packages, the
release manifest and every published AgentRelease row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:19:31 -03:00

85 lines
2.8 KiB
Python

"""Secrets and package bundling for Agent distribution.
Two independent secrets guard the distribution flow, both random(256 bits),
single-use and short-TTL, both stored only as SHA-256 hashes:
- the *download* token: embedded in the shareable link an operator hands to
whoever installs the Agent (or clicked directly, authenticated, from the
panel);
- the *enrollment* token: embedded inside the downloaded bundle as
`enrollment.json`, consumed exactly once by `POST /v1/agents/enroll`.
Neither token is ever the Agent's own permanent Bearer token (issued only
after a successful enrollment) and neither is ever logged or audited in
plaintext.
"""
from __future__ import annotations
import hashlib
import hmac
import io
import json
import re
import secrets
import zipfile
from dataclasses import dataclass
class EnrollmentRejected(Exception):
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason
def new_token() -> str:
"""256 bits of randomness, URL-safe — usable directly in a link or a JSON field."""
return secrets.token_urlsafe(32)
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def token_matches(token: str | None, stored_hash: str | None) -> bool:
if not token or not stored_hash:
return False
return hmac.compare_digest(token_digest(token), stored_hash)
ENROLLMENT_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
def valid_uuid(value: str | None) -> bool:
return bool(value) and bool(ENROLLMENT_UUID_RE.match(value))
@dataclass(frozen=True)
class BundlePlan:
hub_url: str
enrollment_uuid: str
enrollment_token: str
enroll_path: str = "/v1/agents/enroll"
def build_bundle(release_zip_bytes: bytes, plan: BundlePlan) -> bytes:
"""Return a new zip = the release zip's entries plus one
`<package-root>/enrollment.json` holding only temporary, single-use data.
Never a permanent credential — that is issued only once enrollment succeeds."""
with zipfile.ZipFile(io.BytesIO(release_zip_bytes)) as source:
names = source.namelist()
if not names:
raise EnrollmentRejected("EMPTY_RELEASE_PACKAGE")
root = names[0].split("/", 1)[0]
out = io.BytesIO()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dest:
for name in names:
dest.writestr(name, source.read(name))
payload = {
"installation_id": plan.enrollment_uuid,
"enrollment_token": plan.enrollment_token,
"hub_url": plan.hub_url,
"enroll_path": plan.enroll_path,
}
dest.writestr(f"{root}/enrollment.json", json.dumps(payload, indent=1))
return out.getvalue()