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
@@ -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