From 91c4030b06e17731fab7cff9b097be221ef213be Mon Sep 17 00:00:00 2001 From: gti Date: Wed, 23 Sep 2026 16:38:19 -0300 Subject: [PATCH] installer v1 (A+B): install-code enrollment, panel code UI, EXE publish; DPAPI credential store --- agent/scripts/run_agent_service.py | 22 +- .../src/gti_fiscal_agent/credential_store.py | 137 ++++++ agent/tests/test_credential_store.py | 179 ++++++++ .../alembic/versions/0023_install_codes.py | 34 ++ central/app/agent_distribution.py | 108 ++++- central/app/enrollment.py | 52 +++ central/app/models.py | 8 + central/app/release_storage.py | 8 +- central/app/web.py | 51 ++- central/scripts/publish_agent_release.py | 65 +-- central/templates/agent_pending_detail.html | 29 +- central/templates/agent_releases.html | 3 +- central/tests/test_install_code_enrollment.py | 419 ++++++++++++++++++ central/tests/test_install_codes.py | 66 +++ release/RELEASE_MANIFEST.json | 8 +- 15 files changed, 1135 insertions(+), 54 deletions(-) create mode 100644 agent/src/gti_fiscal_agent/credential_store.py create mode 100644 agent/tests/test_credential_store.py create mode 100644 central/alembic/versions/0023_install_codes.py create mode 100644 central/tests/test_install_code_enrollment.py create mode 100644 central/tests/test_install_codes.py diff --git a/agent/scripts/run_agent_service.py b/agent/scripts/run_agent_service.py index 264fd81..77e1ffd 100644 --- a/agent/scripts/run_agent_service.py +++ b/agent/scripts/run_agent_service.py @@ -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): diff --git a/agent/src/gti_fiscal_agent/credential_store.py b/agent/src/gti_fiscal_agent/credential_store.py new file mode 100644 index 0000000..ffc05c0 --- /dev/null +++ b/agent/src/gti_fiscal_agent/credential_store.py @@ -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 \ No newline at end of file diff --git a/agent/tests/test_credential_store.py b/agent/tests/test_credential_store.py new file mode 100644 index 0000000..4432277 --- /dev/null +++ b/agent/tests/test_credential_store.py @@ -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" \ No newline at end of file diff --git a/central/alembic/versions/0023_install_codes.py b/central/alembic/versions/0023_install_codes.py new file mode 100644 index 0000000..367df52 --- /dev/null +++ b/central/alembic/versions/0023_install_codes.py @@ -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") \ No newline at end of file diff --git a/central/app/agent_distribution.py b/central/app/agent_distribution.py index 9be99b3..15ab673 100644 --- a/central/app/agent_distribution.py +++ b/central/app/agent_distribution.py @@ -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 diff --git a/central/app/enrollment.py b/central/app/enrollment.py index 57bfb6b..3b85813 100644 --- a/central/app/enrollment.py +++ b/central/app/enrollment.py @@ -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() diff --git a/central/app/models.py b/central/app/models.py index 4a3e246..536e11a 100644 --- a/central/app/models.py +++ b/central/app/models.py @@ -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") diff --git a/central/app/release_storage.py b/central/app/release_storage.py index 5bf9092..3b4ed31 100644 --- a/central/app/release_storage.py +++ b/central/app/release_storage.py @@ -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: diff --git a/central/app/web.py b/central/app/web.py index cf2b66e..62b3d17 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -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) diff --git a/central/scripts/publish_agent_release.py b/central/scripts/publish_agent_release.py index 479c9bd..d97d2c9 100644 --- a/central/scripts/publish_agent_release.py +++ b/central/scripts/publish_agent_release.py @@ -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-.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--

-.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-.zip / GTIFiscalAgent-Setup-.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__": diff --git a/central/templates/agent_pending_detail.html b/central/templates/agent_pending_detail.html index bfa37cf..ece1f65 100644 --- a/central/templates/agent_pending_detail.html +++ b/central/templates/agent_pending_detail.html @@ -11,6 +11,27 @@ {% endif %} +{% if install_code %} +

+

Código de instalação gerado

+

Mostrado uma única vez. Informe este código ao operador no momento da instalação do instalador (.EXE) no Windows. Expira automaticamente.

+

{{ install_code }} +

+
+{% endif %} + +{% if code_active %} +
+

Código de instalação ativo

+

Inserir no instalador (.EXE) quando a instalação for feita. Expira em {{ code_expires_at }}.

+

Gerar um novo código invalida o atual.

+
+ + +
+
+{% endif %} +

Detalhes

@@ -31,14 +52,18 @@

Ações

{% if enr.status not in ("ENROLLED", "REVOKED") %} +
+ + +
- Baixar Agent agora + Baixar Agent ({{ release.filename if release else "instalador" }}) {% endif %} {% if enr.status != "REVOKED" %} -
+ diff --git a/central/templates/agent_releases.html b/central/templates/agent_releases.html index fe4378b..b4a2c02 100644 --- a/central/templates/agent_releases.html +++ b/central/templates/agent_releases.html @@ -10,7 +10,7 @@ {% for release in releases %}
- + @@ -18,6 +18,7 @@
{{ release.version }}{{ release.platform }}/{{ release.architecture }}{{ release.platform }}/{{ release.architecture }} {% if release.filename.lower().endswith(".exe") %}EXE{% endif %} {{ release.status }} {{ release.sha256[:12] }}… {{ "%.1f"|format(release.size_bytes / 1048576) }} MB{{ installation_counts.get(release.id, 0) }} {% if release.status == "PUBLISHED" %} + {{ release.filename }}
diff --git a/central/tests/test_install_code_enrollment.py b/central/tests/test_install_code_enrollment.py new file mode 100644 index 0000000..45432ff --- /dev/null +++ b/central/tests/test_install_code_enrollment.py @@ -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 \ No newline at end of file diff --git a/central/tests/test_install_codes.py b/central/tests/test_install_codes.py new file mode 100644 index 0000000..770b376 --- /dev/null +++ b/central/tests/test_install_codes.py @@ -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") \ No newline at end of file diff --git a/release/RELEASE_MANIFEST.json b/release/RELEASE_MANIFEST.json index 237c86b..4b26e5c 100644 --- a/release/RELEASE_MANIFEST.json +++ b/release/RELEASE_MANIFEST.json @@ -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": [ {