Skip to content

Commit d2e706c

Browse files
feat(handler): add Apple Encrypted Archive (AEA) handler
Adds a handler for Apple Encrypted Archive files (magic AEA1). Parses the auth-data TLV fields to locate the WKMS FCS key URL, fetches the session key via HPKE (P-256 / HKDF-SHA256 / AES-256-GCM), and decrypts the payload using the python-aea library. Files without embedded WKMS fields are skipped with a warning. pyhpke, python-aea and its pyliblzfse dependency are not in nixpkgs, so they are built from PyPI in overlay.nix. python-aea uses enum.StrEnum without declaring a Python version bound, which breaks the whole CLI on the 3.10 we still support. It is therefore declared as a 3.11+ dependency and imported lazily, so on 3.10 archives are still identified, only not decrypted.
1 parent 49c3f4c commit d2e706c

10 files changed

Lines changed: 320 additions & 1 deletion

File tree

docs/handlers.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
| [`AIROHA BT FIRMWARE`](#airoha-bt-firmware) | ARCHIVE | :octicons-alert-fill-12: |
66
| [`ANDROID EROFS`](#android-erofs) | FILESYSTEM | :octicons-check-16: |
77
| [`ANDROID SPARSE`](#android-sparse) | FILESYSTEM | :octicons-check-16: |
8+
| [`APPLE ENCRYPTED ARCHIVE (AEA)`](#apple-encrypted-archive-aea) | ARCHIVE | :octicons-alert-fill-12: |
89
| [`AR`](#ar) | ARCHIVE | :octicons-check-16: |
910
| [`ARC`](#arc) | ARCHIVE | :octicons-check-16: |
1011
| [`ARJ`](#arj) | ARCHIVE | :octicons-check-16: |
@@ -170,6 +171,27 @@
170171

171172
- [Android Sparse Image Format Documentation](https://formats.kaitai.io/android_sparse/){ target="_blank" }
172173
- [simg2img Tool](https://github.com/anestisb/android-simg2img){ target="_blank" }
174+
## Apple Encrypted Archive (AEA)
175+
176+
!!! warning "Partially supported"
177+
178+
=== "Description"
179+
180+
Apple Encrypted Archive (AEA) is Apple's encrypted container format used for secure firmware and OTA update distribution. Profile 1 archives use Hybrid Public Key Encryption (HPKE) with Apple's WKMS key management service to wrap a per-archive symmetric key.
181+
182+
---
183+
184+
- **Handler type:** Archive
185+
- **Vendor:** Apple
186+
187+
=== "References"
188+
189+
- [Apple Archive - Apple Developer Documentation](https://developer.apple.com/documentation/applearchive){ target="_blank" }
190+
191+
=== "Limitations"
192+
193+
- Decryption requires access to Apple's WKMS key management service
194+
- Archives without WKMS fields cannot be decrypted
173195
## AR
174196

175197
!!! success "Fully supported"

overlay.nix

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,82 @@ final: prev:
3434
};
3535
});
3636

37+
# Dependencies of the Apple Encrypted Archive handler that are not packaged in
38+
# nixpkgs yet.
39+
pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
40+
(python-final: _python-prev: {
41+
pyliblzfse = python-final.buildPythonPackage rec {
42+
pname = "pyliblzfse";
43+
version = "0.4.1";
44+
pyproject = true;
45+
46+
src = python-final.fetchPypi {
47+
inherit pname version;
48+
sha256 = "bb0b899b3830c02fdf3dbde48ea59611833f366fef836e5c32cf8145134b7d3d";
49+
};
50+
51+
build-system = [ python-final.setuptools ];
52+
53+
pythonImportsCheck = [ "liblzfse" ];
54+
55+
meta = {
56+
description = "Python bindings for the LZFSE reference implementation";
57+
homepage = "https://github.com/ydkhatri/pyliblzfse";
58+
license = final.lib.licenses.mit;
59+
};
60+
};
61+
62+
pyhpke = python-final.buildPythonPackage rec {
63+
pname = "pyhpke";
64+
version = "0.6.5";
65+
pyproject = true;
66+
67+
src = python-final.fetchPypi {
68+
inherit pname version;
69+
sha256 = "8dac22eb143cd83b8066213b8ad0ecc9d5326ef41f930197197f3bbb5c99cb93";
70+
};
71+
72+
build-system = [ python-final.uv-build ];
73+
74+
dependencies = [ python-final.cryptography ];
75+
76+
pythonImportsCheck = [ "pyhpke" ];
77+
78+
meta = {
79+
description = "Hybrid Public Key Encryption (RFC9180) implementation";
80+
homepage = "https://github.com/dajiaji/pyhpke";
81+
license = final.lib.licenses.mit;
82+
};
83+
};
84+
85+
python-aea = python-final.buildPythonPackage rec {
86+
pname = "python_aea";
87+
version = "1.1.0";
88+
pyproject = true;
89+
90+
src = python-final.fetchPypi {
91+
inherit pname version;
92+
sha256 = "ee9b5b61456f4bd3c20dc39f6b5fc3f262f4de3a76c3d2fd42617be5ff1153ed";
93+
};
94+
95+
build-system = [ python-final.setuptools ];
96+
97+
dependencies = with python-final; [
98+
cryptography
99+
lz4
100+
pyliblzfse
101+
];
102+
103+
pythonImportsCheck = [ "aea" ];
104+
105+
meta = {
106+
description = "Apple Encrypted Archive (AEA) reader";
107+
homepage = "https://pypi.org/project/python-aea/";
108+
license = final.lib.licenses.mit;
109+
};
110+
};
111+
})
112+
];
113+
37114
unblob = final.callPackage ./package.nix { };
38115
}

package.nix

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,14 @@ python3.pkgs.buildPythonApplication {
9090
pluggy
9191
pydantic
9292
pyfatfs
93+
pyhpke
9394
pymdown-extensions
9495
pyperscan
96+
python-aea
9597
python-magic
9698
zstandard
9799
rarfile
100+
requests
98101
rich
99102
structlog
100103
treelib

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ dependencies = [
2020
"pluggy>=1.3.0",
2121
"pydantic>=2.0",
2222
"pyfatfs>=1.0.5",
23+
"pyhpke>=0.6.4",
2324
"pymdown-extensions>=10.15",
2425
"pyperscan>=0.3.0",
26+
# python-aea uses enum.StrEnum, unavailable on the 3.10 we still support
27+
"python-aea>=1.1; python_version >= '3.11'",
2528
"python-magic>=0.4.27",
2629
"rarfile>=4.1",
30+
"requests>=2.32.5",
2731
"rich>=13.3.5",
2832
"structlog>=24.1.0",
2933
"treelib>=1.7.0",

python/unblob/handlers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
zip as ziparchive,
2121
)
2222
from .archive.autel import ecc
23+
from .archive.apple import aea
2324
from .archive.dlink import alpha_encimg, deafbead, encrpted_img, fpkg, shrs
2425
from .archive.engeniustech import engenius
2526
from .archive.hp import bdl, ipkg
@@ -120,6 +121,7 @@
120121
ar.ARHandler,
121122
arc.ARCHandler,
122123
arj.ARJHandler,
124+
aea.AEAHandler,
123125
cab.CABHandler,
124126
msi.MsiHandler,
125127
tar.TarUstarHandler,

python/unblob/handlers/archive/apple/__init__.py

Whitespace-only changes.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import base64
2+
import io
3+
import json
4+
from pathlib import Path
5+
6+
import requests
7+
from pyhpke import AEADId, CipherSuite, KDFId, KEMId, KEMKey
8+
from structlog import get_logger
9+
10+
from unblob.file_utils import File, InvalidInputFormat
11+
from unblob.models import (
12+
Extractor,
13+
ExtractResult,
14+
Handler,
15+
HandlerDoc,
16+
HandlerType,
17+
HexString,
18+
Reference,
19+
ValidChunk,
20+
)
21+
22+
logger = get_logger()
23+
24+
# python-aea uses enum.StrEnum, which only exists from 3.11 on, so it is declared
25+
# as a Python 3.11+ dependency and imported lazily: importing it at module level
26+
# would take the whole CLI down on 3.10.
27+
_UNSUPPORTED_PYTHON = "AEA decryption needs Python 3.11 or newer"
28+
29+
# HPKE suite as used by Apple AEA Profile 1
30+
_HPKE_SUITE = CipherSuite.new(
31+
KEMId.DHKEM_P256_HKDF_SHA256, KDFId.HKDF_SHA256, AEADId.AES256_GCM
32+
)
33+
34+
35+
# AEA auth data (WKMS key fields) is a few KB; cap generously to reject false-positive
36+
# "AEA1" matches without reading a bogus multi-GB size into memory.
37+
_MAX_AUTH_DATA_SIZE = 1 << 20
38+
39+
40+
def _parse_auth_data_fields(auth_data_blob: bytes) -> dict[str, bytes]:
41+
fields = {}
42+
while auth_data_blob:
43+
if len(auth_data_blob) < 4:
44+
raise InvalidInputFormat("AEA auth data: truncated field header")
45+
field_size = int.from_bytes(auth_data_blob[:4], "little")
46+
# field_size covers the 4-byte size prefix + "key\x00value"; must fit and advance.
47+
if not 4 < field_size <= len(auth_data_blob):
48+
raise InvalidInputFormat(f"AEA auth data: invalid field size {field_size}")
49+
key, sep, value = auth_data_blob[4:field_size].partition(b"\x00")
50+
if not sep:
51+
raise InvalidInputFormat("AEA auth data: field missing NUL separator")
52+
fields[key.decode("latin-1")] = value
53+
auth_data_blob = auth_data_blob[field_size:]
54+
return fields
55+
56+
57+
def _unwrap_session_key(fields: dict[str, bytes]) -> bytes:
58+
if (
59+
"com.apple.wkms.fcs-response" not in fields
60+
or "com.apple.wkms.fcs-key-url" not in fields
61+
):
62+
raise ValueError(
63+
"AEA file does not contain WKMS key fields — cannot decrypt without a pre-shared key"
64+
)
65+
fcs_response = json.loads(fields["com.apple.wkms.fcs-response"])
66+
enc_request = base64.b64decode(fcs_response["enc-request"])
67+
wrapped_key = base64.b64decode(fcs_response["wrapped-key"])
68+
url = fields["com.apple.wkms.fcs-key-url"].decode()
69+
70+
r = requests.get(url, timeout=10)
71+
r.raise_for_status()
72+
privkey = KEMKey.from_pem(r.text)
73+
74+
recipient = _HPKE_SUITE.create_recipient_context(enc_request, privkey)
75+
return recipient.open(wrapped_key)
76+
77+
78+
class AEAExtractor(Extractor):
79+
def extract(self, inpath: Path, outdir: Path) -> ExtractResult:
80+
try:
81+
from aea import aea as aeaformat # noqa: PLC0415
82+
except ImportError:
83+
logger.warning(
84+
"AEA: cannot decrypt — skipping extraction", reason=_UNSUPPORTED_PYTHON
85+
)
86+
return ExtractResult(reports=[])
87+
88+
with inpath.open("rb") as f:
89+
header = f.read(12)
90+
auth_data_size = int.from_bytes(header[8:12], "little")
91+
auth_data_blob = f.read(auth_data_size)
92+
93+
fields = _parse_auth_data_fields(auth_data_blob)
94+
try:
95+
symmetric_key = _unwrap_session_key(fields)
96+
except ValueError as e:
97+
logger.warning("AEA: cannot decrypt — skipping extraction", reason=str(e))
98+
return ExtractResult(reports=[])
99+
logger.debug("AEA session key obtained", length=len(symmetric_key))
100+
101+
decrypted_path = outdir / "decrypted.bin"
102+
with inpath.open("rb") as infile, decrypted_path.open("wb") as outfile:
103+
try:
104+
aeaformat.decode_stream(infile, outfile, symmetric_key=symmetric_key)
105+
except aeaformat.MACValidationError as e:
106+
logger.error(
107+
"AEA MAC validation failed — symmetric_key is likely wrong",
108+
error=str(e),
109+
)
110+
raise
111+
except aeaformat.ParseError as e:
112+
logger.error("AEA parse error", error=str(e))
113+
raise
114+
logger.debug(
115+
"AEA decryption complete", output_size=decrypted_path.stat().st_size
116+
)
117+
118+
return ExtractResult(reports=[])
119+
120+
121+
class AEAHandler(Handler):
122+
NAME = "aea"
123+
PATTERNS = [HexString("41 45 41 31")] # AEA1
124+
EXTRACTOR = AEAExtractor()
125+
126+
DOC = HandlerDoc(
127+
name="Apple Encrypted Archive (AEA)",
128+
description="Apple Encrypted Archive (AEA) is Apple's encrypted container format used for secure firmware and OTA update distribution. Profile 1 archives use Hybrid Public Key Encryption (HPKE) with Apple's WKMS key management service to wrap a per-archive symmetric key.",
129+
handler_type=HandlerType.ARCHIVE,
130+
vendor="Apple",
131+
references=[
132+
Reference(
133+
title="Apple Archive - Apple Developer Documentation",
134+
url="https://developer.apple.com/documentation/applearchive",
135+
),
136+
],
137+
limitations=[
138+
"Decryption requires access to Apple's WKMS key management service",
139+
"Archives without WKMS fields cannot be decrypted",
140+
],
141+
)
142+
143+
def calculate_chunk(self, file: File, start_offset: int) -> ValidChunk | None:
144+
# Validate the auth-data header so false-positive "AEA1" matches (common inside
145+
# large filesystems) are rejected here instead of crashing the extractor.
146+
file.seek(start_offset, io.SEEK_SET)
147+
header = file.read(12)
148+
if len(header) < 12:
149+
raise InvalidInputFormat("AEA header truncated")
150+
auth_data_size = int.from_bytes(header[8:12], "little")
151+
if auth_data_size > _MAX_AUTH_DATA_SIZE:
152+
raise InvalidInputFormat(f"AEA auth data size too large: {auth_data_size}")
153+
auth_data_blob = file.read(auth_data_size)
154+
if len(auth_data_blob) < auth_data_size:
155+
raise InvalidInputFormat("AEA auth data truncated")
156+
_parse_auth_data_fields(auth_data_blob)
157+
158+
file.seek(0, io.SEEK_END)
159+
return ValidChunk(start_offset=start_offset, end_offset=file.tell())
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:ddfcd965d209ddac6431bc776d33cbb8a8072cfcaae9423f61559f6167054aec
3+
size 592

tests/integration/archive/apple/aea/__output__/.gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)