diff --git a/.gitignore b/.gitignore index c21e185..be7fe30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ aastuff aastuff_standalone *.dSYM/ +obj/ .DS_Store tests/ diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..8b13807 --- /dev/null +++ b/.swift-format @@ -0,0 +1,11 @@ +{ + "version": 1, + "lineLength": 140, + "indentation": { + "spaces": 4 + }, + "maximumBlankLines": 2, + "respectsExistingLineBreaks": true, + "lineBreakBeforeControlFlowKeywords": true, + "lineBreakBeforeEachArgument": true +} \ No newline at end of file diff --git a/Makefile b/Makefile index 429c042..5c06a44 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,12 @@ .PHONY: all clean test deploy -SRC_FILES = src/aastuff.m src/args.m src/extract.m src/extract_standalone.m -HDR_FILES = include/AppleArchivePrivate.h include/extract.h include/extract_standalone.h +SRC_FILES = src/aastuff.m src/aea.m src/args.m src/extract.m src/extract_standalone.m src/utils.m +SRC_FILES_SWIFT = +OBJ_FILES_SWIFT = $(patsubst src/%.swift,obj/%.o,$(SRC_FILES_SWIFT)) +HDR_FILES = include/AppleArchivePrivate.h include/OSISAEAExtractor.h \ + include/aea.h include/args.h include/extract.h include/extract_standalone.h include/utils.h $(HDR_SWIFT) -CFLAGS = -fmodules -fobjc-arc -Iinclude -Wall -Werror -Wunreachable-code +CFLAGS = -fmodules -fobjc-arc -Iinclude -Iobj -Wall -Werror -Wunreachable-code LDLIBS = -framework Foundation -lAppleArchive LDFLAGS = -Llib @@ -12,15 +15,32 @@ ifeq ($(DEBUG), 1) CFLAGS += -g -DDEBUG=1 -O0 endif +HPKE ?= 0 +ifeq ($(HPKE), 1) + CFLAGS += -DHAS_HPKE=1 + SRC_FILES_SWIFT += src/hpke.swift + HDR_SWIFT = obj/aastuff-Swift.h +endif + all: aastuff aastuff_standalone -aastuff: $(SRC_FILES) $(HDRS_FILES) - clang++ $(CFLAGS) $(LDFLAGS) $(LDLIBS) $(SRC_FILES) -o $@ +obj: + mkdir -p obj + +obj/%.o: src/%.swift | obj + swiftc -module-name aastuff -parse-as-library -emit-module -emit-module-path obj/$*.swiftmodule -c $< -o $@ + +$(HDR_SWIFT): $(OBJ_FILES_SWIFT) + swiftc -module-name aastuff -parse-as-library -emit-objc-header -emit-objc-header-path $@ -emit-module -emit-module-path obj/aastuff.swiftmodule $(subst .o,.swiftmodule,$^) + +aastuff: $(SRC_FILES) $(HDR_FILES) $(HDR_SWIFT) + clang++ $(CFLAGS) $(LDFLAGS) $(LDLIBS) $(SRC_FILES) $(OBJ_FILES_SWIFT) -o $@ codesign -f -s - $@ -aastuff_standalone: $(SRC_FILES) $(HDRS_FILES) - clang++ -DAASTUFF_STANDALONE=1 $(CFLAGS) $(LDFLAGS) $(LDLIBS) $(SRC_FILES) -o $@ +aastuff_standalone: $(SRC_FILES) $(OBJ_FILES_SWIFT) $(HDR_FILES) $(HDR_SWIFT) + clang++ -DAASTUFF_STANDALONE=1 $(CFLAGS) $(LDFLAGS) $(LDLIBS) $(SRC_FILES) $(OBJ_FILES_SWIFT) -o $@ codesign -f -s - $@ clean: - rm -f aastuff aastuff_standalone + rm -rf obj *.dSYM + rm -f aastuff aastuff_standalone diff --git a/README.md b/README.md index 3a2bbe4..fde12de 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,62 @@ AEA OTA/IPSW decryption -## Grabbing keys with `get_key.py` +## Prerequisites -Gets a key from the key URL embedded in an AEA's auth data blob. +- `get_key.py` + - Python 3.10+ (might work with older, but not tested) + - `requests` + - `pyhpke` +- `aastuff` + - macOS 13+ + - macOS 14+ for HPKE support +- `aastuff_standalone` + - macOS 12+ + - macOS 14+ for HPKE support -> [!NOTE] -> OTAs before iOS 18.0 beta 3 did not have embedded auth data; for these OTAs, you must use the key provided with your response. macOS is the exception. +## Building and Installing + +### `get_key.py` ```shell pip3 install -r requirements.txt -python3 get_key.py ``` -Note: it is highly recommended to use a virtual environment: +> [!NOTE] +> It is highly recommended to use a virtual environment: +> +> ```shell +> python3 -m venv .env # only needed once +> source .env/bin/activate +> pip3 install -r requirements.txt # only needed once +> ``` +> +> On future runs, you only need to activate the virtual environment: +> +> ```shell +> source .env/bin/activate +> ``` + +### `aastuff`/`aastuff_standalone` + +You can pass two options to the makefile: + +- `DEBUG=1`: build debug (debug prints, no optimizations, debug information) +- `HPKE=1`: build with HPKE support (needs macOS 14.0+) + +```shell +make [DEBUG=1] [HPKE=1] +``` + +## Grabbing keys with `get_key.py` + +Unwrap the decryption key using the data embedded in an AEA's auth data blob. + +> [!NOTE] +> OTAs before iOS 18.0 beta 3 did not have embedded auth data; for these OTAs, you must use the decryption key provided with your response. macOS is the exception and has always had embedded auth data. ```shell -python3 -m venv .env # only needed once -source .env/bin/activate -pip3 install -r requirements.txt # only needed once +source .env/bin/activate # if you used a virtual environment python3 get_key.py ``` @@ -27,15 +65,19 @@ python3 get_key.py ```shell aea decrypt -i -o -key-value 'base64:' +# or +./aastuff -i -o -d -k +# or, to use the network to grab the private key +./aastuff -i -o -d -n ``` For IPSWs, you will get the unwrapped file (ie. `090-34187-052.dmg.aea` will decrypt to `090-34187-052.dmg`). -For assets, you will get specially crafted AppleArchives (see next section). +For assets, you will get specially crafted Apple Archives (see next section). ## Extracting assets -Assets (including OTA updates) are constructed specially and cannot be extracted with standard (`aa`) tooling. They can be decrypted normally, which will result in an AppleArchive that is not extractable with `aa` (we will call these "asset archives"). `aastuff` must be used to extract them. +Assets (including OTA updates) are constructed specially and cannot be extracted with standard (`aa`) tooling. They can be decrypted normally, which will result in an Apple Archive that is not extractable with `aa` (we will call these "asset archives"). `aastuff` must be used to extract them. ```shell # Decrypt if necessary @@ -68,7 +110,7 @@ For now, both are built and used in the same way. Once `aastuff_standalone` is f ## Credits -- Siguza - auth data parsing strategy, AppleArchive extraction sample code +- Siguza - auth data parsing strategy, Apple Archive extraction sample code - Nicolas - original HPKE code - Snoolie - auth data parsing strategy -- Flagers - AppleArchive assistance +- Flagers - Apple Archive assistance diff --git a/get_key.py b/get_key.py index 30e0924..c1c01d5 100755 --- a/get_key.py +++ b/get_key.py @@ -5,11 +5,12 @@ # Requirements: pip3 install requests pyhpke +import argparse import base64 import json -from pathlib import Path -import argparse import sys +from pathlib import Path +from pprint import pprint import requests from pyhpke import AEADId, CipherSuite, KDFId, KEMId, KEMKey @@ -24,48 +25,49 @@ def error(msg): exit(1) -def main(aea_path: Path, verbose: bool = False): +def get_key(f, verbose: bool = False): fields = {} - with aea_path.open("rb") as f: - header = f.read(12) - if len(header) != 12: - error(f"Expected 12 bytes, got {len(header)}") + header = f.read(12) + if len(header) != 12: + error(f"Expected 12 bytes, got {len(header)}") + + magic = header[:4] + if magic != b"AEA1": + error(f"Invalid magic: {magic.hex()}") - magic = header[:4] - if magic != b"AEA1": - error(f"Invalid magic: {magic.hex()}") + profile = int.from_bytes(header[4:7], "little") + if profile != AEA_PROFILE__HKDF_SHA256_AESCTR_HMAC__SYMMETRIC__NONE: + error(f"Invalid AEA profile: {profile}") - profile = int.from_bytes(header[4:7], "little") - if profile != AEA_PROFILE__HKDF_SHA256_AESCTR_HMAC__SYMMETRIC__NONE: - error(f"Invalid AEA profile: {profile}") + auth_data_blob_size = int.from_bytes(header[8:12], "little") - auth_data_blob_size = int.from_bytes(header[8:12], "little") + if auth_data_blob_size == 0: + error("No auth data blob") - if auth_data_blob_size == 0: - error("No auth data blob") + auth_data_blob = f.read(auth_data_blob_size) + if len(auth_data_blob) != auth_data_blob_size: + error(f"Expected {auth_data_blob_size} bytes, got {len(auth_data_blob)}") - auth_data_blob = f.read(auth_data_blob_size) - if len(auth_data_blob) != auth_data_blob_size: - error(f"Expected {auth_data_blob_size} bytes, got {len(auth_data_blob)}") + assert auth_data_blob[:4] - assert auth_data_blob[:4] + while len(auth_data_blob) > 0: + field_size = int.from_bytes(auth_data_blob[:4], "little") + field_blob = auth_data_blob[:field_size] - while len(auth_data_blob) > 0: - field_size = int.from_bytes(auth_data_blob[:4], "little") - field_blob = auth_data_blob[:field_size] + key, value = field_blob[4:].split(b"\x00", 1) - key_end = field_blob.index(b"\x00", 4) - key = field_blob[4:key_end].decode() - value = field_blob[key_end + 1 :].decode() - fields[key] = value + fields[key.decode()] = value.decode() - auth_data_blob = auth_data_blob[field_size:] + auth_data_blob = auth_data_blob[field_size:] if verbose: - print(fields, "\n", file=sys.stderr) + pprint(fields, stream=sys.stderr) if "com.apple.wkms.fcs-response" not in fields: - error("No fcs-response field found, is this from an OTA?") + error("No fcs-response field found!") + + if "com.apple.wkms.fcs-key-url" not in fields: + error("No fcs-key-url field found!") fcs_response = json.loads(fields["com.apple.wkms.fcs-response"]) enc_request = base64.b64decode(fcs_response["enc-request"]) @@ -86,10 +88,26 @@ def main(aea_path: Path, verbose: bool = False): print(base64.b64encode(pt).decode()) +def main(path: str, verbose: bool = False): + if path.startswith("http://") or path.startswith("https://"): + with requests.get(path, timeout=10, stream=True) as response: + response.raise_for_status() + + get_key(response.raw, verbose) + + else: + aea_path = Path(path) + if not aea_path.exists(): + error(f"File {path} does not exist") + + with aea_path.open("rb") as f: + get_key(f, verbose) + + if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Get the key for an AEA file") - parser.add_argument("path", help="Path to the AEA file") + parser = argparse.ArgumentParser(description="Get the key for an AEA file or URL") + parser.add_argument("path", help="Path or URL to the AEA file") parser.add_argument("-v", "--verbose", action="store_true", help="Show verbose output") args = parser.parse_args() - main(Path(args.path), args.verbose) + main(args.path, args.verbose) diff --git a/include/aea.h b/include/aea.h new file mode 100644 index 0000000..2c3ea4a --- /dev/null +++ b/include/aea.h @@ -0,0 +1,15 @@ +#ifndef AEA_H +#define AEA_H + +#import +#import + +#import "args.h" + +#if HAS_HPKE + +int fetchKey(AEAContext stream, ExtractionConfiguration* config); + +#endif + +#endif /* AEA_H */ diff --git a/include/args.h b/include/args.h index 73f2abb..d9ac3c4 100644 --- a/include/args.h +++ b/include/args.h @@ -3,15 +3,27 @@ #import +// This needs to be manually synced with the enum in hpke.swift +typedef NS_ENUM(NSInteger, PrivateKeyFormat) { + PrivateKeyFormatAll, + PrivateKeyFormatPEM, + PrivateKeyFormatDER, + PrivateKeyFormatX963, +}; + @interface ExtractionConfiguration : NSObject @property(nonatomic, assign) bool encrypted; @property(nonatomic, assign) bool list; @property(nonatomic, strong) NSString* archivePath; -@property(nonatomic, strong) NSString* outputDirectory; +@property(nonatomic, strong) NSString* outputPath; +@property(nonatomic, assign) bool decryptOnly; @property(nonatomic, strong) NSData* key; @property(nonatomic, strong) NSString* filter; @property(nonatomic, strong) NSRegularExpression* regex; +@property(nonatomic, assign) bool network; +@property(nonatomic, strong) NSData* unwrapKey; +@property(nonatomic, assign) PrivateKeyFormat unwrapKeyFormat; @property(nonatomic, strong) NSString* function; diff --git a/include/utils.h b/include/utils.h index 60c9898..4acf2a1 100644 --- a/include/utils.h +++ b/include/utils.h @@ -2,7 +2,6 @@ #define UTILS_H #import -#import #import #define LOG(format, ...) printf("%s\n", [[NSString stringWithFormat:format, ##__VA_ARGS__] UTF8String]) @@ -20,6 +19,8 @@ #define NAME @"aastuff" #endif -#define VERSION @"1.0.0" +#define VERSION @"2.0.0" + +NSData* makeSynchronousRequest(NSURLRequest* request, NSHTTPURLResponse** response, NSError** error); #endif /* UTILS_H */ diff --git a/src/aastuff.m b/src/aastuff.m index 74e48b9..ca9a61b 100644 --- a/src/aastuff.m +++ b/src/aastuff.m @@ -1,6 +1,7 @@ #import #import +#import "aea.h" #import "args.h" #import "extract.h" #import "extract_standalone.h" @@ -34,6 +35,18 @@ int main(int argc, char** argv) { return 1; } + if (!config.key) { +#if HAS_HPKE + if (fetchKey(decryptionContext, config)) { + AEAContextDestroy(decryptionContext); + AAByteStreamClose(stream); + return 1; + } +#else + assert(0 && "HPKE not supported, key should be present at this stage"); +#endif + } + int ret = AEAContextSetFieldBlob(decryptionContext, AEA_CONTEXT_FIELD_SYMMETRIC_KEY, 0, config.key.bytes, config.key.length); if (ret != 0) { ERRLOG(@"Failed to set key"); @@ -51,16 +64,42 @@ int main(int argc, char** argv) { } } + if (config.decryptOnly) { + AAByteStream outputStream = AAFileStreamOpenWithPath(config.outputPath.UTF8String, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (!outputStream) { + ERRLOG(@"Failed to open output file stream"); + AEAContextDestroy(decryptionContext); + AAByteStreamClose(decryptionStream); + AAByteStreamClose(stream); + return 1; + } + + off_t processed = AAByteStreamProcess(decryptionStream, outputStream); + + if (processed < 0) { + ERRLOG(@"Failed to process stream"); + AEAContextDestroy(decryptionContext); + AAByteStreamClose(decryptionStream); + AAByteStreamClose(stream); + AAByteStreamClose(outputStream); + return 1; + } else { + DBGLOG(@"Processed %lld bytes", processed); + } + + AAByteStreamClose(outputStream); + } else { #if AASTUFF_STANDALONE - if (extractAssetStandalone(config.encrypted ? decryptionStream : stream, config)) { + if (extractAssetStandalone(config.encrypted ? decryptionStream : stream, config)) { #else - if (extractAsset(config.encrypted ? decryptionStream : stream, config)) { + if (extractAsset(config.encrypted ? decryptionStream : stream, config)) { #endif - ERRLOG(@"Extracting asset failed"); - AEAContextDestroy(decryptionContext); - AAByteStreamClose(decryptionStream); - AAByteStreamClose(stream); - return 1; + ERRLOG(@"Extracting asset failed"); + AEAContextDestroy(decryptionContext); + AAByteStreamClose(decryptionStream); + AAByteStreamClose(stream); + return 1; + } } AEAContextDestroy(decryptionContext); diff --git a/src/aea.m b/src/aea.m new file mode 100644 index 0000000..2b3e98d --- /dev/null +++ b/src/aea.m @@ -0,0 +1,165 @@ +#import "aea.h" + +#if HAS_HPKE + #import "aastuff-Swift.h" +#endif +#import "utils.h" + +static __attribute__((used)) NSDictionary* getAuthData(AEAContext context) { + NSMutableDictionary* authDataDict = [NSMutableDictionary dictionary]; + AEAAuthData authData = AEAAuthDataCreateWithContext(context); + if (!authData) { + ERRLOG(@"Failed to create auth data"); + return nil; + } + + uint32_t entryCount = AEAAuthDataGetEntryCount(authData); + DBGLOG(@"Auth data entry count: %d", entryCount); + + for (int i = 0; i < entryCount; i++) { + // DBGLOG(@"Processing entry %d", i); + + // Does not include null terminator + size_t keyLength = -1; + size_t dataLength = -1; + if (AEAAuthDataGetEntry(authData, i, 0, NULL, &keyLength, 0, NULL, &dataLength)) { + ERRLOG(@"Failed to get key and data lengths"); + AEAAuthDataDestroy(authData); + return nil; + } + + // DBGLOG(@"Key length: %zu, data length: %zu", keyLength, dataLength); + + char* rawKey = malloc(keyLength + 1); + uint8_t* rawData = malloc(dataLength); + + if (AEAAuthDataGetEntry(authData, i, keyLength + 1, rawKey, &keyLength, dataLength, rawData, &dataLength)) { + ERRLOG(@"Failed to get key and data"); + free(rawKey); + free(rawData); + AEAAuthDataDestroy(authData); + return nil; + } + + NSString* key = [NSString stringWithUTF8String:rawKey]; + NSData* data = [NSData dataWithBytes:rawData length:dataLength]; + authDataDict[key] = data; + free(rawKey); + free(rawData); + } + + AEAAuthDataDestroy(authData); + + return authDataDict; +} + +#if HAS_HPKE + +int fetchKey(AEAContext context, ExtractionConfiguration* config) { + NSError* error = nil; + + AEAProfile profile = AEAContextGetProfile(context); + if (profile != AEA_PROFILE__HKDF_SHA256_AESCTR_HMAC__SYMMETRIC__NONE) { + ERRLOG(@"Unsupported AEA profile %d", profile); + return 1; + } + + NSDictionary* authData = getAuthData(context); + if (!authData) { + return 1; + } + + DBGLOG(@"Auth data: %@", authData); + + NSData* urlData = authData[@"com.apple.wkms.fcs-key-url"]; + if (!urlData) { + ERRLOG(@"Auth data is missing required metadata (FCS key URL)"); + return 1; + } + + NSData* responseData = authData[@"com.apple.wkms.fcs-response"]; + if (!responseData) { + ERRLOG(@"Auth data is missing required metadata (FCS response)"); + return 1; + } + + NSURL* url = [NSURL URLWithString:[[NSString alloc] initWithData:urlData encoding:NSUTF8StringEncoding]]; + if (!url) { + ERRLOG(@"Failed to create URL from key URL"); + return 1; + } + + NSDictionary* response = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:&error]; + if (!response) { + ERRLOG(@"Failed to parse FCS response JSON: %@", error); + return 1; + } + + // Encapsulated symmetric key. This is what was used to encrypt the archive's encryption key. Also known as the shared secret. + NSData* encryptedRequest = [[NSData alloc] initWithBase64EncodedString:response[@"enc-request"] options:0]; + if (!encryptedRequest) { + ERRLOG(@"Failed to decode encrypted request"); + return 1; + } + + // Wrapped archive encryption key. Also known as the message, encrypted data, or ciphertext. + NSData* wrappedKey = [[NSData alloc] initWithBase64EncodedString:response[@"wrapped-key"] options:0]; + if (!wrappedKey) { + ERRLOG(@"Failed to decode wrapped key"); + return 1; + } + + DBGLOG(@"Key URL: %@", url); + DBGLOG(@"Response data: %@", response); + DBGLOG(@"Encrypted request (encapsulated symmetric key): %@", encryptedRequest); + DBGLOG(@"Wrapped key (ciphertext): %@", wrappedKey); + + // Receipient's private key. The receipient's public key is what was used to encrypt the encapsulated symmetric key. + NSData* privateKey = nil; + PrivateKeyFormat privateKeyFormat = PrivateKeyFormatAll; + if (config.unwrapKey) { + privateKey = config.unwrapKey; + privateKeyFormat = config.unwrapKeyFormat; + } else { + NSURLRequest* request = [NSURLRequest requestWithURL:url]; + NSHTTPURLResponse* privateKeyResponse = nil; + + privateKey = makeSynchronousRequest(request, &privateKeyResponse, &error); + privateKeyFormat = PrivateKeyFormatPEM; + + if (error) { + ERRLOG(@"Failed to fetch key: %@", error); + return 1; + } + + if (privateKeyResponse.statusCode != 200) { + ERRLOG(@"Failed to fetch key: HTTP status code %ld", privateKeyResponse.statusCode); + return 1; + } + + // Sanity check + if (![[NSString alloc] initWithData:privateKey encoding:NSUTF8StringEncoding]) { + ERRLOG(@"Failed to decode fetched key"); + return 1; + } + } + + DBGLOG(@"Private key (recepient's private key): %@", privateKey); + + // The unwrapped encryption key. This is the data that was encrypted. Also known as the plaintext/cleartext. + NSData* unwrappedKey = [HPKEWrapper unwrapPrivateKey:privateKey format:privateKeyFormat encryptedRequest:encryptedRequest + wrappedKey:wrappedKey + error:&error]; + if (!unwrappedKey) { + ERRLOG(@"Failed to unwrap key: %@", error); + return 1; + } + + DBGLOG(@"Unwrapped key (cleartext): %@ (%@)", unwrappedKey, [unwrappedKey base64EncodedStringWithOptions:0]); + + config.key = unwrappedKey; + + return 0; +} + +#endif \ No newline at end of file diff --git a/src/args.m b/src/args.m index 6018c53..3de649d 100644 --- a/src/args.m +++ b/src/args.m @@ -8,66 +8,72 @@ #define APPLE_ARCHIVE_MAGIC @"AA01" #define APPLE_ENCRYPTED_ARCHIVE_MAGIC @"AEA1" -/* -Options: --l, --list - List the contents of the archive instead of extracting it --i, --input - Input archive to extract --o, --output - Output directory for extracted files --k, --key - Key in base64 format for encrypted archives --h, --help - Display this help message --v, --version - Display the version number --f, --filter - Filter files by pattern --r, --regex - Filter files by regex pattern -*/ - static void usage(void) { ERRLOG(@"Usage: %@ [options]", NAME); ERRLOG(@"Options:"); + ERRLOG(@" -i, --input "); + ERRLOG(@" Input archive to extract"); + ERRLOG(@" -o, --output "); + ERRLOG(@" Output directory for extracted files"); + ERRLOG(@" If decrypt-only is set, output path for decrypted contents"); #if AASTUFF_STANDALONE ERRLOG(@" -l, --list"); ERRLOG(@" List the contents of the archive instead of extracting it"); #endif - ERRLOG(@" -i, --input "); - ERRLOG(@" Input archive to extract"); - ERRLOG(@" -o, --output "); - ERRLOG(@" Output directory for extracted files"); - ERRLOG(@" -k, --key "); - ERRLOG(@" Key in base64 format for encrypted archives"); + ERRLOG(@" -d, --decrypt-only"); + ERRLOG(@" Only decrypt the archive, do not extract it"); ERRLOG(@" -h, --help"); ERRLOG(@" Display this help message"); ERRLOG(@" -v, --version"); ERRLOG(@" Display the version number"); #if AASTUFF_STANDALONE + ERRLOG(@"Filter Options:"); ERRLOG(@" -f, --filter "); ERRLOG(@" Filter files by glob pattern"); ERRLOG(@" -r, --regex "); ERRLOG(@" Filter files by regex pattern"); +#endif + ERRLOG(@"Key Options:"); + ERRLOG(@" -k, --key "); + ERRLOG(@" Decryption key in base64 format for encrypted archives"); + ERRLOG(@" -K, --key-file "); + ERRLOG(@" Path to file containing raw decryption key for encrypted archives"); +#if HAS_HPKE + ERRLOG(@" -u, --unwrap "); + ERRLOG(@" Unwrap decryption key using private key in base64 format"); + ERRLOG(@" -U, --unwrap-key-file "); + ERRLOG(@" Unwrap decryption key using private key at path"); + ERRLOG(@" --unwrap-key-format "); + ERRLOG(@" Format of the private key (pem, der, x9.63/x963)"); + ERRLOG(@" By default, each format is tried in order"); + ERRLOG(@" -n, --network"); + ERRLOG(@" Fetch private key using URL in auth data"); #endif } ExtractionConfiguration* parseArgs(int argc, char** argv, int* returnCode) { // clang-format off static struct option long_options[] = { + {"input", required_argument, 0, 'i'}, + {"output", required_argument, 0, 'o'}, #if AASTUFF_STANDALONE {"list", no_argument, 0, 'l'}, #endif - {"input", required_argument, 0, 'i'}, - {"output", required_argument, 0, 'o'}, - {"key", required_argument, 0, 'k'}, + {"decrypt-only", no_argument, 0, 'd'}, {"help", no_argument, 0, 'h'}, {"version", no_argument, 0, 'v'}, #if AASTUFF_STANDALONE {"filter", required_argument, 0, 'f'}, {"regex", required_argument, 0, 'r'}, #endif + {"key", required_argument, 0, 'k'}, + {"key-file", required_argument, 0, 'K'}, + #if HAS_HPKE + {"unwrap", required_argument, 0, 'u'}, + {"unwrap-key-file", required_argument, 0, 'U'}, + {"unwrap-key-format", required_argument, 0, 'F'}, + {"network", no_argument, 0, 'n'}, + #endif {0, 0, 0, 0}, }; // clang-format on @@ -77,26 +83,32 @@ static void usage(void) { bool list = false; NSString* archivePath = nil; - NSString* outputDirectory = nil; + NSString* outputPath = nil; + bool decryptOnly = false; NSString* keyBase64 = nil; + NSString* keyPath = nil; NSString* filter = nil; NSString* regexString = nil; + bool network = false; + NSString* unwrapKeyBase64 = nil; + NSString* unwrapKeyPath = nil; + NSString* unwrapKeyFormatString = nil; - while ((c = getopt_long(argc, argv, "-li:o:k:hvf:r:", long_options, &option_index)) != -1) { + while ((c = getopt_long(argc, argv, "-i:o:ldhvf:r:k:K:u:U:n", long_options, &option_index)) != -1) { switch (c) { -#if AASTUFF_STANDALONE - case 'l': - list = true; - break; -#endif case 'i': archivePath = [NSString stringWithUTF8String:optarg]; break; case 'o': - outputDirectory = [NSString stringWithUTF8String:optarg]; + outputPath = [NSString stringWithUTF8String:optarg]; break; - case 'k': - keyBase64 = [NSString stringWithUTF8String:optarg]; +#if AASTUFF_STANDALONE + case 'l': + list = true; + break; +#endif + case 'd': + decryptOnly = true; break; case 'h': usage(); @@ -113,6 +125,26 @@ static void usage(void) { case 'r': regexString = [NSString stringWithUTF8String:optarg]; break; +#endif + case 'k': + keyBase64 = [NSString stringWithUTF8String:optarg]; + break; + case 'K': + keyPath = [NSString stringWithUTF8String:optarg]; + break; +#if HAS_HPKE + case 'u': + unwrapKeyBase64 = [NSString stringWithUTF8String:optarg]; + break; + case 'U': + unwrapKeyPath = [NSString stringWithUTF8String:optarg]; + break; + case 'F': + unwrapKeyFormatString = [NSString stringWithUTF8String:optarg]; + break; + case 'n': + network = true; + break; #endif default: ERRLOG(@"Unknown option"); @@ -129,8 +161,22 @@ static void usage(void) { return nil; } - if (!list && !outputDirectory) { - ERRLOG(@"Output directory is required"); + if (!list && !outputPath) { + ERRLOG(@"Output %@ is required", decryptOnly ? @"file path" : @"directory"); + usage(); + *returnCode = 1; + return nil; + } + + if (decryptOnly && list) { + ERRLOG(@"Cannot use both decrypt-only and list options"); + usage(); + *returnCode = 1; + return nil; + } + + if (decryptOnly && (filter || regexString)) { + ERRLOG(@"Cannot use both decrypt-only and filter options"); usage(); *returnCode = 1; return nil; @@ -143,8 +189,70 @@ static void usage(void) { return nil; } + if (keyPath && keyBase64) { + ERRLOG(@"Cannot use both key and key file options"); + usage(); + *returnCode = 1; + return nil; + } + + if (unwrapKeyPath && unwrapKeyBase64) { + ERRLOG(@"Cannot use both unwrap key and unwrap key file options"); + usage(); + *returnCode = 1; + return nil; + } + + if ((keyBase64 || keyPath) && (unwrapKeyBase64 || unwrapKeyPath)) { + ERRLOG(@"Cannot use both key and unwrap key options"); + usage(); + *returnCode = 1; + return nil; + } + + if ((keyBase64 || keyPath) && network) { + ERRLOG(@"Cannot use both key and network options"); + usage(); + *returnCode = 1; + return nil; + } + + if (network && (unwrapKeyBase64 || unwrapKeyPath)) { + ERRLOG(@"Cannot use both network and unwrap options"); + usage(); + *returnCode = 1; + return nil; + } + + PrivateKeyFormat unwrapKeyFormat = PrivateKeyFormatAll; + if (unwrapKeyFormatString) { + NSDictionary* formatStringToNumber = @{ + @"pem": @(PrivateKeyFormatPEM), + @"der": @(PrivateKeyFormatDER), + @"x963": @(PrivateKeyFormatX963), + @"x9.63": @(PrivateKeyFormatX963), + }; + NSNumber* formatNumber = formatStringToNumber[unwrapKeyFormatString.lowercaseString]; + if (!formatNumber) { + ERRLOG(@"Invalid unwrap key format"); + usage(); + *returnCode = 1; + return nil; + } + + unwrapKeyFormat = [formatNumber intValue]; + } + NSData* key = nil; - if (keyBase64) { + if (keyPath) { + NSError* error = nil; + key = [[NSData alloc] initWithContentsOfFile:keyPath options:0 error:&error]; + if (!key) { + ERRLOG(@"Failed to read key file: %@", error); + *returnCode = 1; + return nil; + } + } else if (keyBase64) { key = [[NSData alloc] initWithBase64EncodedString:keyBase64 options:0]; if (!key) { ERRLOG(@"Failed to decode key from base64"); @@ -153,6 +261,24 @@ static void usage(void) { } } + NSData* unwrapKey = nil; + if (unwrapKeyPath) { + NSError* error = nil; + unwrapKey = [[NSData alloc] initWithContentsOfFile:unwrapKeyPath options:0 error:&error]; + if (!unwrapKey) { + ERRLOG(@"Failed to read unwrap key file: %@", error); + *returnCode = 1; + return nil; + } + } else if (unwrapKeyBase64) { + unwrapKey = [[NSData alloc] initWithBase64EncodedString:unwrapKeyBase64 options:0]; + if (!unwrapKey) { + ERRLOG(@"Failed to decode unwrap key from base64"); + *returnCode = 1; + return nil; + } + } + NSRegularExpression* regex = nil; if (regexString) { NSError* error = nil; @@ -167,10 +293,14 @@ static void usage(void) { ExtractionConfiguration* config = [[ExtractionConfiguration alloc] init]; config.list = list; config.archivePath = archivePath; - config.outputDirectory = outputDirectory; + config.outputPath = outputPath; + config.decryptOnly = decryptOnly; config.key = key; config.filter = filter; config.regex = regex; + config.network = network; + config.unwrapKey = unwrapKey; + config.unwrapKeyFormat = unwrapKeyFormat; return config; } @@ -184,10 +314,19 @@ int validateArgs(ExtractionConfiguration* config) { return 1; } - if (!config.list) { + if (config.list) { + // We need an output directory for processing to work. However, we will not mutate it + config.outputPath = NSTemporaryDirectory(); + } else { BOOL isDirectory = false; - if (![fileManager fileExistsAtPath:config.outputDirectory isDirectory:&isDirectory]) { - if (![fileManager createDirectoryAtPath:config.outputDirectory withIntermediateDirectories:NO attributes:nil error:&error]) { + BOOL exists = [fileManager fileExistsAtPath:config.outputPath isDirectory:&isDirectory]; + if (config.decryptOnly) { + if (exists) { + ERRLOG(@"Output path already exists"); + return 1; + } + } else if (!exists) { + if (![fileManager createDirectoryAtPath:config.outputPath withIntermediateDirectories:NO attributes:nil error:&error]) { ERRLOG(@"Failed to create directory: %@", error); return 1; } @@ -197,9 +336,6 @@ int validateArgs(ExtractionConfiguration* config) { return 1; } } - } else { - // We need an output directory for processing to work. However, we will not mutate it - config.outputDirectory = NSTemporaryDirectory(); } NSFileHandle* handle = [NSFileHandle fileHandleForReadingAtPath:config.archivePath]; @@ -227,11 +363,23 @@ int validateArgs(ExtractionConfiguration* config) { return 1; } - if (config.encrypted && !config.key) { - ERRLOG(@"Encrypted archive requires key"); + if (!config.encrypted && config.decryptOnly) { + ERRLOG(@"Cannot decrypt unencrypted archive"); return 1; } + if (config.encrypted && !(config.key || config.unwrapKey || config.network)) { + ERRLOG(@"Encrypted archive requires key, unwrap key, or network option"); + return 1; + } + + if (config.unwrapKey && config.unwrapKeyFormat == PrivateKeyFormatPEM) { + if (![[NSString alloc] initWithData:config.unwrapKey encoding:NSUTF8StringEncoding]) { + ERRLOG(@"Invalid PEM key"); + return 1; + } + } + return 0; } @@ -239,7 +387,11 @@ @implementation ExtractionConfiguration - (nonnull id)copyWithZone:(nullable NSZone*)zone { ExtractionConfiguration* copy = [[ExtractionConfiguration alloc] init]; + copy.encrypted = self.encrypted; copy.list = self.list; + copy.archivePath = self.archivePath; + copy.outputPath = self.outputPath; + copy.key = self.key; copy.filter = self.filter; copy.regex = self.regex; diff --git a/src/extract.m b/src/extract.m index ce66903..9d367d9 100644 --- a/src/extract.m +++ b/src/extract.m @@ -6,7 +6,7 @@ #define ALLOC_SIZE 0x100000uLL int extractAsset(AAByteStream stream, ExtractionConfiguration* config) { - AAAssetExtractor extractor = AAAssetExtractorCreate(config.outputDirectory.UTF8String, NULL, NULL); + AAAssetExtractor extractor = AAAssetExtractorCreate(config.outputPath.UTF8String, NULL, NULL); if (!extractor) { ERRLOG(@"Failed to create asset extractor"); return 1; diff --git a/src/extract_standalone.m b/src/extract_standalone.m index 6d9d6bd..c89a709 100644 --- a/src/extract_standalone.m +++ b/src/extract_standalone.m @@ -235,19 +235,18 @@ int extractAssetStandalone(AAByteStream byteStream, ExtractionConfiguration* con break; } + CFTypeRef configCopy = CFBridgingRetain([config copyWithFunction:(yop == AA_YOP_TYPE_DST_FIXUP ? @"VERIFY" : @"EXTRACT")]); + // TODO: What is the difference between these two? // TODO: Magic constant - // TODO: CFBridgingRetain will leak AAArchiveStream extractStream = yop == AA_YOP_TYPE_DST_FIXUP - ? AAVerifyDirectoryArchiveOutputStreamOpen(config.outputDirectory.UTF8String, keySet, - (void*)CFBridgingRetain([config copyWithFunction:@"VERIFY"]), - aa_callback, UINT64_C(1) << 53, 0) - : AAExtractArchiveOutputStreamOpen(config.outputDirectory.UTF8String, - (void*)CFBridgingRetain([config copyWithFunction:@"EXTRACT"]), aa_callback, 0, - 0); + ? AAVerifyDirectoryArchiveOutputStreamOpen(config.outputPath.UTF8String, keySet, (void*)configCopy, aa_callback, + UINT64_C(1) << 53, 0) + : AAExtractArchiveOutputStreamOpen(config.outputPath.UTF8String, (void*)configCopy, aa_callback, 0, 0); if (!extractStream) { ERRLOG(@"Failed to open extract stream"); + CFRelease(configCopy); AAArchiveStreamClose(innerDecodeStream); AAByteStreamClose(decompressStream); AAByteStreamClose(datStream); @@ -258,6 +257,7 @@ int extractAssetStandalone(AAByteStream byteStream, ExtractionConfiguration* con aa_callback, 0, 0) < 0) { ERRLOG(@"Failed to process archive stream"); AAArchiveStreamClose(extractStream); + CFRelease(configCopy); AAArchiveStreamClose(innerDecodeStream); AAByteStreamClose(decompressStream); AAByteStreamClose(datStream); @@ -265,6 +265,7 @@ int extractAssetStandalone(AAByteStream byteStream, ExtractionConfiguration* con } AAArchiveStreamClose(extractStream); + CFRelease(configCopy); AAArchiveStreamClose(innerDecodeStream); AAByteStreamClose(decompressStream); AAByteStreamClose(datStream); diff --git a/src/hpke.swift b/src/hpke.swift new file mode 100644 index 0000000..d27e15f --- /dev/null +++ b/src/hpke.swift @@ -0,0 +1,59 @@ +import CryptoKit +import Foundation + +// This needs to be manually synced with the enum in args.h +enum PrivateKeyFormat: Int { + case all + case PEM + case DER + case X963 +} + +enum HPKEWrapperError: Error { + case invalidPrivateKeyFormat(Int) + case invalidWrappedKey(String) + case invalidPrivateKey(String) +} + +@objc public class HPKEWrapper: NSObject { + @objc(unwrapPrivateKey:format:encryptedRequest:wrappedKey:error:) public static func unwrapKey( + rawPrivateKey: Data, + rawFormat: Int, + encryptedRequest: Data, + wrappedKey: Data + ) throws -> Data { + guard wrappedKey.count == 0x30 else { + throw HPKEWrapperError.invalidWrappedKey("Wrapped key is not 0x30 bytes") + } + + guard let format = PrivateKeyFormat(rawValue: rawFormat) else { + throw HPKEWrapperError.invalidPrivateKeyFormat(rawFormat) + } + + var privateKey: (any CryptoKit.HPKEDiffieHellmanPrivateKey)? = nil + if format == .all || format == .PEM { + if let privateKeyString = String(data: rawPrivateKey, encoding: .utf8) { + privateKey = try? P256.KeyAgreement.PrivateKey(pemRepresentation: privateKeyString) + } + } + else if privateKey == nil && (format == .all || format == .DER) { + privateKey = try? P256.KeyAgreement.PrivateKey(derRepresentation: rawPrivateKey) + } + else if privateKey == nil && (format == .all || format == .X963) { + privateKey = try? P256.KeyAgreement.PrivateKey(x963Representation: rawPrivateKey) + } + + if privateKey == nil { + throw HPKEWrapperError.invalidPrivateKey("Invalid private key") + } + + var recipient = try CryptoKit.HPKE.Recipient.init( + privateKey: privateKey!, + ciphersuite: HPKE.Ciphersuite.P256_SHA256_AES_GCM_256, + info: Data(), + encapsulatedKey: encryptedRequest + ) + + return try recipient.open(wrappedKey) + } +} diff --git a/src/utils.m b/src/utils.m new file mode 100644 index 0000000..5cc1f28 --- /dev/null +++ b/src/utils.m @@ -0,0 +1,34 @@ +#import "utils.h" + +NSData* makeSynchronousRequest(NSURLRequest* request, NSHTTPURLResponse** response, NSError** error) { + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block NSData* data = nil; + __block NSHTTPURLResponse* taskResponse = nil; + __block NSError* taskError = nil; + + NSURLSessionDataTask* task = [[NSURLSession sharedSession] + dataTaskWithRequest:request completionHandler:^(NSData* taskData, NSURLResponse* response, NSError* error) { + data = taskData; + + if (response) { + assert([response isKindOfClass:[NSHTTPURLResponse class]]); + } + taskResponse = (NSHTTPURLResponse*)response; + + taskError = error; + dispatch_semaphore_signal(semaphore); + }]; + [task resume]; + + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + + if (response) { + *response = taskResponse; + } + + if (error) { + *error = taskError; + } + + return data; +}