Commit initial

This commit is contained in:
2026-04-15 17:57:12 +02:00
parent 005d8e797e
commit 55516ee10f
269 changed files with 26854 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Generators package for ImpTune."""
+132
View File
@@ -0,0 +1,132 @@
"""
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 io
import os
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"
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.
"""
# --- Step 1: Create inner ZIP (DEFLATE-compressed content) ---
inner_zip_buf = io.BytesIO()
with zipfile.ZipFile(inner_zip_buf, "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 = inner_zip_buf.getvalue()
# --- 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)
# --- Step 3: Encrypt with AES-256-CBC (PKCS7 padding) ---
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
# --- Step 4: Compute HMAC-SHA256 over (IV + ciphertext) using mac_key ---
# 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.
mac_digest = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
# --- Step 5: Assemble encrypted blob: [HMAC(32)] + [IV(16)] + [ciphertext] ---
encrypted_blob = mac_digest + iv + ciphertext
# --- Step 6: Compute plaintext (inner ZIP) SHA256 digest for Detection.xml ---
file_digest = hashlib.sha256(plaintext).digest()
# --- 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(len(plaintext))
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) ---
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer:
outer.writestr(
"IntuneWinPackage/Contents/IntunePackage.intunewin",
encrypted_blob,
)
outer.writestr(
"IntuneWinPackage/Metadata/Detection.xml",
detection_xml.encode("utf-8"),
)
+105
View File
@@ -0,0 +1,105 @@
"""Script generator module — renders PowerShell scripts from Jinja2 templates.
Provides render_install(), render_uninstall(), and render_detect() which produce
complete PowerShell scripts for Intune deployment:
- render_install: WOW64 guard, UAC self-elevation, pnputil two-step, idempotent setup
- render_uninstall: Ordered removal of printer, driver, and port
- render_detect: Intune detection contract (Write-Output + exit 0/1)
"""
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
_SCRIPTS_DIR = Path(__file__).parent.parent / "templates" / "scripts"
_env = Environment(
loader=FileSystemLoader(str(_SCRIPTS_DIR)),
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True,
)
_duplex_map = {
"OneSided": "OneSided",
"LongEdge": "TwoSidedLongEdge",
"ShortEdge": "TwoSidedShortEdge",
}
def render_install(
printer_name: str,
ip_address: str,
port_name: str,
driver_name: str,
inf_filename: str,
duplex_mode: str,
color_mode: bool,
paper_size: str,
collate: bool,
) -> str:
"""Render install.ps1.j2 with the given printer configuration.
Args:
printer_name: Display name of the printer.
ip_address: IP address for the printer TCP/IP port.
port_name: Port name (e.g. "IP_192.168.1.10").
driver_name: Exact driver name as registered in Windows.
inf_filename: INF filename inside the drivers/ subfolder.
duplex_mode: One of "OneSided", "LongEdge", "ShortEdge" (model values).
color_mode: True for color printing, False for mono.
paper_size: Paper size string (e.g. "A4", "Letter").
collate: True to enable collation.
Returns:
Rendered PowerShell script as a string.
"""
tpl = _env.get_template("install.ps1.j2")
return tpl.render(
printer_name=printer_name,
ip_address=ip_address,
port_name=port_name,
driver_name=driver_name,
inf_filename=inf_filename,
duplex_mode=_duplex_map.get(duplex_mode, duplex_mode),
color=str(color_mode).lower(),
paper_size=paper_size,
collate=str(collate).lower(),
)
def render_uninstall(printer_name: str, driver_name: str, port_name: str) -> str:
"""Render uninstall.ps1.j2 — removes printer, driver, and port in safe order.
Removal order: Printer first (so driver is no longer referenced), then Driver,
then Port. All operations use -ErrorAction SilentlyContinue for idempotency.
Args:
printer_name: Display name of the printer to remove.
driver_name: Exact driver name as registered in Windows.
port_name: Port name (e.g. "IP_192.168.1.10").
Returns:
Rendered PowerShell script as a string.
"""
tpl = _env.get_template("uninstall.ps1.j2")
return tpl.render(
printer_name=printer_name,
driver_name=driver_name,
port_name=port_name,
)
def render_detect(printer_name: str) -> str:
"""Render detect.ps1.j2 — Intune detection script.
Follows the Intune detection contract: Write-Output + exit 0 when printer
is found, exit 1 when absent. Intune considers exit 0 as "app installed".
Args:
printer_name: Display name of the printer to detect.
Returns:
Rendered PowerShell script as a string.
"""
tpl = _env.get_template("detect.ps1.j2")
return tpl.render(printer_name=printer_name)