Files
ImpTune/tests/test_intunewin.py
T
2026-04-15 17:57:12 +02:00

284 lines
12 KiB
Python

"""
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 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
import hmac
import io
import os
import xml.etree.ElementTree as ET
import zipfile
import pytest
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from imptune.generators.intunewin_builder import _TOOL_VERSION, build_intunewin
@pytest.fixture
def source_dir(tmp_path):
"""Create a small test source directory with a few files."""
src = tmp_path / "source"
src.mkdir()
(src / "install.ps1").write_text("Write-Host 'Installing printer...'")
(src / "config.json").write_text('{"printer": "HP LaserJet"}')
(src / "readme.txt").write_text("Printer deployment package")
return str(src)
@pytest.fixture
def built_package(source_dir, tmp_path):
"""Build an .intunewin package and return the path."""
output = str(tmp_path / "package.intunewin")
build_intunewin(source_dir, "install.ps1", output)
return output
@pytest.fixture
def package_contents(built_package):
"""Extract outer ZIP contents and parsed Detection.xml."""
with zipfile.ZipFile(built_package, "r") as outer:
names = outer.namelist()
blob = outer.read("IntuneWinPackage/Contents/IntunePackage.intunewin")
detection_xml_bytes = outer.read("IntuneWinPackage/Metadata/Detection.xml")
tree = ET.fromstring(detection_xml_bytes.decode("utf-8"))
return {
"names": names,
"blob": blob,
"detection_xml_bytes": detection_xml_bytes,
"tree": tree,
}
def _get_xml_text(tree, 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 (no namespace — reference omits xmlns)."""
return tree.find("EncryptionInfo")
def _get_enc_text(tree, tag):
"""Get text of a child of EncryptionInfo (no namespace)."""
enc = _get_encryption_info(tree)
if enc is None:
return None
elem = enc.find(tag)
return elem.text if elem is not None else None
class TestOuterZipStructure:
def test_output_is_valid_zip(self, built_package):
"""build_intunewin() output file is a valid ZIP archive."""
assert zipfile.is_zipfile(built_package), "Output file must be a valid ZIP archive"
def test_outer_zip_structure(self, package_contents):
"""Outer ZIP contains exactly the two required entries."""
names = set(package_contents["names"])
assert "IntuneWinPackage/Contents/IntunePackage.intunewin" in names
assert "IntuneWinPackage/Metadata/Detection.xml" in names
def test_outer_zip_stored(self, built_package):
"""Outer ZIP entries use ZIP_STORED compression (no extra compression on encrypted content)."""
with zipfile.ZipFile(built_package, "r") as outer:
for info in outer.infolist():
assert info.compress_type == zipfile.ZIP_STORED, (
f"Entry {info.filename} must use ZIP_STORED, got compress_type={info.compress_type}"
)
class TestDetectionXml:
def test_detection_xml_valid(self, package_contents):
"""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 == "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 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 — 7 required (MacAlgorithm absent per reference schema)
for field in (
"EncryptionKey",
"MacKey",
"InitializationVector",
"Mac",
"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"]
assert _get_xml_text(tree, "SetupFile") == "install.ps1"
class TestEncryptedBlobLayout:
def test_encrypted_blob_layout(self, package_contents):
"""Encrypted blob starts with 32 bytes (HMAC) + 16 bytes (IV) + remainder (ciphertext)."""
blob = package_contents["blob"]
# Must be at least 48 bytes (HMAC + IV) plus at least one AES block (16 bytes)
assert len(blob) >= 64, f"Blob too short: {len(blob)} bytes"
# Total length = 48 header + ciphertext length; ciphertext length is a multiple of 16
ciphertext_len = len(blob) - 48
assert ciphertext_len > 0, "Blob has no ciphertext after header"
assert ciphertext_len % 16 == 0, (
f"Ciphertext length {ciphertext_len} must be a multiple of AES block size 16"
)
def test_iv_is_16_bytes(self, package_contents):
"""IV extracted from Detection.xml base64-decodes to exactly 16 bytes (NOT 32 — critical per RESEARCH.md)."""
tree = package_contents["tree"]
iv_b64 = _get_enc_text(tree, "InitializationVector")
assert iv_b64 is not None, "InitializationVector missing from Detection.xml"
iv = base64.b64decode(iv_b64)
assert len(iv) == 16, f"IV must be exactly 16 bytes, got {len(iv)}"
def test_encryption_key_is_32_bytes(self, package_contents):
"""EncryptionKey from Detection.xml base64-decodes to exactly 32 bytes."""
tree = package_contents["tree"]
key_b64 = _get_enc_text(tree, "EncryptionKey")
assert key_b64 is not None, "EncryptionKey missing from Detection.xml"
key = base64.b64decode(key_b64)
assert len(key) == 32, f"EncryptionKey must be exactly 32 bytes, got {len(key)}"
def test_mac_key_is_32_bytes(self, package_contents):
"""MacKey from Detection.xml base64-decodes to exactly 32 bytes."""
tree = package_contents["tree"]
mac_key_b64 = _get_enc_text(tree, "MacKey")
assert mac_key_b64 is not None, "MacKey missing from Detection.xml"
mac_key = base64.b64decode(mac_key_b64)
assert len(mac_key) == 32, f"MacKey must be exactly 32 bytes, got {len(mac_key)}"
class TestCryptographicVerification:
def test_hmac_matches(self, package_contents):
"""HMAC-SHA256 of (IV || ciphertext) matches first 32 bytes of blob AND Mac in Detection.xml.
The reference implementation (svrooij/ContentPrep Zipper.cs DecryptFileAsync) reads
the first 32 bytes as the stored HMAC, then computes the hash of the *remaining* bytes
(bytes[32:] = IV || ciphertext) to verify integrity. The IV MUST be included in the
HMAC so that a forged IV cannot redirect decryption without being detected.
"""
blob = package_contents["blob"]
tree = package_contents["tree"]
blob_hmac = blob[:32]
iv_and_ciphertext = blob[32:] # IV (16 bytes) + ciphertext — what the reference hashes
mac_key_b64 = _get_enc_text(tree, "MacKey")
mac_key = base64.b64decode(mac_key_b64)
computed_hmac = hmac.new(mac_key, iv_and_ciphertext, hashlib.sha256).digest()
# Must match the blob header
assert computed_hmac == blob_hmac, (
"HMAC-SHA256 of (IV || ciphertext) does not match the first 32 bytes of the blob"
)
# Must also match Detection.xml Mac field
xml_mac = base64.b64decode(_get_enc_text(tree, "Mac"))
assert computed_hmac == xml_mac, (
"HMAC-SHA256 of (IV || ciphertext) does not match the Mac value in Detection.xml"
)
def test_decryption_roundtrip(self, source_dir, package_contents):
"""Decrypt the ciphertext and verify it is a valid ZIP containing the original source files."""
blob = package_contents["blob"]
tree = package_contents["tree"]
aes_key = base64.b64decode(_get_enc_text(tree, "EncryptionKey"))
iv = base64.b64decode(_get_enc_text(tree, "InitializationVector"))
ciphertext = blob[48:]
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
# Must be a valid ZIP
assert zipfile.is_zipfile(io.BytesIO(plaintext)), (
"Decrypted plaintext is not a valid ZIP file"
)
# Must contain the original source files
with zipfile.ZipFile(io.BytesIO(plaintext), "r") as inner_zip:
inner_names = set(inner_zip.namelist())
for filename in ("install.ps1", "config.json", "readme.txt"):
assert filename in inner_names, (
f"Original file {filename} not found in decrypted inner ZIP. Found: {inner_names}"
)
def test_file_digest_matches(self, package_contents):
"""FileDigest in Detection.xml matches SHA256 of the decrypted plaintext ZIP."""
blob = package_contents["blob"]
tree = package_contents["tree"]
aes_key = base64.b64decode(_get_enc_text(tree, "EncryptionKey"))
iv = base64.b64decode(_get_enc_text(tree, "InitializationVector"))
ciphertext = blob[48:]
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
computed_digest = hashlib.sha256(plaintext).digest()
xml_digest = base64.b64decode(_get_enc_text(tree, "FileDigest"))
assert computed_digest == xml_digest, (
"FileDigest in Detection.xml does not match SHA256 of decrypted plaintext"
)
def test_unencrypted_content_size(self, package_contents):
"""UnencryptedContentSize in Detection.xml matches byte length of decrypted plaintext ZIP."""
blob = package_contents["blob"]
tree = package_contents["tree"]
aes_key = base64.b64decode(_get_enc_text(tree, "EncryptionKey"))
iv = base64.b64decode(_get_enc_text(tree, "InitializationVector"))
ciphertext = blob[48:]
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
xml_size = int(_get_xml_text(tree, "UnencryptedContentSize"))
assert xml_size == len(plaintext), (
f"UnencryptedContentSize {xml_size} does not match actual plaintext size {len(plaintext)}"
)