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>
85 lines
2.8 KiB
Python
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()
|