diff --git a/docs/superpowers/specs/2026-09-23-windows-installer-exe-v1-design.md b/docs/superpowers/specs/2026-09-23-windows-installer-exe-v1-design.md new file mode 100644 index 0000000..bdb3b58 --- /dev/null +++ b/docs/superpowers/specs/2026-09-23-windows-installer-exe-v1-design.md @@ -0,0 +1,264 @@ +# GTI FISCAL WINDOWS INSTALLER EXE V1 — Design + +- Date: 2026-09-23 +- Status: APPROVED_WITH_ADJUSTMENTS (operator 2026-09-23) +- Related: previous mission "Distribuição ZIP V1" shipped release 1.1.0. + +## Objective + +Replace the ZIP download UX with a real Windows `.EXE` installer +(`GTIFiscalAgent-Setup-.exe`) plus an installation/enrollment code per +installation. One universal installer per version. Publish release 1.1.1 in the +production Hub, run a REAL Windows E2E, and stop before the first client. + +Acceptance (operator visible): Fiscal → Agents → Nova instalação → Criar → +Baixar Agent → browser downloads `GTIFiscalAgent-Setup-1.1.1.exe` directly (an +executable, not a ZIP). Run the EXE on Windows, type the code, install/enroll. +ZIP as the main flow = FAIL. + +## Approved adjustments (operator) + +1. Setup.exe uses `requireAdministrator`/`--uac-admin` manifest. NO hybrid + asInvoker + self-elevation flow. +2. Setup.exe is self-contained: embeds the Service host + runtime + Uninstall.exe. + It never downloads a second ZIP during install. +3. `install_code` alone resolves the Installation on the Hub. User types only the + code. If both `install_code` and `enrollment_token` are present, the request + is rejected. +4. Production: the permanent credential NEVER goes into `agent.env`. It lives + only in `credentials.bin`, protected by DPAPI LocalMachine + ACL + SYSTEM/Administrators. `agent.env` holds only non-secret configuration plus + `FIREBIRD_WRITES=0`. +5. "Reproducible build" = an automated, repeatable build process and an + immutable release — NOT byte-identical hashes across rebuilds. After + PUBLISHED, 1.1.1 can never be replaced/overwritten. + +Also fixed in this design: +- 16 Crockford Base32 chars = 80 bits of entropy for the install code. +- Enrollment rate limit keyed by IP AND by code/installation. +- Install code is normalized (hyphens/case) before hashing. +- Release 1.1.0 stays untouched. +- `FIRST_REAL_CLIENT_AGENT_INSTALLED=false` this mission. + +## Installer technology + +**Chosen: PyInstaller (all-Python).** No Inno Setup. + +Reasoning (INSTALLER_TECHNOLOGY / INSTALLER_REASON): +- PyInstaller 6.21 is already installed on the Windows dev machine; Inno Setup is + not. A fully automated, fail-fast build is achievable today with `pip`-managed + tools. +- The enrollment wizard already needs HTTPS (httpx) and DPAPI (pywin32 + win32crypt); those are Python layers, so the wizard must be Python regardless. + Adding Inno Setup would add a second tool without removing the Python wizard + requirement. +- pywin32 gives the battle-tested Windows Service wrapper, and PyInstaller's own + runtime sulution replaces a venv in Program Files, so no Python/pip is needed + on clients. +- The previous mission already validated the service wrapper + `sc.exe failure` + recovery path in the pilot checklist. +- Reproducible build is achieved with a dedicated PowerShell build script (clean + output dir, official VERSION, SHA256, metadata, fail on missing dep); exact + byte-identical output is NOT required. + +### Artifacts (universal; built on the Windows dev machine) + +| Artifact | PyInstaller mode | Contents / role | +|---|---|---| +| `GTIFiscalAgent-Setup-.exe` | onefile, `--uac-admin` | GUI wizard (tkinter). Embeds Agent runtime sources + service host + `Uninstall.exe` as bundled data. Enroll → write config → DPAPI credential → extract Service host → register service → preflight. Main download artifact. | +| `GTIFiscalAgentService.exe` (+ `_internal/`) | onedir | The Windows Service host (bundled inside Setup.exe, extracted to `C:\Program Files\GTI\FiscalAgent\`). Reuses `gti_agent_windows_service.py` logic; token comes from `credentials.bin`. | +| `Uninstall.exe` | onefile | Stops/removes the service, removes Program Files tree + ARP entry. Invoked from Settings → Apps → GT1 Fiscal Agent → Uninstall. | + +Setup.exe behavior: +1. Program runs elevated (requireAdministrator manifest). If not elevated, + Windows UAC prompts. +2. tkinter window: title "GTI Fiscal Agent", a single install-code field, Hub + URL fixed at `https://gtifiscal.gtigrupo.com.br` (no prompt; dev override via + env / CLI flag only). +3. "Instalar" → POST `/v1/agents/enroll` `{"install_code": "XXXX-XXXX-XXXX-XXXX"}`. +4. On success (agent_id, agent_token, hub_url, tenant, release info): + - Write `agent.env` (non-secret): GTI_HUB_URL, AGENT_ID, COMPANY_ID/UF + (optional), FIREBIRD_DATABASE_PATH (blank), FB_USER/FB_PASSWORD blank, + FISCAL_MODE=DRY_RUN, FIREBIRD_WRITES=0, AGENT_VERSION, POLL_INTERVAL_SECONDS, + AGENT_LOG_PATH, AGENT_STATE_DB, EXPECTED_SCHEMA_FINGERPRINT. + - Write `credentials.bin` via DPAPI LocalMachine encryption + (`win32crypt.CryptProtectData(..., CRYPTPROTECT_LOCAL_MACHINE)`); contains + AGENT_TOKEN (and optional secrets). Apply ACL: SYSTEM/Administrators only + (icacls). `agent.env` never holds the token. + - Extract bundled Service host onedir to `C:\Program Files\GTI\FiscalAgent\`. + - Install service `GTIFiscalAgent` (Automatic/Delayed), set recovery + `sc.exe failure "GTIFiscalAgent" reset= 86400 actions= restart/60000/restart/60000/restart/60000`. +5. Preflight: Firebird path not configured → report "serviço instalado; configure + o arquivo de banco para ativar" and do NOT start the service (no error loop). + If fully configured → optionally start. +6. Write ARP uninstall key (DisplayName "GTI Fiscal Agent", + UninstallString = Uninstall.exe). +7. `WINDOWS_INSTALL_COMPLETED` audit (best effort) once install succeeds. + +The installer never stores the enrollment code after success, never embeds any +permanent credential, and never enables Firebird/AI writes (no UI to do so). + +## Installation code (`install_code`) + +- Format: 16 chars, Crockford Base32 alphabet + (`0123456789ABCDEFGHJKMNPQRSTVWXYZ` — excludes I, L, O, U), grouped + `XXXX-XXXX-XXXX-XXXX`. 16 chars × 5 bits = 80 bits entropy. +- Generation: `secrets.randbelow`/random pick of 16 chars; return formatted. +- Normalization before hashing: uppercase, strip hyphens/spaces. +- Storage: only SHA-256 of the normalized code. Never the plaintext. +- Columns on `agent_enrollments` (migration `0023`): + `install_code_hash` (str 64), `install_code_expires_at` (timestamptz), + `install_code_attempts` (int, default 0), `install_code_created_at` (timestamptz). +- TTL: `AGENT_ENROLLMENT_TTL_SECONDS` (default 86400) reused. +- One active code per installation at a time; "Gerar novo código" rotates it + (old code invalidated, previous hash kept for audit but unusable). +- Revocation (`Revogar`) invalidates the code and, when an agent exists, + deactivates it. +- Rate limit: `rate_limit.record_attempt(bucket="enroll_code:"+normalized_code_hash or agent_enrollment_id, origin=IP)` in addition to per-IP bucket. + +## Enrollment endpoint (`POST /v1/agents/enroll`) + +- Body: `{"install_code": "..."}` OR legacy `{"installation_id": ..., "enrollment_token": ...}`. +- If both `install_code` and `enrollment_token` are provided → 400, `ENROLLMENT_REJECTED` (reason INVALID_REQUEST). +- With `install_code` (primary): + - Resolve the Installation by normalized-code hash. No installation_id needed. + - Validate: exists, status=PENDING (not REVOKED), release PUBLISHED, code not + expired, one-time. `install_code_attempts` guard + rate limit. + - On success: digest the code (mark used), set installation status ENROLLED, + create the Agent with a fresh permanent token, return + `{agent_id, agent_token, installation_id, hub_url, ...}`. + - `ENROLLMENT_STARTED / SUCCEEDED / REJECTED` audit; never log the code. +- Legacy `enrollment_token` path kept for backward compatibility (published 1.1.0 + bundles) but is no longer produced for new installations. + +## Downloads + +- `/agents/releases` main button + pending detail + temp link: serve the SAME + universal EXE (`GTIFiscalAgent-Setup-.exe`) stored as the release artifact. +- Filename/querystring carry no code/token. +- `INSTALLER_DOWNLOADED` audit event on download. + +## Release model + +- New release row: `version=1.1.1`, `platform=windows`, `architecture=x64`, + `filename=GTIFiscalAgent-Setup-1.1.1.exe`, `status=PUBLISHED`, + `published_at` (per the prior fix), `sha256`, `size_bytes`. +- Storage path generalized (EXE artifact, not hardcoded `.zip`). The internal + ZIP remains a build/debug artifact only (kept under dist/, not the main flow). +- Release 1.1.0 row and artifact remain untouched. +- After PUBLISHED, the artifact is immutable: publish script refuses to replace + an existing PUBLISHED filename for the same version+platform+arch. + +## Agent runtime changes (FIREBIRD_WRITES=0, DPAPI credential) + +- New module `gti_fiscal_agent/credential_store.py`: + - `read_credential(path)` — decrypt DPAPI LocalMachine blob (fallback: if the + file is a plaintext token, accept in dev only). + - `write_credential(path, payload)` (installer side; reused by Setup.exe) — + encrypt + set ACL. + - `install_env_from_credentials(path, env)` — fill `AGENT_TOKEN` from + `credentials.bin`; `agent.env` stays non-secret. +- `Config`/`main`/service wrapper read the token via + `AGENT_TOKEN` if set, else look for `GTI_CREDENTIAL_FILE` (default + `C:\Program Files\GTI\FiscalAgent\credentials.bin`) and decrypt. +- `FIREBIRD_WRITES=0` enforced (already fail-closed in `Config.firebird_writes` + and `open_write_connection`); drift test asserts the installer template sets 0 + and no UI/flag enables it. + +## Panel UI changes + +- `agent_pending_detail.html` + `agent_detail.html`: show + - Versão, Status, + - Código de instalação `XXXX-XXXX-XXXX-XXXX` + [Copiar] + - Expira em (formatted TTL deadline) + - [Baixar instalador (.EXE)] → EXE + - [Gerar novo código] → rotates code (audit INSTALLATION_CODE_CREATED) + - [Revogar] → revokes (audit INSTALLATION_CODE_REVOKED / INSTALLATION_REVOKED) +- `/agents/releases`: download button → EXE (main flow). +- Never show raw enrollment tokens; the install code is shown to the operator who + created the installation only (SSO-authenticated panel). + +## Build script + +- `scripts/build_windows_installer.ps1` + - Reads official `release/VERSION` (1.1.1). + - Cleans `dist/installer` output. + - Builds service host (onedir) and Setup.exe (onefile, `--uac-admin`) and + Uninstall.exe with PyInstaller from the bundled sources; Setup embeds the + service host dir + Uninstall.exe as data. + - Computes sha256, writes `GTIFiscalAgent-Setup-.exe.sha256`. + - Fails on missing dependency/tool. + - Deterministic inputs; byte-identical output not required. + +## Tests (TDD) + +Central (`central/tests`, pytest): +- install code generation/normalization/hash round trip (format, alphabet, + uppercase, hyphen removal, no ambiguous chars). +- enroll with `install_code` resolves installation by code alone; success creates + enrolled agent + credential issuance; `install_code_attempts` increments; + expiry/revoked/used-one-time → REJECTED; code+token together → 400; + rate limit by IP and by code; duplicate/rotation invalidation. +- migration 0023 up/down. +- publish script refuses to overwrite a PUBLISHED artifact and stamps + `published_at`. +- downloads serve EXE with `INSTALLER_DOWNLOADED` audit. +- panel pages render code/button actions without leaking tokens. + +Agent (`agent/tests` + root `tests`): credential_store encrypt/decrypt, DRY_RUN / +FIREBIRD_WRITES=0 enforcement, `agent.env` has no token, service wrapper reads +credential. + +Build verification: PowerShell script dry-run on dev machine produces EXE + +sha256; PyInstaller starts the setup binary (elevation triggered) or we verify +artifact layout. + +## E2E plan + +1. Local: build 1.1.1 EXE; launch against the PRODUCTION Hub once the backend is + deployed (or against a local Hub in a scratch container for the UI pass). +2. Production Hub (real, at `https://gtifiscal.gtigrupo.com.br`): + - Create an E2E installation via the panel. + - Generate an install code. + - Download the real EXE (assert filename, MIME application/octet-stream / + executable, not ZIP). + - Run on the real Windows dev machine, enter code, complete enrollment + (real `POST /v1/agents/enroll`), verify: + - Installation → ENROLLED, Agent created. + - `credentials.bin` exists (DPAPI-encrypted, not plaintext token). + - `agent.env` has no token, FIREBIRD_WRITES=0, blank Firebird path. + - Service `GTIFiscalAgent` registered, Automatic/Delayed, recovery set, + NOT started (Firebird not configured) — or started if configured. + - Windows firewall/dir ACL ok (SYSTEM/Administrators). + - Uninstall via Uninstall.exe → service removed, Program Files cleaned. + - Revoke/deactivate the E2E agent in the panel; keep audit trail. + - No real Firebird database is ever connected. +3. Rejections E2E: invalid code, expired code, replayed/used code (each audited + ENROLLMENT_REJECTED), code+token together → 400. + +## Deploy sequence + +1. Backup production: PostgreSQL logical dump (`pg_dump`) of `gti_fiscal` + + copy of release storage volume (via container) to the backup location. +2. Build image with migration `0023`; `alembic upgrade head` (fresh-DB and + head checks). +3. Deploy Hub (overlay, docker compose up -d --build), postgres intact. +4. Publish release 1.1.1 EXE (immutable). +5. Production smoke: health endpoint, panel pages, download EXE. +6. Run production E2E (above). +7. Validate restore: restore EXE artifact + PG metadata from backup copy. + +## Constraints (non-negotiables) + +- FIREBIRD_WRITES=0; AI_WRITE_ALLOWED=false; no UI enabling either. +- No real client install this mission. +- No SSO/NPM/DNS/hardware/credentials changes. +- Release 1.1.0 not touched. 1.1.1 immutable once PUBLISHED. +- CODE_SIGNED=false. SmartScreen warning acceptable in the internal pilot. +- Credentials never logged/committed; `GTI_SSH_PASSWORD` env var only. + +## Report (final) + +Populate the mission's exact field list (STATUS, INSTALLER_TECHNOLOGY=PyInstaller, +INSTALLER_REASON, ..., PANEL_DOWNLOAD_FILENAME=GTIFiscalAgent-Setup-1.1.1.exe, +DPAPI=TRUE, ACL=system+administrators, FIREBIRD_WRITES=0, WINDOWS_E2E=..., etc.). \ No newline at end of file