From 4d455e7b196f90620dee4914fee45e943f42b6da Mon Sep 17 00:00:00 2001 From: Kawa Date: Fri, 10 Apr 2026 11:24:45 +0200 Subject: [PATCH] test(01-03): add failing tests for .intunewin byte-level format - 14 tests covering outer ZIP structure, Detection.xml schema, key sizes - Cryptographic roundtrip: HMAC-SHA256, AES-256-CBC decryption, file digest - IV size assertion: must be 16 bytes (critical correction from RESEARCH.md) - Tests serve as format specification for build_intunewin() implementation --- imptune/generators/__init__.py | 1 + tests/__init__.py | 0 tests/test_intunewin.py | 266 +++++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 imptune/generators/__init__.py create mode 100644 tests/__init__.py create mode 100644 tests/test_intunewin.py diff --git a/imptune/generators/__init__.py b/imptune/generators/__init__.py new file mode 100644 index 0000000..525ac46 --- /dev/null +++ b/imptune/generators/__init__.py @@ -0,0 +1 @@ +"""Generators package for ImpTune.""" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_intunewin.py b/tests/test_intunewin.py new file mode 100644 index 0000000..4f42299 --- /dev/null +++ b/tests/test_intunewin.py @@ -0,0 +1,266 @@ +""" +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). +""" +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 build_intunewin + + +NAMESPACE = "http://schemas.microsoft.com/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 (with namespace).""" + elem = tree.find(f"{{{NAMESPACE}}}{tag}") + if elem is None: + # Try without namespace as fallback + 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 + + +def _get_enc_text(tree, tag): + """Get text of a child of EncryptionInfo.""" + 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 + + +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 in the correct namespace.""" + tree = package_contents["tree"] + assert tree.tag == f"{{{NAMESPACE}}}ApplicationInfo", ( + f"Root element must be ApplicationInfo with namespace {NAMESPACE}, got {tree.tag}" + ) + + def test_detection_xml_fields(self, package_contents): + """Detection.xml contains all required elements including all 8 EncryptionInfo sub-elements.""" + 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) + for field in ( + "EncryptionKey", + "MacKey", + "InitializationVector", + "Mac", + "MacAlgorithm", + "ProfileIdentifier", + "FileDigest", + "FileDigestAlgorithm", + ): + assert _get_enc_text(tree, field) is not None, f"Missing EncryptionInfo/{field}" + + 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 ciphertext matches first 32 bytes of blob AND Mac in Detection.xml.""" + blob = package_contents["blob"] + tree = package_contents["tree"] + + blob_hmac = blob[:32] + ciphertext = blob[48:] + + mac_key_b64 = _get_enc_text(tree, "MacKey") + mac_key = base64.b64decode(mac_key_b64) + + computed_hmac = hmac.new(mac_key, ciphertext, hashlib.sha256).digest() + + # Must match the blob header + assert computed_hmac == blob_hmac, ( + "HMAC-SHA256 of 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 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)}" + )