installer v1 (A+B): install-code enrollment, panel code UI, EXE publish; DPAPI credential store

This commit is contained in:
gti
2026-09-23 16:38:19 -03:00
parent b058affa29
commit 91c4030b06
15 changed files with 1135 additions and 54 deletions
+21 -1
View File
@@ -76,7 +76,11 @@ class Config:
def __init__(self, env):
self.hub_url = env.get("GTI_HUB_URL") or env.get("HUB_URL") or "http://localhost:8000"
self.agent_id = env.get("AGENT_ID", "")
self.agent_token = env.get("AGENT_TOKEN") or env.get("AGENT_DEV_TOKEN", "")
self.credential_file = env.get("GTI_CREDENTIAL_FILE") or str(Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "GTI" / "FiscalAgent" / "credentials.bin")
# The permanent token NEVER lives in agent.env: it is decrypted from
# credentials.bin (DPAPI LocalMachine + SYSTEM/Administrators ACL). An
# explicit AGENT_TOKEN/AGENT_DEV_TOKEN in env wins (dev override only).
self.agent_token = self._resolve_agent_token(env)
self.company_id = env.get("COMPANY_ID", "")
self.company_uf = env.get("COMPANY_UF", "")
self.database_path = env["FIREBIRD_DATABASE_PATH"]
@@ -98,6 +102,22 @@ class Config:
self.expected_schema_fingerprint = env.get("EXPECTED_SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
self.log_path = env.get("AGENT_LOG_PATH", str(AGENT_ROOT / "logs" / "agent.log"))
@staticmethod
def _resolve_agent_token(env):
"""DPAPI credential from credentials.bin; explicit AGENT_TOKEN env wins
(dev override). Fail closed: never log or fabricate a token."""
from gti_fiscal_agent.credential_store import install_env_from_credentials
explicit = env.get("AGENT_TOKEN") or env.get("AGENT_DEV_TOKEN") or ""
if explicit:
return explicit
try:
return install_env_from_credentials(env.get("GTI_CREDENTIAL_FILE") or str(
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "GTI" / "FiscalAgent" / "credentials.bin"), env)
except Exception as exc: # noqa: BLE001 — fail closed: no token, no heartbeat
logger.warning("no usable agent credential: %s", exc)
return ""
class GracefulStop:
def __init__(self):
@@ -0,0 +1,137 @@
"""Windows DPAPI credential store for the GTI Fiscal Agent.
The permanent Agent credential (AGENT_TOKEN) must NEVER live in `agent.env`
(which is world-readable config). It is stored in a dedicated
`credentials.bin` file, encrypted with DPAPI in LocalMachine scope
(`CRYPTPROTECT_LOCAL_MACHINE`), so any local Windows account on the host can
decrypt it — and the file ACL is tightened to SYSTEM/Administrators only via
`icacls`. The token is decrypted in-process at runtime by the service host and
never logged.
`write_credential`/`read_credential` are shared by Setup.exe (writes at
install time) and the Service host (reads at every start).
Fallback: a plaintext token file is accepted ONLY in development, guarded by
`GTI_ALLOW_PLAINTEXT_CREDENTIAL=1`. Production Setup.exe never enables it.
"""
import os
import subprocess
import sys
from pathlib import Path
_PROGRAM_DIR = Path("C:/Program Files/GTI/FiscalAgent")
_MAGIC = b"GTIDPAPI1"
_DEFAULT_FLAGS = 0x1 | 0x4 # CRYPTPROTECT_UI_FORBIDDEN | CRYPTPROTECT_LOCAL_MACHINE
class CredentialStoreError(Exception):
pass
class _DpapiLazy:
"""Lazy wrapper around win32crypt so importing this module (and the whole
agent package) works on non-Windows hosts without pywin32. Functions accept
the same signatures pywin32 exposes on this machine's build, tolerating both
the tuple and the bytes return shapes seen across pywin32 versions."""
def __init__(self):
self._crypt = None
def _api(self):
if self._crypt is None:
try:
import win32crypt
except Exception as exc: # noqa: BLE001
raise CredentialStoreError(f"win32crypt unavailable: {exc}") from exc
self._crypt = win32crypt
return self._crypt
def protect(self, data):
crypt = self._api()
returned = crypt.CryptProtectData(data, "gti-fiscal-agent-credential", None, None, None, _DEFAULT_FLAGS)
if isinstance(returned, tuple):
returned, _ = returned
return bytes(returned)
def unprotect(self, blob):
crypt = self._api()
returned = crypt.CryptUnprotectData(bytes(blob), None, None, None, 0)
if isinstance(returned, tuple):
_, returned = returned
return bytes(returned)
_dpapi = _DpapiLazy()
def _dpapi_available() -> bool:
try:
_dpapi._api()
return True
except CredentialStoreError:
return False
def default_credential_path() -> Path:
return _PROGRAM_DIR / "credentials.bin"
def _apply_acl(path: Path) -> None:
"""Tighten ACL so only SYSTEM and Administrators can read/modify the blob.
Well-known SIDs are used (`*S-1-5-18` = SYSTEM, `*S-1-5-32-544` = BUILTIN\
Administrators) because account-name mapping is locale-dependent and fails
on non-English Windows. Setup.exe runs elevated (`--uac-admin`), so icacls
succeeds (the owner — the installing administrator — can change the DACL)."""
cmd = ["icacls", str(path), "/inheritance:r", "/grant:r", "*S-1-5-18:(F)", "*S-1-5-32-544:(F)"]
try:
result = subprocess.run(cmd, capture_output=True, text=True)
except FileNotFoundError as exc: # non-Windows host without icacls
raise CredentialStoreError(f"icacls unavailable: {exc}") from exc
if result.returncode != 0:
raise CredentialStoreError(f"icacls failed: {result.stdout} {result.stderr}")
def write_credential(path, payload: str) -> None:
"""Encrypt `payload` with DPAPI LocalMachine and write `path` with a
SYSTEM/Administrators-only ACL. Fails closed: no plaintext file is ever
created on this path."""
if isinstance(payload, str):
payload = payload.encode("utf-8")
if not _dpapi_available():
raise CredentialStoreError("DPAPI unavailable; refusing to store the credential as plaintext (fail closed)")
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
blob = _MAGIC + _dpapi.protect(payload)
path.write_bytes(blob)
try:
_apply_acl(path)
except CredentialStoreError:
path.unlink(missing_ok=True) # do not leave a credential file without its ACL
raise
def read_credential(path, *, env=None) -> str:
"""Decrypt `path`. A plaintext file is honored ONLY when
`GTI_ALLOW_PLAINTEXT_CREDENTIAL=1` is set in `env`/environment (development)."""
env = env if env is not None else os.environ
path = Path(path)
if not path.exists():
raise CredentialStoreError(f"credential file missing: {path}")
raw = path.read_bytes()
if raw.startswith(_MAGIC):
return _dpapi.unprotect(raw[len(_MAGIC):]).decode("utf-8")
if (env.get("GTI_ALLOW_PLAINTEXT_CREDENTIAL") or "").strip() != "1":
raise CredentialStoreError("credential file is plaintext and GTI_ALLOW_PLAINTEXT_CREDENTIAL is not set; refusing (fail closed)")
return raw.decode("utf-8").strip()
def install_env_from_credentials(path, env) -> str:
"""Fill `env["AGENT_TOKEN"]` from `credentials.bin` unless the env already
carries an explicit token (which wins). Returns the effective token —
WITHOUT writing it into `agent.env` — and never logs it."""
existing = env.get("AGENT_TOKEN") or env.get("AGENT_DEV_TOKEN") or ""
if existing:
return existing
token = read_credential(path, env=env)
env["AGENT_TOKEN"] = token
return token
+179
View File
@@ -0,0 +1,179 @@
"""DPAPI LocalMachine credential store: encrypt/decrypt round-trip, plaintext
fallback is dev-only, ACL is SYSTEM/Administrators, and the token is never
written into agent.env."""
import importlib.util
import os
import re
import stat
import subprocess
import sys
from pathlib import Path
import pytest
from gti_fiscal_agent import credential_store
import win32api
AGENT_ROOT = Path(__file__).resolve().parents[1]
RUN_SCRIPT = AGENT_ROOT / "scripts" / "run_agent_service.py"
def load_run_service():
spec = importlib.util.spec_from_file_location("run_agent_service_cred", RUN_SCRIPT)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def write_plaintext(path: Path, value: str = "TOKEN_PLAINTEXT_DEV"):
path.write_text(value, encoding="utf-8")
# ---------- DPAPI round-trip ----------
def _allow_current_user(path: Path) -> None:
"""The strict SYSTEM/Administrators DACL blocks the pytest user (not an
admin here). As the file OWNER the current user may grant itself read —
exactly like the elevated installer/local administrator does on the real
host before the credential is consumed by the SYSTEM service."""
import subprocess
import win32security
acct_sid, _domain, _type = win32security.LookupAccountName(None, win32api.GetUserName())
sid_str = win32security.ConvertSidToStringSid(acct_sid)
subprocess.run(["icacls", str(path), "/grant", f"*{sid_str}:(R)"], check=True, capture_output=True)
def test_write_then_read_roundtrip(tmp_path):
path = tmp_path / "credentials.bin"
credential_store.write_credential(path, "TOKEN_DPAPI_1")
_allow_current_user(path)
assert credential_store.read_credential(path) == "TOKEN_DPAPI_1"
def test_stored_blob_never_contains_the_plaintext_secret(tmp_path):
path = tmp_path / "credentials.bin"
credential_store.write_credential(path, "TOKEN_VERY_SECRET_9")
_allow_current_user(path)
raw = path.read_bytes()
assert b"TOKEN_VERY_SECRET_9" not in raw
def test_read_missing_file_raises(tmp_path):
with pytest.raises(credential_store.CredentialStoreError):
credential_store.read_credential(tmp_path / "nope.bin")
def test_write_detects_dpapi_unavailable_and_fails_closed(tmp_path, monkeypatch):
monkeypatch.setattr(credential_store, "_dpapi_available", lambda: False)
with pytest.raises(credential_store.CredentialStoreError):
credential_store.write_credential(tmp_path / "credentials.bin", "never")
# ---------- plaintext fallback (dev-only) ----------
def test_plaintext_rejected_by_default(tmp_path):
path = tmp_path / "credentials.bin"
write_plaintext(path)
with pytest.raises(credential_store.CredentialStoreError):
credential_store.read_credential(path)
def test_plaintext_accepted_only_with_dev_flag(tmp_path, monkeypatch):
path = tmp_path / "credentials.bin"
write_plaintext(path, "TOKEN_DEV_PLAIN")
assert credential_store.read_credential(path, env={"GTI_ALLOW_PLAINTEXT_CREDENTIAL": "1"}) == "TOKEN_DEV_PLAIN"
monkeypatch.setenv("GTI_ALLOW_PLAINTEXT_CREDENTIAL", "1")
assert credential_store.read_credential(path) == "TOKEN_DEV_PLAIN"
monkeypatch.delenv("GTI_ALLOW_PLAINTEXT_CREDENTIAL", raising=False)
with pytest.raises(credential_store.CredentialStoreError):
credential_store.read_credential(path)
# ---------- ACL (SYSTEM/Administrators only) ----------
def test_write_sets_system_and_administrators_acl(tmp_path):
import win32security
path = tmp_path / "credentials.bin"
credential_store.write_credential(path, "TOKEN_ACL_7")
admin_sid = win32security.ConvertStringSidToSid("S-1-5-32-544")
system_sid = win32security.ConvertStringSidToSid("S-1-5-18")
sd = win32security.GetFileSecurity(str(path), win32security.DACL_SECURITY_INFORMATION)
dacl = sd.GetSecurityDescriptorDacl()
assert dacl.GetAceCount() == 2
allow_trustees = []
for i in range(dacl.GetAceCount()):
ace = dacl.GetAce(i)
assert ace[0][0] == win32security.ACCESS_ALLOWED_ACE_TYPE, ace # only allows, no deny
allow_trustees.append(win32security.ConvertSidToStringSid(ace[2]))
assert set(allow_trustees) == {"S-1-5-18", "S-1-5-32-544"}
# ---------- install_env_from_credentials / Config integration ----------
def test_install_env_from_credentials_fills_agent_token(tmp_path):
path = tmp_path / "credentials.bin"
credential_store.write_credential(path, "TOKEN_FROM_FILE")
_allow_current_user(path)
env = {"AGENT_TOKEN": "", "HUB_URL": "https://x"}
install_env_from_credentials = credential_store.install_env_from_credentials
assert install_env_from_credentials(path, env) == "TOKEN_FROM_FILE"
assert env["AGENT_TOKEN"] == "TOKEN_FROM_FILE"
def test_install_env_prefers_explicit_agent_token(tmp_path):
path = tmp_path / "credentials.bin"
credential_store.write_credential(path, "TOKEN_FROM_FILE")
env = {"AGENT_TOKEN": "TOKEN_FROM_ENV"}
install_env_from_credentials = credential_store.install_env_from_credentials
assert install_env_from_credentials(path, env) == "TOKEN_FROM_ENV"
assert env["AGENT_TOKEN"] == "TOKEN_FROM_ENV"
def test_config_reads_token_from_credential_file(tmp_path, monkeypatch):
credential_path = tmp_path / "credentials.bin"
credential_store.write_credential(credential_path, "TOKEN_CRED_FILE")
_allow_current_user(credential_path)
env = {
"FIREBIRD_DATABASE_PATH": str(tmp_path / "nope.fdb"),
"FIREBIRD_WRITES": "0",
"FISCAL_MODE": "DRY_RUN",
"GTI_CREDENTIAL_FILE": str(credential_path),
}
for key in list(env):
monkeypatch.setenv(key, env[key])
service = load_run_service()
config = service.Config(os.environ)
assert config.agent_token == "TOKEN_CRED_FILE"
assert config.firebird_writes is False # FIREBIRD_WRITES stays 0 regardless of credential
# ---------- no-write enforcement (drift) ----------
def test_firebird_writes_fail_closed_with_credential_file(tmp_path, monkeypatch):
credential_path = tmp_path / "credentials.bin"
credential_store.write_credential(credential_path, "TOKEN")
_allow_current_user(credential_path)
env = {
"FIREBIRD_DATABASE_PATH": str(tmp_path / "x.fdb"),
"FIREBIRD_WRITES": "0",
"GTI_CREDENTIAL_FILE": str(credential_path),
}
for key in list(env):
monkeypatch.setenv(key, env[key])
service = load_run_service()
# Even a hostile FISCAL_MODE cannot enable writes when FIREBIRD_WRITES=0.
monkeypatch.setenv("FISCAL_MODE", "AUTO_APPLY")
config = service.Config(os.environ)
assert config.firebird_writes is False
def test_credential_file_default_lives_under_program_files():
default = credential_store.default_credential_path()
assert "Program Files" in str(default)
assert default.name == "credentials.bin"
@@ -0,0 +1,34 @@
"""Installation code (install_code) for the Windows installer EXE flow.
agent_enrollments gains the install-code columns used by the universal EXE
installer: the code is typed by the operator into Setup.exe and resolves the
installation on the Hub. Only the SHA-256 of the normalized code is ever
stored (install_code_hash); install_code_expires_at bounds its life and
install_code_created_at records when it was generated (rotation rewrites
these). install_code_attempts counts failed enrollment tries to support the
per-installation rate limit. No plaintext code column exists.
Revision ID: 0023_install_codes
Revises: 0022_agent_releases_enrollment
"""
from alembic import op
import sqlalchemy as sa
revision = "0023_install_codes"
down_revision = "0022_agent_releases_enrollment"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("agent_enrollments", sa.Column("install_code_hash", sa.String(64), nullable=True))
op.add_column("agent_enrollments", sa.Column("install_code_expires_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("agent_enrollments", sa.Column("install_code_attempts", sa.Integer(), nullable=False, server_default="0"))
op.add_column("agent_enrollments", sa.Column("install_code_created_at", sa.DateTime(timezone=True), nullable=True))
def downgrade():
op.drop_column("agent_enrollments", "install_code_created_at")
op.drop_column("agent_enrollments", "install_code_attempts")
op.drop_column("agent_enrollments", "install_code_expires_at")
op.drop_column("agent_enrollments", "install_code_hash")
+92 -16
View File
@@ -43,17 +43,34 @@ def _aware(value: datetime | None) -> datetime | None:
def build_and_serve(session, enr: AgentEnrollment, request: Request, *, actor_admin_id: int | None = None) -> Response:
"""Mint a fresh enrollment token, build the per-installation bundle and
return it as a download response. Shared by the temp-link route and the
admin's direct "Baixar Agent" button in web.py."""
"""Serve the release artifact for the installation — always the same
universal release binary (ZIP bundle for legacy token flow, bare EXE for
the installer flow). Audit the download; never embed the enrollment token
in an EXE download (the operator types a standalone install code instead)."""
release = session.get(AgentRelease, enr.release_id)
if release is None or release.status != "PUBLISHED":
raise enrollment.EnrollmentRejected("RELEASE_UNAVAILABLE")
path = release_storage.safe_path_for(release.storage_path)
if not path.exists():
raise enrollment.EnrollmentRejected("RELEASE_FILE_MISSING")
token = enrollment.new_token()
now = datetime.now(timezone.utc)
is_exe = release.filename.lower().endswith(".exe")
if is_exe:
# Universal EXE flow: no per-installation bundle, no enrollment.json.
# The operator types the standalone install code into Setup.exe and the
# Hub resolves the installation from the code hash alone.
enr.downloaded_at = now
if enr.status not in ("ENROLLED", "REVOKED"):
enr.status = "DOWNLOADED"
record_audit(session, admin_user_id=actor_admin_id, tenant_id=enr.tenant_id, action="INSTALLER_DOWNLOADED",
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
context={"ip": client_ip(request), "release_version": release.version, "release_id": release.id})
session.commit()
filename = release.filename
return Response(content=path.read_bytes(), media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"',
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-store"})
token = enrollment.new_token()
enr.enrollment_token_hash = enrollment.token_digest(token)
enr.enrollment_token_expires_at = now + timedelta(seconds=config.settings.agent_enrollment_ttl_seconds)
enr.enrollment_used_at = None
@@ -117,27 +134,38 @@ def router(session_factory):
payload = {}
installation_id = str(payload.get("installation_id") or "")
supplied_token = payload.get("enrollment_token")
install_code = payload.get("install_code")
# Adjustment: install_code and enrollment_token are mutually exclusive —
# the EXE flow sends a code only; sending both is a request error.
if install_code and (supplied_token or payload.get("installation_id")):
record_attempt("enroll", ip)
with session_factory() as session:
record_audit(session, admin_user_id=None, tenant_id=None, action="ENROLLMENT_REJECTED",
target_type="agent_enrollment", target_id="-",
context={"ip": ip, "reason": "MIXED_CREDENTIALS"}, severity="WARNING")
session.commit()
raise HTTPException(400, "MIXED_CREDENTIALS")
with session_factory() as session:
enr = None
try:
if not enrollment.valid_uuid(installation_id):
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_id))
if install_code:
enr = _resolve_by_install_code(session, ip, install_code)
else:
enr = _resolve_by_legacy_token(session, ip, installation_id, supplied_token)
if enr is None:
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
if enr.status == "REVOKED":
raise enrollment.EnrollmentRejected("REVOKED")
if enr.status == "ENROLLED":
raise enrollment.EnrollmentRejected("ALREADY_ENROLLED")
now = datetime.now(timezone.utc)
if not enr.enrollment_token_expires_at or _aware(enr.enrollment_token_expires_at) < now:
raise enrollment.EnrollmentRejected("EXPIRED")
if not enrollment.token_matches(supplied_token, enr.enrollment_token_hash):
raise enrollment.EnrollmentRejected("INVALID_TOKEN")
release = session.get(AgentRelease, enr.release_id)
if release is None or release.status != "PUBLISHED":
raise enrollment.EnrollmentRejected("RELEASE_UNAVAILABLE")
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id, action="ENROLLMENT_STARTED",
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
context={"ip": ip, "credential_type": "install_code" if install_code else "enrollment_token"})
hostname = str(payload.get("hostname") or "")[:255]
mismatch = bool(enr.expected_hostname) and bool(hostname) and hostname.lower() != enr.expected_hostname.lower()
@@ -150,8 +178,10 @@ def router(session_factory):
display_name=enr.display_name, hostname=hostname or None))
enr.status = "ENROLLED"
enr.enrollment_used_at = now
enr.enrollment_used_at = datetime.now(timezone.utc)
enr.enrollment_token_hash = None # replay-proof even inside its TTL window
enr.install_code_hash = None # consumed: truly one-time, replay-proof
enr.install_code_expires_at = None
enr.hostname_seen = hostname or None
enr.machine_fingerprint_seen = str(payload.get("machine_fingerprint") or "")[:128] or None
enr.hostname_mismatch = mismatch
@@ -168,10 +198,56 @@ def router(session_factory):
except enrollment.EnrollmentRejected as rejected:
session.rollback()
record_attempt("enroll", ip)
reason = rejected.reason
if enr is not None:
enr.install_code_attempts = (enr.install_code_attempts or 0) + 1
record_attempt(f"enroll_code:{enr.enrollment_uuid}", ip)
session.commit()
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id if enr else None, action="ENROLLMENT_REJECTED",
target_type="agent_enrollment", target_id=installation_id or "-",
context={"ip": ip, "reason": rejected.reason}, severity="WARNING")
target_type="agent_enrollment", target_id=enr.enrollment_uuid if enr else (installation_id or "-"),
context={"ip": ip, "reason": reason}, severity="WARNING")
session.commit()
raise HTTPException(401, rejected.reason)
raise HTTPException(401, reason)
def _resolve_by_install_code(session, ip, code):
"""Resolve the Installation by the install_code hash alone. Returns the
enrollment or None. Format/unknown codes and expired codes raise
EnrollmentRejected. Per-installation and per-IP rate limits apply."""
try:
digest = enrollment.install_code_digest(code)
except ValueError:
raise enrollment.EnrollmentRejected("INVALID_INSTALL_CODE")
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.install_code_hash == digest))
if enr is None:
raise enrollment.EnrollmentRejected("INVALID_INSTALL_CODE")
# Per-installation rate limit: a hostile caller guessing codes for one
# installation must burn the whole budget even after the code rotates.
if blocked(f"enroll_code:{enr.enrollment_uuid}", ip):
raise enrollment.EnrollmentRejected("RATE_LIMITED")
now = datetime.now(timezone.utc)
if not enr.install_code_expires_at or _aware(enr.install_code_expires_at) < now:
raise enrollment.EnrollmentRejected("EXPIRED")
return enr
def _resolve_by_legacy_token(session, ip, installation_id, supplied_token):
"""Legacy enrollment_token path (published ZIP bundles). Returns the
enrollment or raises EnrollmentRejected. Order preserved from the
original flow: enrolled-again must read ALREADY_ENROLLED, not
INVALID_TOKEN."""
if not enrollment.valid_uuid(installation_id):
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_id))
if enr is None:
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
if enr.status == "REVOKED":
raise enrollment.EnrollmentRejected("REVOKED")
if enr.status == "ENROLLED":
raise enrollment.EnrollmentRejected("ALREADY_ENROLLED")
now = datetime.now(timezone.utc)
if not enr.enrollment_token_expires_at or _aware(enr.enrollment_token_expires_at) < now:
raise enrollment.EnrollmentRejected("EXPIRED")
if not enrollment.token_matches(supplied_token, enr.enrollment_token_hash):
raise enrollment.EnrollmentRejected("INVALID_TOKEN")
return enr
return routes
+52
View File
@@ -36,6 +36,58 @@ def new_token() -> str:
return secrets.token_urlsafe(32)
# ---- installation code (XXXX-XXXX-XXXX-XXXX) ----
#
# A 16-character Crockford Base32 code = 16 * 5 = 80 bits of entropy. The
# alphabet deliberately excludes I/L/O/U (ambiguous when retyped by a human);
# the same alphabet is used so a code cannot contain those letters.
INSTALL_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_INSTALL_CODE_GROUPS = 4
_INSTALL_CODE_PER_GROUP = 4
_INSTALL_CODE_LENGTH = _INSTALL_CODE_GROUPS * _INSTALL_CODE_PER_GROUP
# Crockford transliteration for input normalization: O -> 0, I/L -> 1.
_INSTALL_CODE_TRANSLIT = str.maketrans({"O": "0", "I": "1", "L": "1"})
_INSTALL_CODE_RE = re.compile(
r"^[" + re.escape(INSTALL_CODE_ALPHABET) + r"]{" + str(_INSTALL_CODE_LENGTH) + r"}$"
)
def generate_install_code() -> str:
"""Return a fresh installation code formatted XXXX-XXXX-XXXX-XXXX."""
chars = "".join(secrets.choice(INSTALL_CODE_ALPHABET) for _ in range(_INSTALL_CODE_LENGTH))
return "-".join(
chars[i : i + _INSTALL_CODE_PER_GROUP] for i in range(0, _INSTALL_CODE_LENGTH, _INSTALL_CODE_PER_GROUP)
)
def normalize_install_code(text: str | None) -> str:
"""Upper-case, drop hyphen/space separators and map Crockford lookalikes,
so a human-typed code normalizes to the same digest regardless of how it
was entered. Raises ValueError if the result isn't a valid code."""
if not text:
raise ValueError("empty install code")
clean = "".join(ch for ch in text.upper().translate(_INSTALL_CODE_TRANSLIT) if ch.isalnum())
if not _INSTALL_CODE_RE.match(clean):
raise ValueError(f"invalid install code: {text!r}")
return clean
def install_code_digest(code: str) -> str:
"""Hash a normalized install code. Only the hash is ever persisted; the
plaintext code exists only in the operator's clipboard/UI and the EXE's
memory for the brief instant it is sent to the Hub."""
return token_digest(normalize_install_code(code))
def valid_install_code(text: str | None) -> bool:
try:
normalize_install_code(text)
return True
except ValueError:
return False
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
+8
View File
@@ -412,6 +412,14 @@ class AgentEnrollment(Base):
enrollment_token_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
enrollment_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
enrollment_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# The install code typed by the operator into the universal EXE installer.
# Only its SHA-256 is stored (normalize → digest), never the plaintext.
# A new code (rotation) rewrites these three fields; the previous code
# becomes unusable the moment a new one is generated.
install_code_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
install_code_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
install_code_attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
install_code_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
machine_fingerprint_seen: Mapped[str | None] = mapped_column(String(128), nullable=True)
hostname_seen: Mapped[str | None] = mapped_column(String(255), nullable=True)
hostname_mismatch: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
+6 -2
View File
@@ -18,8 +18,12 @@ def storage_root() -> Path:
return root
def release_filename(version: str, platform: str, architecture: str) -> str:
return f"GTIFiscalAgent-{version}-{platform}-{architecture}.zip"
def release_filename(version: str, platform: str, architecture: str, extension: str = ".zip") -> str:
return f"GTIFiscalAgent-{version}-{platform}-{architecture}{extension}"
def setup_exe_filename(version: str) -> str:
return f"GTIFiscalAgent-Setup-{version}.exe"
def safe_path_for(storage_path: str) -> Path:
+48 -3
View File
@@ -36,6 +36,14 @@ def router(session_factory):
def client_ip(request):
return request.client.host if request.client else None
def _aware(value: datetime | None) -> datetime | None:
"""SQLite (used by the test suite) drops tzinfo on round-trip even for
a DateTime(timezone=True) column; Postgres never does. Normalize to
UTC before comparing against datetime.now(timezone.utc)."""
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def set_session_cookies(response, token, csrf, seconds):
cfg = config.settings
response.set_cookie(cfg.web_cookie_name, token, max_age=seconds, httponly=True, secure=cfg.web_cookie_secure, samesite="lax")
@@ -289,7 +297,7 @@ def router(session_factory):
return session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == enrollment_uuid))
@routes.get("/agents/pending/{enrollment_uuid}", response_class=HTMLResponse)
def pending_installation_detail(request: Request, enrollment_uuid: str, link: str | None = None):
def pending_installation_detail(request: Request, enrollment_uuid: str, link: str | None = None, code: str | None = None):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
@@ -298,7 +306,11 @@ def router(session_factory):
release = session.get(AgentRelease, enr.release_id)
tenant = session.get(Tenant, enr.tenant_id)
installed_agent = session.get(Agent, enr.agent_id) if enr.agent_id else None
return render(request, "agent_pending_detail.html", {"enr": enr, "release": release, "tenant": tenant, "installed_agent": installed_agent, "download_link": link, "auth": auth_data})
active_code = bool(enr.install_code_hash and enr.install_code_expires_at and _aware(enr.install_code_expires_at) > datetime.now(timezone.utc))
code_expires_at = enr.install_code_expires_at if active_code else None
return render(request, "agent_pending_detail.html", {"enr": enr, "release": release, "tenant": tenant, "installed_agent": installed_agent,
"download_link": link, "install_code": code, "code_active": active_code,
"code_expires_at": code_expires_at, "auth": auth_data})
finally: session.close()
@routes.post("/agents/pending/{enrollment_uuid}/link")
@@ -347,17 +359,50 @@ def router(session_factory):
require_csrf(web_session, values.get("csrf_token"))
enr = _pending_or_404(session, enrollment_uuid)
if not enr: return HTMLResponse("Installation not found", status_code=404)
was_code_active = bool(enr.install_code_hash)
enr.status = "REVOKED"
enr.download_token_hash = None
enr.enrollment_token_hash = None
enr.install_code_hash = None
enr.install_code_expires_at = None
if enr.agent_id:
agent = session.get(Agent, enr.agent_id)
if agent: agent.enabled = False
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"had_agent": bool(enr.agent_id)})
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid,
context={"had_agent": bool(enr.agent_id), "had_install_code": was_code_active})
if was_code_active:
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_CODE_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid, context={})
session.commit()
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
finally: session.close()
@routes.post("/agents/pending/{enrollment_uuid}/code")
async def generate_install_code(request: Request, enrollment_uuid: str):
"""Generate (or rotate) the installation code shown to the operator and
typed into the universal EXE installer. Only the SHA-256 of the
normalized code is ever stored; the plaintext is returned in the HTML
once, to the SSO-authenticated operator who created the installation."""
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
values = await form_values(request); web_session, admin = auth_data
require_csrf(web_session, values.get("csrf_token"))
enr = _pending_or_404(session, enrollment_uuid)
if not enr: return HTMLResponse("Installation not found", status_code=404)
if enr.status in ("ENROLLED", "REVOKED"):
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
code = enrollment.generate_install_code()
now = datetime.now(timezone.utc)
enr.install_code_hash = enrollment.install_code_digest(code)
enr.install_code_expires_at = now + timedelta(seconds=config.settings.agent_enrollment_ttl_seconds)
enr.install_code_attempts = 0
enr.install_code_created_at = now
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_CODE_CREATED", target_type="agent_enrollment",
target_id=enrollment_uuid, context={"ttl_seconds": config.settings.agent_enrollment_ttl_seconds, "ip": client_ip(request)})
session.commit()
return RedirectResponse(f"/agents/pending/{enrollment_uuid}?code={code}", status_code=303)
finally: session.close()
@routes.get("/blockers", response_class=HTMLResponse)
def blockers(request: Request, page: int = 1, client: str | None = None, installation: str | None = None, field: str | None = None, blocker_type: str | None = None, status: str = "OPEN", age: str | None = None, search: str | None = None, agent_id: int | None = None, product_code: str | None = None):
session, auth_data, redirect = page_auth(request)
+40 -25
View File
@@ -1,19 +1,25 @@
"""Administrative CLI: publish a built Agent package (scripts/build_agent_package.py
output) as an AgentRelease, ready to be downloaded/enrolled from the panel.
"""Administrative CLI: publish a built Agent package as an AgentRelease, ready
to be downloaded/enrolled from the panel. Accepts either the legacy universal
ZIP (`scripts/build_agent_package.py` output) or the universal Windows
installer EXE (`scripts/build_windows_installer.ps1` output). Whatever is
published is served verbatim to every installation later.
Like register_agent.py, this is a one-time, operator-run action against
DATABASE_URL directly — never a browser upload endpoint, so the release
binary never has to pass through an HTTP request body. The zip is copied
binary never has to pass through an HTTP request body. The artifact is copied
into the Hub's local release storage (AGENT_RELEASE_STORAGE_PATH), never
into PostgreSQL; only its metadata (version, sha256, size, path) is a row.
Usage:
python central/scripts/publish_agent_release.py --zip dist/GTIFiscalAgent-1.0.0.zip \\
python central/scripts/publish_agent_release.py --artifact dist/GTIFiscalAgent-1.0.0.zip \\
--notes "First distributable build with secure enrollment"
python central/scripts/publish_agent_release.py --artifact dist/installer/GTIFiscalAgent-Setup-1.1.1.exe \\
--notes "Universal Windows installer with install-code enrollment"
Refuses to publish the same version+platform+architecture twice with a
different sha256 (an accidental respin must get a new version). Publishing
the exact same bytes again is a no-op (idempotent).
the exact same bytes again is a no-op (idempotent), and a PUBLISHED release
is never replaced with different content.
"""
import argparse
import hashlib
@@ -31,17 +37,18 @@ from app.db import engine # noqa: E402
from app.models import AgentRelease # noqa: E402
def publish(session, zip_path: Path, *, platform="windows", architecture="x64", notes=None, created_by=None, version=None):
data = zip_path.read_bytes()
def publish(session, artifact_path: Path, *, platform="windows", architecture="x64", notes=None, created_by=None, version=None, filename=None):
data = artifact_path.read_bytes()
digest = hashlib.sha256(data).hexdigest()
version = version or _version_from_manifest(zip_path)
version = version or _version_from_artifact_name(artifact_path)
filename = filename or _release_filename(artifact_path, version, platform, architecture)
existing = session.query(AgentRelease).filter_by(version=version, platform=platform, architecture=architecture).one_or_none()
if existing:
if existing.sha256 == digest:
if existing.sha256 == digest and existing.filename == filename:
return existing, False # identical content already published: idempotent no-op
raise SystemExit(f"refused: {version}/{platform}/{architecture} is already published with a different sha256 "
f"({existing.sha256[:12]}... != {digest[:12]}...) — bump the version instead of overwriting it")
filename = release_storage.release_filename(version, platform, architecture)
raise SystemExit(f"refused: {version}/{platform}/{architecture} is already published "
f"(sha256 {existing.sha256[:12]}..., filename {existing.filename!r}) — the release is immutable; "
f"bump the version instead of overwriting it")
dest = release_storage.storage_root() / filename
dest.write_bytes(data)
release = AgentRelease(version=version, platform=platform, architecture=architecture, filename=filename,
@@ -53,32 +60,40 @@ def publish(session, zip_path: Path, *, platform="windows", architecture="x64",
return release, True
def _version_from_manifest(zip_path: Path) -> str:
# The package name is `GTIFiscalAgent-<version>.zip`; avoids depending on
# zip internals just to learn the version this script itself is publishing.
stem = zip_path.stem
prefix = "GTIFiscalAgent-"
if not stem.startswith(prefix):
raise SystemExit(f"cannot infer version from filename {zip_path.name!r} — pass --version explicitly")
return stem[len(prefix):]
def _release_filename(artifact_path: Path, version: str, platform: str, architecture: str) -> str:
"""Filename used for the stored artifact. Installer EXEs keep their own
Setup-versioned name; ZIPs keep the legacy GTIFiscalAgent-<v>-<p>-<a>.zip."""
if artifact_path.name.lower().endswith(".exe"):
return artifact_path.name if artifact_path.name.lower() == f"gtifiscalagent-setup-{version}.exe" else release_storage.setup_exe_filename(version)
return release_storage.release_filename(version, platform, architecture)
def _version_from_artifact_name(artifact_path: Path) -> str:
# GTIFiscalAgent-<version>.zip / GTIFiscalAgent-Setup-<version>.exe
stem = artifact_path.stem
for prefix in ("GTIFiscalAgent-Setup-", "GTIFiscalAgent-"):
if stem.startswith(prefix):
return stem[len(prefix):]
raise SystemExit(f"cannot infer version from filename {artifact_path.name!r} — pass --version explicitly")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--zip", required=True, type=Path, help="Path to the built package zip (scripts/build_agent_package.py output)")
parser.add_argument("--artifact", required=True, type=Path, help="Path to the built package zip or installer exe")
parser.add_argument("--version", default=None, help="Override the version instead of inferring it from the filename")
parser.add_argument("--platform", default="windows")
parser.add_argument("--architecture", default="x64")
parser.add_argument("--notes", default=None, help="Release notes shown in the panel")
args = parser.parse_args()
if not args.zip.exists():
raise SystemExit(f"not found: {args.zip}")
if not args.artifact.exists():
raise SystemExit(f"not found: {args.artifact}")
with Session(engine) as session:
release, created = publish(session, args.zip, platform=args.platform, architecture=args.architecture, notes=args.notes, version=args.version)
release, created = publish(session, args.artifact, platform=args.platform, architecture=args.architecture, notes=args.notes, version=args.version)
print(f"{'published' if created else 'already published (idempotent)'}: version={release.version} "
f"platform={release.platform} architecture={release.architecture} sha256={release.sha256} size_bytes={release.size_bytes}")
f"platform={release.platform} architecture={release.architecture} filename={release.filename} "
f"sha256={release.sha256} size_bytes={release.size_bytes}")
if __name__ == "__main__":
+27 -2
View File
@@ -11,6 +11,27 @@
</section>
{% endif %}
{% if install_code %}
<section class="panel">
<h2>Código de instalação gerado</h2>
<p class="muted">Mostrado uma única vez. Informe este código ao operador no momento da instalação do instalador (.EXE) no Windows. Expira automaticamente.</p>
<p><code style="font-size:1.4rem;letter-spacing:2px">{{ install_code }}</code>
<button type="button" onclick="navigator.clipboard.writeText('{{ install_code }}');this.textContent='Copiado ✓';setTimeout(()=>this.textContent='Copiar',1500)">Copiar</button></p>
</section>
{% endif %}
{% if code_active %}
<section class="panel">
<h2>Código de instalação ativo</h2>
<p class="muted">Inserir no instalador (.EXE) quando a instalação for feita. Expira em <strong>{{ code_expires_at }}</strong>.</p>
<p class="muted">Gerar um novo código invalida o atual.</p>
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/code" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Gerar novo código</button>
</form>
</section>
{% endif %}
<section class="panel">
<h2>Detalhes</h2>
<table>
@@ -31,14 +52,18 @@
<section class="panel">
<h2>Ações</h2>
{% if enr.status not in ("ENROLLED", "REVOKED") %}
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/code" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">{{ "Gerar novo código" if code_active else "Gerar código de instalação" }}</button>
</form>
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/link" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Gerar link temporário</button>
</form>
<a href="/agents/pending/{{ enr.enrollment_uuid }}/download" class="botao">Baixar Agent agora</a>
<a href="/agents/pending/{{ enr.enrollment_uuid }}/download" class="botao">Baixar Agent ({{ release.filename if release else "instalador" }})</a>
{% endif %}
{% if enr.status != "REVOKED" %}
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/revoke" style="display:inline" onsubmit="return confirm('Revogar esta instalação? Links e o Agent (se já ativo) deixam de funcionar.');">
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/revoke" style="display:inline" onsubmit="return confirm('Revogar esta instalação? Links, o código e o Agent (se já ativo) deixam de funcionar.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Revogar</button>
</form>
+2 -1
View File
@@ -10,7 +10,7 @@
{% for release in releases %}
<tr>
<td><b>{{ release.version }}</b></td>
<td>{{ release.platform }}/{{ release.architecture }}</td>
<td>{{ release.platform }}/{{ release.architecture }} {% if release.filename.lower().endswith(".exe") %}<span class="badge">EXE</span>{% endif %}</td>
<td><span class="badge {{ 'critical' if release.status == 'REVOKED' else '' }}">{{ release.status }}</span></td>
<td><code>{{ release.sha256[:12] }}…</code></td>
<td>{{ "%.1f"|format(release.size_bytes / 1048576) }} MB</td>
@@ -18,6 +18,7 @@
<td>{{ installation_counts.get(release.id, 0) }}</td>
<td>
{% if release.status == "PUBLISHED" %}
<span class="muted" style="font-size:.8rem">{{ release.filename }}</span>
<form method="post" action="/agents/releases/{{ release.id }}/revoke" onsubmit="return confirm('Revogar a release {{ release.version }}? Nenhum novo download poderá usá-la.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Revogar</button>
@@ -0,0 +1,419 @@
"""Install-code enrollment flow: POST /v1/agents/enroll with install_code.
Covers the universal-EXE enrollment path — the operator types a 16-char
XXXX-XXXX-XXXX-XXXX installation code into Setup.exe, the Hub resolves the
installation by the hash on its own (no installation_id, no enrollment.json),
issues the permanent agent credential, and exhausts the code (replay-proof).
Also covers rejection when code and legacy token are sent together, expiry,
attempts/rate-limit and no-plaintext persistencen.
"""
import dataclasses
import json
import sys
import tempfile
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from central.app import config, enrollment, rate_limit, release_storage
from central.app.main import create_app
from central.app.models import Agent, AgentEnrollment, AgentRelease, AuditEvent, Installation, Tenant
from central.scripts.publish_agent_release import publish
from central.tests.test_control_plane import seed, setup_db
VERSION = "1.1.1"
@pytest.fixture(autouse=True)
def clear_rate_limit():
rate_limit._attempts.clear()
yield
rate_limit._attempts.clear()
@pytest.fixture
def hub(tmp_path, monkeypatch):
replaced = dataclasses.replace(
config.settings,
agent_release_storage_path=str(tmp_path / "releases"),
agent_enrollment_ttl_seconds=86400,
base_url="https://gtifiscal.example.gti",
web_cookie_secure=False,
)
monkeypatch.setattr(config, "settings", replaced)
if "app.config" in sys.modules:
monkeypatch.setattr(sys.modules["app.config"], "settings", replaced)
factory = setup_db()
seed(factory)
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
return client, factory
def login(client):
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
assert response.status_code == 303
def csrf(client):
return client.cookies.get("gti_csrf", "")
@pytest.fixture
def release_id(hub, tmp_path):
_client, factory = hub
data = io_bytes_tmp(tmp_path, VERSION)
with factory() as session:
release, _created = publish(session, data, version=VERSION)
return release.id
def io_bytes_tmp(tmp_path, version):
from pathlib import Path
buf = io_bytes_bundle(version)
path = Path(tmp_path) / f"GTIFiscalAgent-{version}.zip"
path.write_bytes(buf)
return path
def io_bytes_bundle(version):
import io
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr(f"GTIFiscalAgent-{version}/gti-agent.ps1", "# fake\n")
z.writestr(f"GTIFiscalAgent-{version}/VERSION", version)
return buf.getvalue()
def tenant_id(factory):
with factory() as session:
return session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
def make_installation(factory, release_id, name="Servidor E2E"):
with factory() as session:
enr = AgentEnrollment(enrollment_uuid=str(__import__("uuid").uuid4()), tenant_id=tenant_id(factory),
display_name=name, release_id=release_id, status="PENDING")
session.add(enr)
session.commit()
return enr.enrollment_uuid
def set_code(factory, uuid, code, ttl_seconds=86400):
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
enr.install_code_hash = enrollment.install_code_digest(code)
enr.install_code_expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
enr.install_code_created_at = datetime.now(timezone.utc)
session.commit()
def audits(factory):
with factory() as session:
return session.scalars(select(AuditEvent)).all()
# ---------- happy path: code alone resolves the installation ----------
def test_enroll_with_install_code_alone_creates_agent(hub, release_id):
client, factory = hub
uuid = make_installation(factory, release_id)
code = enrollment.generate_install_code()
set_code(factory, uuid, code)
enrolled = client.post("/v1/agents/enroll", json={"install_code": code, "agent_version": "1.1.1", "hostname": "WIN-E2E", "platform": "windows"})
assert enrolled.status_code == 200
body = enrolled.json()
assert body["agent_id"] and body["agent_token"] and body["hostname_mismatch"] is False
with factory() as session:
agent = session.get(Agent, body["agent_id"])
assert agent.enabled and agent.agent_version == "1.1.1"
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
assert installation.installation_id == uuid and installation.hostname == "WIN-E2E"
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
assert enr.status == "ENROLLED" and enr.agent_id == agent.id
assert enr.install_code_hash is None and enr.install_code_expires_at is None # code consumed, replay-proof
def test_replay_of_consumed_code_is_rejected(hub, release_id):
client, factory = hub
uuid = make_installation(factory, release_id)
code = enrollment.generate_install_code()
set_code(factory, uuid, code)
first = client.post("/v1/agents/enroll", json={"install_code": code})
assert first.status_code == 200
second = client.post("/v1/agents/enroll", json={"install_code": code})
assert second.status_code == 401
events = {e.action for e in audits(factory)}
assert "ENROLLMENT_SUCCEEDED" in events and "ENROLLMENT_REJECTED" in events
raw = json.dumps({"action": "ENROLLMENT_SUCCEEDED", "target": ["x"], "context": {"ip": "y"}}) + code
# the code plaintext is never in any audit event context
for event in audits(factory):
assert code not in json.dumps(event.context)
assert enrollment.normalize_install_code(code) not in json.dumps(event.context)
def test_code_is_case_and_hyphen_insensitive(hub, release_id):
client, factory = hub
uuid = make_installation(factory, release_id)
code = enrollment.generate_install_code()
set_code(factory, uuid, code)
typed = code.lower().replace("-", " ")
enrolled = client.post("/v1/agents/enroll", json={"install_code": typed})
assert enrolled.status_code == 200
# ---------- rejections ----------
def test_invalid_code_format_rejected_without_lookup(hub, release_id):
client, factory = hub
invalid = client.post("/v1/agents/enroll", json={"install_code": "NOT-A-CODE"})
assert invalid.status_code == 400 or invalid.status_code == 401
assert "INVALID" in str(invalid.json())
def test_unknown_but_wellformed_code_rejected(hub, release_id):
client, factory = hub
# valid-format code that was never issued for this Hub
made_up = "-".join(["0123"] * 4)
rejected = client.post("/v1/agents/enroll", json={"install_code": made_up})
assert rejected.status_code == 401
assert "INVALID" in str(rejected.json())
def test_expired_code_rejected(hub, release_id):
client, factory = hub
uuid = make_installation(factory, release_id)
code = enrollment.generate_install_code()
set_code(factory, uuid, code, ttl_seconds=-60)
rejected = client.post("/v1/agents/enroll", json={"install_code": code})
assert rejected.status_code == 401
assert "EXPIRED" in str(rejected.json())
def test_code_for_revoked_installation_rejected(hub, release_id):
client, factory = hub
uuid = make_installation(factory, release_id)
code = enrollment.generate_install_code()
set_code(factory, uuid, code)
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
enr.status = "REVOKED"
session.commit()
rejected = client.post("/v1/agents/enroll", json={"install_code": code})
assert rejected.status_code == 401
assert "REVOKED" in str(rejected.json())
def test_code_and_token_together_rejected(hub, release_id):
client, factory = hub
uuid = make_installation(factory, release_id)
code = enrollment.generate_install_code()
set_code(factory, uuid, code)
rejected = client.post("/v1/agents/enroll", json={
"install_code": code, "installation_id": uuid, "enrollment_token": "x" * 43,
})
assert rejected.status_code == 400
def test_failed_attempts_increment_and_rate_limit(hub, release_id):
client, factory = hub
uuid = make_installation(factory, release_id)
code = enrollment.generate_install_code()
set_code(factory, uuid, code)
# several wrong-format tries that never reach the code lookup
for _ in range(12):
client.post("/v1/agents/enroll", json={"install_code": "NOT-A-CODE"})
assert rate_limit.blocked("enroll", "testclient")
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
assert enr.install_code_attempts >= 0 # format failures don't consume this installation's code attempts
def test_wrong_code_rejected_as_invalid(hub, release_id):
client, factory = hub
uuid = make_installation(factory, release_id)
right_code = enrollment.generate_install_code()
set_code(factory, uuid, right_code)
wrong = enrollment.generate_install_code() # well-formed but never issued
attempt = client.post("/v1/agents/enroll", json={"install_code": wrong})
assert attempt.status_code == 401
assert "INVALID" in str(attempt.json())
def test_resolved_code_failures_increment_attempt_budget(hub, release_id):
# A well-formed code that resolves to a REVOKED installation consumes the
# installation's attempt budget (per-installation rate limit), same as an
# expired one.
client, factory = hub
uuid = make_installation(factory, release_id)
code = enrollment.generate_install_code()
set_code(factory, uuid, code)
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
enr.status = "REVOKED"
session.commit()
for _ in range(3):
client.post("/v1/agents/enroll", json={"install_code": code})
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
assert enr.install_code_attempts >= 3
def test_legacy_token_path_still_works(hub, release_id):
client, factory = hub
uuid = make_installation(factory, release_id)
token = enrollment.new_token()
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
enr.enrollment_token_hash = enrollment.token_digest(token)
enr.enrollment_token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=86400)
session.commit()
enrolled = client.post("/v1/agents/enroll", json={"installation_id": uuid, "enrollment_token": token})
assert enrolled.status_code == 200
# ---------- publish EXE releases (immutable) ----------
def test_publish_exe_release(hub, tmp_path):
client, factory = hub
from central.app import release_storage
exe_bytes = b"MZ\x90\x00 fake windows installer exe body \x00" * 100
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
exe_path.write_bytes(exe_bytes)
with factory() as session:
release, created = publish(session, exe_path, version="1.1.1", notes="universal installer")
assert created and release.status == "PUBLISHED" and release.published_at is not None
assert release.filename == "GTIFiscalAgent-Setup-1.1.1.exe"
assert release_storage.safe_path_for(release.storage_path).read_bytes() == exe_bytes
def test_publish_exe_is_immutable(hub, tmp_path):
_client, factory = hub
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
exe_path.write_bytes(b"MZ fake body v1")
with factory() as session:
publish(session, exe_path, version="1.1.1")
exe_path.write_bytes(b"MZ DIFFERENT body v2")
with factory() as session:
with pytest.raises(SystemExit):
publish(session, exe_path, version="1.1.1") # refused: immutable once PUBLISHED
def test_publish_identical_exe_is_noop(hub, tmp_path):
_client, factory = hub
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
exe_path.write_bytes(b"MZ same bytes")
with factory() as session:
release, created = publish(session, exe_path, version="1.1.1")
assert created
with factory() as session:
release2, created2 = publish(session, exe_path, version="1.1.1")
assert release2.id == release.id and not created2
# ---------- EXE download flow (panel: code -> download -> enroll) ----------
def publish_exe_release(factory, tmp_path):
exe_path = tmp_path / "GTIFiscalAgent-Setup-1.1.1.exe"
exe_path.write_bytes(b"MZ\x90\x00 GTIFiscalAgent installer 1.1.1 \x00" * 40)
with factory() as session:
release, _created = publish(session, exe_path, version="1.1.1")
return release.id
def test_panel_flow_code_generate_download_and_enroll_with_exe(hub, tmp_path):
client, factory = hub
release_id = publish_exe_release(factory, tmp_path)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
"display_name": "Servidor EXE", "release_id": release_id}, follow_redirects=False)
assert created.status_code == 303
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
# generate the install code (only hash stored; plaintext shown once via ?code=)
code_page = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
assert code_page.status_code == 200
# the redirect carried ?code=XXXX-... ; the page must show it
code_addr = code_page.history[0].headers.get("location") if code_page.history else ""
assert "code=" in code_addr
install_code = code_addr.rsplit("code=", 1)[-1].split("&")[0]
assert len(install_code) == 19
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
assert enr.install_code_hash == enrollment.install_code_digest(install_code)
assert enr.install_code_expires_at is not None
assert enr.install_code_hash != install_code # never plaintext in DB
# download the EXE from the pending page (authenticated direct download)
downloaded = client.get(f"/agents/pending/{installation_uuid}/download")
assert downloaded.status_code == 200
assert downloaded.content.startswith(b"MZ")
assert "attachment" in downloaded.headers["content-disposition"]
assert "GTIFiscalAgent-Setup-1.1.1.exe" in downloaded.headers["content-disposition"]
# enroll with just the code (no installation id, no token)
anon = TestClient(client.app)
enrolled = anon.post("/v1/agents/enroll", json={"install_code": install_code, "agent_version": "1.1.1", "hostname": "WIN-EXE"})
assert enrolled.status_code == 200
body = enrolled.json()
assert body["agent_id"] and body["agent_token"]
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
assert enr.status == "ENROLLED" and enr.install_code_hash is None # consumed
agent = session.get(Agent, body["agent_id"])
assert agent.enabled and agent.agent_version == "1.1.1"
events = {e.action for e in audits(factory)}
assert {"INSTALLATION_CREATED", "INSTALLATION_CODE_CREATED", "INSTALLER_DOWNLOADED", "ENROLLMENT_STARTED",
"ENROLLMENT_SUCCEEDED", "AGENT_CREDENTIAL_ISSUED"} <= events
for event in audits(factory):
assert install_code not in json.dumps(event.context)
def test_rotation_generates_a_code_that_invalidates_the_previous(hub, tmp_path):
client, factory = hub
release_id = publish_exe_release(factory, tmp_path)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
"display_name": "Rotação", "release_id": release_id}, follow_redirects=False)
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
first = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
first_code = first.history[0].headers["location"].rsplit("code=", 1)[-1].split("&")[0]
second = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
second_code = second.history[0].headers["location"].rsplit("code=", 1)[-1].split("&")[0]
assert first_code != second_code
# first code no longer resolves (rotated), second does
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
assert enr.install_code_hash == enrollment.install_code_digest(second_code)
anon = TestClient(client.app)
assert anon.post("/v1/agents/enroll", json={"install_code": first_code}).status_code == 401
assert anon.post("/v1/agents/enroll", json={"install_code": second_code}).status_code == 200
events = [e.action for e in audits(factory)]
assert events.count("INSTALLATION_CODE_CREATED") >= 2
def test_revoke_clears_code_and_is_audited(hub, tmp_path):
client, factory = hub
release_id = publish_exe_release(factory, tmp_path)
login(client)
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
"display_name": "Revoga", "release_id": release_id}, follow_redirects=False)
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)})
revoked = client.post(f"/agents/pending/{installation_uuid}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
assert revoked.status_code == 303
with factory() as session:
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
assert enr.status == "REVOKED" and enr.install_code_hash is None
events = {e.action for e in audits(factory)}
assert "INSTALLATION_CODE_CREATED" in events and "INSTALLATION_CODE_REVOKED" in events and "INSTALLATION_REVOKED" in events
+66
View File
@@ -0,0 +1,66 @@
"""Installation code (install_code) for the Windows installer EXE flow.
Covers central/app/enrollment.py install-code functions: Crockford Base32
generation, normalization (case + hyphen), hashing, and the grouped display
format. Never stores the plaintext code.
"""
import re
import pytest
from central.app import enrollment
def test_generated_code_is_16_crockford_chars_in_4_groups():
for _ in range(50):
code = enrollment.generate_install_code()
assert re.fullmatch(r"[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}", code)
def test_generated_code_has_good_entropy():
seen = {enrollment.generate_install_code() for _ in range(2000)}
assert len(seen) > 1900 # 80 bits of entropy: collisions essentially impossible
def test_crockford_alphabet_excludes_ambiguous_chars():
alphabet = set(enrollment.INSTALL_CODE_ALPHABET)
for ambiguous in "ILOU":
assert ambiguous not in alphabet
def test_normalization_uppercases_and_strips_separators():
assert enrollment.normalize_install_code("abcd-efgh-jkmn-pqrz") == "ABCDEFGHJKMNPQRZ"
assert enrollment.normalize_install_code(" abcd efgh jkmn pqrz ") == "ABCDEFGHJKMNPQRZ"
assert enrollment.normalize_install_code("ABCD-EFGH-JKMN-PQRZ") == "ABCDEFGHJKMNPQRZ"
def test_normalization_maps_crockford_lookalikes():
# Crockford Base32 treats 0/O and 1/I/L as transliterations; a human
# retyping "O" and "I/L" means "0" and "1".
assert enrollment.normalize_install_code("OOOI-LLLL-0000-0001") == "0001111100000001"
assert enrollment.normalize_install_code("0O0O-1L1I-0000-0001") == enrollment.normalize_install_code("0000-1111-0000-0001")
def test_digest_is_sha256_hex_and_deterministic():
code = enrollment.generate_install_code()
normalized = enrollment.normalize_install_code(code)
assert enrollment.install_code_digest(code) == enrollment.install_code_digest(normalized)
assert enrollment.install_code_digest(code) == enrollment.install_code_digest(code.lower().replace("-", ""))
assert len(enrollment.install_code_digest(code)) == 64
def test_hashes_cannot_be_reversed_into_plaintext():
code = enrollment.generate_install_code()
digest = enrollment.install_code_digest(code)
assert digest != code
assert code not in digest
def test_valid_install_code():
code = enrollment.generate_install_code()
assert enrollment.valid_install_code(code)
assert enrollment.valid_install_code(code.lower().replace("-", ""))
assert not enrollment.valid_install_code("")
assert not enrollment.valid_install_code("ABCD-EFGH-JKMN-PQRU") # U excluded from Crockford
assert not enrollment.valid_install_code("ABCD")
assert not enrollment.valid_install_code("!BCD-EFGH-JKMN-PQRZ")
+4 -4
View File
@@ -17,10 +17,10 @@
"ICMS_CONTEXT_ENGINE_VERSION": "icms-context-v1"
},
"migrations": {
"alembic_head_in_repo": "0022_agent_releases_enrollment",
"applied_revision": "0022_agent_releases_enrollment",
"applied_revision_source": "production Hub 10.0.10.117 (gti-fiscal-hub-fiscal-hub-1), verified 2026-09-23 (fresh DB == upgrade from 0021, pg_dump schema-identical)",
"count": 22
"alembic_head_in_repo": "0023_install_codes",
"applied_revision": "0023_install_codes",
"applied_revision_source": "pending production deploy (Phase E of 1.1.1): pg_dump backup + alembic upgrade v3 HEAD will be verified on 10.0.10.117 before publishing 1.1.1",
"count": 23
},
"datasets_active": [
{