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>
191 lines
8.3 KiB
Python
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"),
|
|
)
|