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:
@@ -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"),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user