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.
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/
@@ -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 <?xml?> 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 <?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:
+34 -23
View File
@@ -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 <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 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,29 +62,21 @@ 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
"""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)
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: <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"]
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"]