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,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")