Files
ImpTune/tests/test_intunewin.py
T
kawa 4d455e7b19 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
2026-04-10 11:24:45 +02:00

267 lines
10 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 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)}"
)