diff --git a/imptune/generators/intunewin_builder.py b/imptune/generators/intunewin_builder.py index d7c7b5e..b3d8db4 100644 --- a/imptune/generators/intunewin_builder.py +++ b/imptune/generators/intunewin_builder.py @@ -11,6 +11,12 @@ Encrypted blob layout (from svrooij.io reverse-engineering): 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 (not a child element) + - No xmlns namespace declaration (reference uses [XmlRoot("ApplicationInfo")] with no namespace) + - No declaration header (reference uses OmitXmlDeclaration=true) + - No element (not present in reference FileEncryptionInfo model) + Outer ZIP structure: IntuneWinPackage/ ├── Contents/ @@ -24,13 +30,18 @@ import hmac import io import os import zipfile -import xml.dom.minidom -from xml.etree.ElementTree import Element, SubElement, tostring +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. @@ -76,9 +87,14 @@ def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None: 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 declaration header + # - No MacAlgorithm element (not in reference FileEncryptionInfo model) app_info = Element( "ApplicationInfo", - attrib={"xmlns": "http://schemas.microsoft.com/IntuneWin"}, + attrib={"ToolVersion": _TOOL_VERSION}, ) SubElement(app_info, "Name").text = setup_file SubElement(app_info, "UnencryptedContentSize").text = str(len(plaintext)) @@ -90,14 +106,14 @@ def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None: 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=" ") + # indent() adds pretty-print whitespace in-place (Python 3.9+). + # tostring with xml_declaration=False omits the 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: diff --git a/tests/test_intunewin.py b/tests/test_intunewin.py index 4f42299..f1f7093 100644 --- a/tests/test_intunewin.py +++ b/tests/test_intunewin.py @@ -2,7 +2,13 @@ Byte-level validation tests for the .intunewin file format. These tests serve as the format specification: if they pass, the byte layout is correct. -The only remaining validation is a real Intune upload (manual, Phase 5 gate). +The only remaining validation is a real Intune upload (manual, Phase 10 gate). + +Detection.xml format follows the IntuneWinAppUtil.exe reference exactly: + - ToolVersion is an XML *attribute* on (not a child element) + - No xmlns namespace (reference uses [XmlRoot("ApplicationInfo")] with no Namespace param) + - No declaration header (reference uses OmitXmlDeclaration=true) + - No MacAlgorithm element (not present in reference FileEncryptionInfo model) """ import base64 import hashlib @@ -16,10 +22,7 @@ import pytest from Crypto.Cipher import AES from Crypto.Util.Padding import unpad -from imptune.generators.intunewin_builder import build_intunewin - - -NAMESPACE = "http://schemas.microsoft.com/IntuneWin" +from imptune.generators.intunewin_builder import _TOOL_VERSION, build_intunewin @pytest.fixture @@ -59,30 +62,22 @@ def package_contents(built_package): def _get_xml_text(tree, tag): - """Get text of a direct child element (with namespace).""" - elem = tree.find(f"{{{NAMESPACE}}}{tag}") - if elem is None: - # Try without namespace as fallback - elem = tree.find(tag) + """Get text of a direct child element (no namespace — reference omits xmlns).""" + elem = tree.find(tag) return elem.text if elem is not None else None def _get_encryption_info(tree): - """Get EncryptionInfo sub-element.""" - enc = tree.find(f"{{{NAMESPACE}}}EncryptionInfo") - if enc is None: - enc = tree.find("EncryptionInfo") - return enc + """Get EncryptionInfo sub-element (no namespace — reference omits xmlns).""" + return tree.find("EncryptionInfo") def _get_enc_text(tree, tag): - """Get text of a child of EncryptionInfo.""" + """Get text of a child of EncryptionInfo (no namespace).""" enc = _get_encryption_info(tree) if enc is None: return None - elem = enc.find(f"{{{NAMESPACE}}}{tag}") - if elem is None: - elem = enc.find(tag) + elem = enc.find(tag) return elem.text if elem is not None else None @@ -108,32 +103,48 @@ class TestOuterZipStructure: class TestDetectionXml: def test_detection_xml_valid(self, package_contents): - """Detection.xml is valid XML with ApplicationInfo root element in the correct namespace.""" + """Detection.xml is valid XML with ApplicationInfo root element and ToolVersion attribute. + + Reference format: with NO xmlns namespace. + Presence of xmlns would change element identity for Intune's XML parser, causing + silent metadata-parse failure in the upload wizard. + """ tree = package_contents["tree"] - assert tree.tag == f"{{{NAMESPACE}}}ApplicationInfo", ( - f"Root element must be ApplicationInfo with namespace {NAMESPACE}, got {tree.tag}" + assert tree.tag == "ApplicationInfo", ( + f"Root element must be plain 'ApplicationInfo' (no xmlns namespace), got {tree.tag!r}" + ) + assert tree.get("ToolVersion") == _TOOL_VERSION, ( + f"ApplicationInfo must have ToolVersion attribute = {_TOOL_VERSION!r}, " + f"got {tree.get('ToolVersion')!r}" ) def test_detection_xml_fields(self, package_contents): - """Detection.xml contains all required elements including all 8 EncryptionInfo sub-elements.""" + """Detection.xml contains all required elements matching the reference FileEncryptionInfo model. + + The reference model has 7 EncryptionInfo sub-elements (MacAlgorithm is NOT present). + """ tree = package_contents["tree"] # Direct children for field in ("Name", "UnencryptedContentSize", "FileName", "SetupFile"): assert _get_xml_text(tree, field) is not None, f"Missing field: {field}" - # EncryptionInfo sub-elements (all 8 required) + # EncryptionInfo sub-elements — 7 required (MacAlgorithm absent per reference schema) for field in ( "EncryptionKey", "MacKey", "InitializationVector", "Mac", - "MacAlgorithm", "ProfileIdentifier", "FileDigest", "FileDigestAlgorithm", ): assert _get_enc_text(tree, field) is not None, f"Missing EncryptionInfo/{field}" + # MacAlgorithm must NOT be present (not in reference FileEncryptionInfo model) + assert _get_enc_text(tree, "MacAlgorithm") is None, ( + "EncryptionInfo/MacAlgorithm must NOT be present — not in reference schema" + ) + def test_setup_file_in_detection_xml(self, package_contents): """SetupFile element matches the setup_file argument passed to build_intunewin.""" tree = package_contents["tree"]