Files
ImpTune/imptune/generators/intunewin_builder.py
T
kawaandClaude Opus 5 b397d3dc3d feat: memory-only sessions on HTTP, streamed exports, UI refresh
Session

- COOKIE_SECURE=false no longer persists the owner key for ten years.
  services/session.cookie_kwargs() drops max_age in that mode, so the
  browser holds the key in memory and the session ends with the window.
  Everything still persists server-side; only the browser link is
  temporary. base.html shows a warning banner (FR/EN) and an extra
  paragraph in the onboarding modal, and the README explains the
  trade-off and the backup-key escape hatch.
- Both cookie writers (middleware, POST /session/restore) go through
  cookie_kwargs() so the policy cannot drift between them.
- The CSRF guard on /session/restore compared request.url.scheme against
  the Origin header. Behind a TLS-terminating proxy uvicorn sees http
  while the browser sends https, so every legitimate restore was
  rejected with 403. It now compares hosts only, including
  X-Forwarded-Host.
- /static/*, /favicon.ico and /robots.txt skip the middleware. Each
  cookieless hit was inserting an Owner row no browser could ever use.

Reliability

- Malformed printer-form FK fields no longer escape as HTTP 500:
  a non-numeric client_id/driver_id raised ValueError and an unknown
  driver_id hit a FOREIGN KEY constraint. Both are now 400/404 HTMX
  fragments, and the duplicated field checks moved into
  _validate_fields().
- Package exports stream. build_intunewin() encrypts the inner ZIP in
  1 MB chunks against temp files with a streaming HMAC and SHA256, and
  both endpoints serve the result with FileResponse plus a background
  cleanup task. A 100 MB driver used to be held in memory three or four
  times over per concurrent download. The byte layout is unchanged.
- FileResponse also escapes the download filename, which was previously
  interpolated raw into Content-Disposition.
- python-multipart >= 0.0.18 (CVE-2024-53981, reachable from
  /drivers/upload) and Pillow >= 10.3 (CVE-2024-28219, reachable from
  icon upload).
- icons.py reads cfg.ICONS_DIR instead of re-deriving the path from
  DATA_DIR, matching the .intunewin export.

UI

- Sidebar/topbar shell, inline SVG icon macros (partials/icons.html),
  card and data-table components, grouped printer list, and the
  dedicated /printers/new page replacing partials/printer_form.html.

Tests

- 194 pass with a bare `pytest tests/`: tests/conftest.py now forces
  cfg.COOKIE_SECURE = False like the e2e conftest already did, so the
  Secure cookie is no longer dropped over http://testserver.
- New coverage for the malformed-FK guards, the chunk-boundary cases in
  the encrypt loop (every residue mod _CHUNK plus a multi-megabyte
  payload), temp-dir cleanup after both exports, and the whole
  COOKIE_SECURE matrix.
- test_printer_edit.py located the Edit button by its translated label,
  so it only passed on English-locale machines. It now targets the
  showModal() hook, which also cuts the e2e run from 84s to 15s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:49 +02:00

191 lines
8.3 KiB
Python

"""
Python-native .intunewin file assembler.
Generates valid .intunewin packages using AES-256-CBC encryption with HMAC-SHA256,
producing the exact byte layout that Microsoft Intune expects.
Encrypted blob layout (from svrooij.io reverse-engineering):
[0:32] HMAC-SHA256 of the ciphertext (32 bytes, mac_key)
[32:48] AES-256-CBC Initialization Vector (16 bytes — standard AES block size)
[48:] AES-256-CBC ciphertext (PKCS7-padded to 16-byte boundary)
IMPORTANT: IV is 16 bytes, NOT 32. STACK.md has a documentation error on this point.
Detection.xml format matches the reference IntuneWinAppUtil.exe output exactly:
- ToolVersion is an XML *attribute* on <ApplicationInfo> (not a child element)
- No xmlns namespace declaration (reference uses [XmlRoot("ApplicationInfo")] with no namespace)
- No <?xml ...?> declaration header (reference uses OmitXmlDeclaration=true)
- No <MacAlgorithm> element (not present in reference FileEncryptionInfo model)
Outer ZIP structure:
IntuneWinPackage/
├── Contents/
│ └── IntunePackage.intunewin (the encrypted blob)
└── Metadata/
└── Detection.xml (encryption metadata)
"""
import base64
import hashlib
import hmac
import os
import shutil
import tempfile
import zipfile
from xml.etree.ElementTree import Element, SubElement, indent, tostring
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
# Version string that matches the reference IntuneWinAppUtil.exe tool.
# Intune's upload wizard validates or uses this field to confirm the package
# was produced by a compatible tool version.
_TOOL_VERSION = "1.8.6.0"
# Streaming chunk size. Must be a multiple of AES.block_size (16) so every
# chunk but the last is a whole number of CBC blocks.
_CHUNK = 1024 * 1024
def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
"""Build a .intunewin file from source_dir, with setup_file as entry point.
Args:
source_dir: Path to the directory containing files to package.
setup_file: Name of the setup/entry-point file (e.g., "install.ps1").
Must be present in source_dir. Used in Detection.xml metadata.
output_path: Destination path for the generated .intunewin file.
Raises:
FileNotFoundError: If source_dir does not exist.
ValueError: If setup_file is empty.
Driver payloads run to ~100 MB, so every step streams through 1 MB chunks
against temp files: buffering the inner ZIP, its ciphertext and the outer
ZIP in memory held 3-4 full copies of the package per concurrent download.
"""
if not setup_file:
raise ValueError("setup_file must not be empty")
if not os.path.isdir(source_dir):
raise FileNotFoundError(f"source_dir does not exist: {source_dir}")
with tempfile.TemporaryDirectory(prefix="intunewin_") as staging:
inner_zip_path = os.path.join(staging, "inner.zip")
ciphertext_path = os.path.join(staging, "inner.enc")
# --- Step 1: Create inner ZIP (DEFLATE-compressed content) ---
with zipfile.ZipFile(inner_zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(source_dir):
dirs.sort() # deterministic ordering
for filename in sorted(files):
abs_path = os.path.join(root, filename)
arc_name = os.path.relpath(abs_path, source_dir)
# Normalise to forward slashes for cross-platform consistency
arc_name = arc_name.replace("\\", "/")
zf.write(abs_path, arc_name)
plaintext_size = os.path.getsize(inner_zip_path)
# --- Step 2: Generate random keys and IV ---
aes_key = os.urandom(32) # 256-bit AES key
mac_key = os.urandom(32) # 256-bit HMAC key (same size as AES key)
iv = os.urandom(16) # 128-bit IV — standard AES-CBC block size (NOT 32 bytes)
# --- Steps 3-4: Encrypt (AES-256-CBC, PKCS7) while MAC-ing and digesting ---
# The reference (svrooij/ContentPrep Zipper.cs DecryptFileAsync) reads the first
# 32 bytes as the stored HMAC, then computes the hash of the *remaining* bytes
# (= IV || ciphertext) to verify integrity. Authenticated-encryption best
# practice (Encrypt-then-MAC) also requires the IV to be covered by the MAC so
# that a forged IV cannot redirect decryption.
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
mac = hmac.new(mac_key, iv, hashlib.sha256)
plaintext_hash = hashlib.sha256()
with open(inner_zip_path, "rb") as src, open(ciphertext_path, "wb") as dst:
while True:
# BufferedReader.read(n) returns n bytes unless EOF, so a short
# read means "last chunk" — the only one that carries padding.
chunk = src.read(_CHUNK)
plaintext_hash.update(chunk)
if len(chunk) < _CHUNK:
block = cipher.encrypt(pad(chunk, AES.block_size))
mac.update(block)
dst.write(block)
break
block = cipher.encrypt(chunk)
mac.update(block)
dst.write(block)
mac_digest = mac.digest()
# --- Step 6: Plaintext (inner ZIP) SHA256 digest for Detection.xml ---
file_digest = plaintext_hash.digest()
return _finalize(
output_path=output_path,
ciphertext_path=ciphertext_path,
setup_file=setup_file,
plaintext_size=plaintext_size,
aes_key=aes_key,
mac_key=mac_key,
iv=iv,
mac_digest=mac_digest,
file_digest=file_digest,
)
def _finalize(
*,
output_path: str,
ciphertext_path: str,
setup_file: str,
plaintext_size: int,
aes_key: bytes,
mac_key: bytes,
iv: bytes,
mac_digest: bytes,
file_digest: bytes,
) -> None:
"""Write Detection.xml plus the [HMAC(32)][IV(16)][ciphertext] blob into the outer ZIP."""
# --- Step 7: Build Detection.xml ---
# Format MUST match IntuneWinAppUtil.exe reference output exactly:
# - ToolVersion is an XML attribute on ApplicationInfo (not a child element)
# - No xmlns namespace (reference omits it)
# - No <?xml?> declaration header
# - No MacAlgorithm element (not in reference FileEncryptionInfo model)
app_info = Element(
"ApplicationInfo",
attrib={"ToolVersion": _TOOL_VERSION},
)
SubElement(app_info, "Name").text = setup_file
SubElement(app_info, "UnencryptedContentSize").text = str(plaintext_size)
SubElement(app_info, "FileName").text = "IntunePackage.intunewin"
SubElement(app_info, "SetupFile").text = setup_file
enc_info = SubElement(app_info, "EncryptionInfo")
SubElement(enc_info, "EncryptionKey").text = base64.b64encode(aes_key).decode()
SubElement(enc_info, "MacKey").text = base64.b64encode(mac_key).decode()
SubElement(enc_info, "InitializationVector").text = base64.b64encode(iv).decode()
SubElement(enc_info, "Mac").text = base64.b64encode(mac_digest).decode()
SubElement(enc_info, "ProfileIdentifier").text = "ProfileVersion1"
SubElement(enc_info, "FileDigest").text = base64.b64encode(file_digest).decode()
SubElement(enc_info, "FileDigestAlgorithm").text = "SHA256"
# indent() adds pretty-print whitespace in-place (Python 3.9+).
# tostring with xml_declaration=False omits the <?xml?> header.
indent(app_info, space=" ")
detection_xml = tostring(app_info, encoding="unicode", xml_declaration=False)
# --- Step 8: Build outer ZIP (STORED — no extra compression on encrypted content) ---
# The blob is streamed in as [HMAC(32)] + [IV(16)] + [ciphertext] so the
# encrypted payload is never materialised as a second in-memory copy.
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer:
with outer.open("IntuneWinPackage/Contents/IntunePackage.intunewin", "w") as dest:
dest.write(mac_digest)
dest.write(iv)
with open(ciphertext_path, "rb") as ct:
shutil.copyfileobj(ct, dest, _CHUNK)
outer.writestr(
"IntuneWinPackage/Metadata/Detection.xml",
detection_xml.encode("utf-8"),
)