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
This commit is contained in:
@@ -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)}"
|
||||
)
|
||||
Reference in New Issue
Block a user