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:
@@ -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:
|
||||||
|
|||||||
+36
-25
@@ -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,30 +62,22 @@ 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}")
|
elem = tree.find(tag)
|
||||||
if elem is None:
|
|
||||||
# Try without namespace as fallback
|
|
||||||
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}")
|
elem = enc.find(tag)
|
||||||
if elem is None:
|
|
||||||
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user