fix(intunewin): align Detection.xml with IntuneWinAppUtil.exe reference format

Four structural defects in the generated Detection.xml caused Intune's upload
wizard to silently fail metadata parsing (empty form, OK button greyed):

1. Missing ToolVersion="1.8.6.0" XML attribute on ApplicationInfo — the wizard
   uses this to validate the package was produced by a compatible tool.
2. Spurious xmlns="http://schemas.microsoft.com/IntuneWin" namespace — changes
   element identity for Intune's XML parser (reference emits no namespace).
3. <?xml version="1.0" ?> declaration header — reference uses OmitXmlDeclaration=true.
4. Extra <MacAlgorithm> child element inside EncryptionInfo — not present in
   the reference FileEncryptionInfo model (svrooij/ContentPrep verified).

Fix: switched from toprettyxml() to tostring(xml_declaration=False)+indent(),
added ToolVersion attribute, removed xmlns and MacAlgorithm.
Tests updated to assert the corrected reference format; all 114 pass.

Root cause verified against svrooij/ContentPrep Packager.cs + ApplicationInfo.cs
(open-source C# reference implementation of IntuneWinAppUtil.exe).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 11:48:36 +02:00
co-authored by Claude Sonnet 4.6
parent 46cfde00e0
commit 77162466d5
2 changed files with 59 additions and 32 deletions
+23 -7
View File
@@ -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. 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: Outer ZIP structure:
IntuneWinPackage/ IntuneWinPackage/
├── Contents/ ├── Contents/
@@ -24,13 +30,18 @@ import hmac
import io import io
import os import os
import zipfile import zipfile
import xml.dom.minidom from xml.etree.ElementTree import Element, SubElement, indent, tostring
from xml.etree.ElementTree import Element, SubElement, tostring
from Crypto.Cipher import AES from Crypto.Cipher import AES
from Crypto.Util.Padding import pad 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: 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. """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() file_digest = hashlib.sha256(plaintext).digest()
# --- Step 7: Build Detection.xml --- # --- 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( app_info = Element(
"ApplicationInfo", "ApplicationInfo",
attrib={"xmlns": "http://schemas.microsoft.com/IntuneWin"}, attrib={"ToolVersion": _TOOL_VERSION},
) )
SubElement(app_info, "Name").text = setup_file SubElement(app_info, "Name").text = setup_file
SubElement(app_info, "UnencryptedContentSize").text = str(len(plaintext)) 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, "MacKey").text = base64.b64encode(mac_key).decode()
SubElement(enc_info, "InitializationVector").text = base64.b64encode(iv).decode() SubElement(enc_info, "InitializationVector").text = base64.b64encode(iv).decode()
SubElement(enc_info, "Mac").text = base64.b64encode(mac_digest).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, "ProfileIdentifier").text = "ProfileVersion1"
SubElement(enc_info, "FileDigest").text = base64.b64encode(file_digest).decode() SubElement(enc_info, "FileDigest").text = base64.b64encode(file_digest).decode()
SubElement(enc_info, "FileDigestAlgorithm").text = "SHA256" SubElement(enc_info, "FileDigestAlgorithm").text = "SHA256"
detection_xml = xml.dom.minidom.parseString( # indent() adds pretty-print whitespace in-place (Python 3.9+).
tostring(app_info, encoding="unicode") # tostring with xml_declaration=False omits the <?xml?> header.
).toprettyxml(indent=" ") 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) --- # --- Step 8: Build outer ZIP (STORED — no extra compression on encrypted content) ---
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer: with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer:
+34 -23
View File
@@ -2,7 +2,13 @@
Byte-level validation tests for the .intunewin file format. Byte-level validation tests for the .intunewin file format.
These tests serve as the format specification: if they pass, the byte layout is correct. 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 <ApplicationInfo> (not a child element)
- No xmlns namespace (reference uses [XmlRoot("ApplicationInfo")] with no Namespace param)
- No <?xml?> declaration header (reference uses OmitXmlDeclaration=true)
- No MacAlgorithm element (not present in reference FileEncryptionInfo model)
""" """
import base64 import base64
import hashlib import hashlib
@@ -16,10 +22,7 @@ import pytest
from Crypto.Cipher import AES from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad from Crypto.Util.Padding import unpad
from imptune.generators.intunewin_builder import build_intunewin from imptune.generators.intunewin_builder import _TOOL_VERSION, build_intunewin
NAMESPACE = "http://schemas.microsoft.com/IntuneWin"
@pytest.fixture @pytest.fixture
@@ -59,29 +62,21 @@ def package_contents(built_package):
def _get_xml_text(tree, tag): def _get_xml_text(tree, tag):
"""Get text of a direct child element (with namespace).""" """Get text of a direct child element (no namespace — reference omits xmlns)."""
elem = tree.find(f"{{{NAMESPACE}}}{tag}")
if elem is None:
# Try without namespace as fallback
elem = tree.find(tag) elem = tree.find(tag)
return elem.text if elem is not None else None return elem.text if elem is not None else None
def _get_encryption_info(tree): def _get_encryption_info(tree):
"""Get EncryptionInfo sub-element.""" """Get EncryptionInfo sub-element (no namespace — reference omits xmlns)."""
enc = tree.find(f"{{{NAMESPACE}}}EncryptionInfo") return tree.find("EncryptionInfo")
if enc is None:
enc = tree.find("EncryptionInfo")
return enc
def _get_enc_text(tree, tag): 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) enc = _get_encryption_info(tree)
if enc is None: if enc is None:
return 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 return elem.text if elem is not None else None
@@ -108,32 +103,48 @@ class TestOuterZipStructure:
class TestDetectionXml: class TestDetectionXml:
def test_detection_xml_valid(self, package_contents): 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: <ApplicationInfo ToolVersion="1.8.6.0"> 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"] tree = package_contents["tree"]
assert tree.tag == f"{{{NAMESPACE}}}ApplicationInfo", ( assert tree.tag == "ApplicationInfo", (
f"Root element must be ApplicationInfo with namespace {NAMESPACE}, got {tree.tag}" 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): 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"] tree = package_contents["tree"]
# Direct children # Direct children
for field in ("Name", "UnencryptedContentSize", "FileName", "SetupFile"): for field in ("Name", "UnencryptedContentSize", "FileName", "SetupFile"):
assert _get_xml_text(tree, field) is not None, f"Missing field: {field}" 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 ( for field in (
"EncryptionKey", "EncryptionKey",
"MacKey", "MacKey",
"InitializationVector", "InitializationVector",
"Mac", "Mac",
"MacAlgorithm",
"ProfileIdentifier", "ProfileIdentifier",
"FileDigest", "FileDigest",
"FileDigestAlgorithm", "FileDigestAlgorithm",
): ):
assert _get_enc_text(tree, field) is not None, f"Missing EncryptionInfo/{field}" 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): def test_setup_file_in_detection_xml(self, package_contents):
"""SetupFile element matches the setup_file argument passed to build_intunewin.""" """SetupFile element matches the setup_file argument passed to build_intunewin."""
tree = package_contents["tree"] tree = package_contents["tree"]