feat(01-03): implement Python-native .intunewin file builder

- AES-256-CBC encryption with PKCS7 padding using pycryptodome
- HMAC-SHA256 of ciphertext prepended to blob (mac_key, 32 bytes)
- Encrypted blob layout: HMAC(32) + IV(16) + ciphertext
- IV is 16 bytes (corrected from STACK.md documentation error of 32 bytes)
- Detection.xml with all 8 EncryptionInfo sub-elements and correct namespace
- Inner ZIP uses DEFLATE compression; outer ZIP uses STORED compression
- All 14 byte-level tests pass including crypto roundtrip verification
This commit is contained in:
2026-04-10 11:25:22 +02:00
parent bd4e132f82
commit 25f82e67a2
+111
View File
@@ -0,0 +1,111 @@
"""
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.
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
import xml.dom.minidom
from xml.etree.ElementTree import Element, SubElement, tostring
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
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 ciphertext (using mac_key) ---
mac_digest = hmac.new(mac_key, 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 ---
app_info = Element(
"ApplicationInfo",
attrib={"xmlns": "http://schemas.microsoft.com/IntuneWin"},
)
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, "MacAlgorithm").text = "SHA256"
SubElement(enc_info, "ProfileIdentifier").text = "ProfileVersion1"
SubElement(enc_info, "FileDigest").text = base64.b64encode(file_digest).decode()
SubElement(enc_info, "FileDigestAlgorithm").text = "SHA256"
detection_xml = xml.dom.minidom.parseString(
tostring(app_info, encoding="unicode")
).toprettyxml(indent=" ")
# --- 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"),
)