mirror of
https://github.com/EthanArbuckle/fairplay-decrypt.git
synced 2026-10-27 13:54:36 +00:00
249 lines
9.9 KiB
Python
249 lines
9.9 KiB
Python
"""FairPlay IPA decryption and page-key derivation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import struct
|
|
from collections.abc import Iterator, Sequence
|
|
from dataclasses import dataclass
|
|
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
|
|
ARM64 = 0x0100000C
|
|
MASK32 = 0xFFFFFFFF
|
|
PAGE_SIZE = 4096
|
|
ROUND_SELECTORS = (
|
|
((0, 1), (2, 3), (4, 5), (6, 7)),
|
|
((2, 1), (0, 3), (4, 7), (6, 5)),
|
|
((2, 0), (4, 1), (6, 3), (7, 5)),
|
|
((1, 2), (4, 0), (7, 3), (5, 6)),
|
|
)
|
|
ROUND_CONSTANTS = (0x68547C2E, 0x7E05E1DE, 0xD495038B, 0x0AB24ADD)
|
|
FINAL_SELECTORS = ((0, 1, 2, 3), (0, 2, 4, 6), (0, 3, 5, 7), (4, 5, 6, 7))
|
|
FINAL_CONSTANTS = (0x5B7198C7, 0xEB5DE22C, 0x0F8AA8FA, 0xF6E1BB3F)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MachoInfo:
|
|
base: int
|
|
cryptoff: int
|
|
cryptsize: int
|
|
cryptid_offset: int
|
|
|
|
|
|
def _xor(a: bytes, b: bytes) -> bytes:
|
|
if len(a) != len(b):
|
|
raise ValueError("XOR inputs must have equal lengths")
|
|
return bytes(x ^ y for x, y in zip(a, b))
|
|
|
|
|
|
def _xor_int(lhs: Sequence[int], rhs: Sequence[int]) -> Sequence[int]:
|
|
return [x ^ y for x, y in zip(lhs, rhs)]
|
|
|
|
|
|
def arm64_encryption_info(data: bytes) -> MachoInfo:
|
|
if data[:4] == b"\xca\xfe\xba\xbe":
|
|
count = struct.unpack_from(">I", data, 4)[0]
|
|
if not 0 < count <= 16 or 8 + count * 20 > len(data):
|
|
raise ValueError("invalid FAT32 architecture table")
|
|
entries = [struct.unpack_from(">IIIII", data, 8 + i * 20) for i in range(count)]
|
|
arm = [(off, size) for cpu, _sub, off, size, _align in entries if cpu == ARM64]
|
|
elif data[:4] == b"\xca\xfe\xba\xbf":
|
|
count = struct.unpack_from(">I", data, 4)[0]
|
|
if not 0 < count <= 16 or 8 + count * 32 > len(data):
|
|
raise ValueError("invalid FAT64 architecture table")
|
|
entries = [
|
|
struct.unpack_from(">IIQQII", data, 8 + i * 32) for i in range(count)
|
|
]
|
|
arm = [
|
|
(off, size)
|
|
for cpu, _sub, off, size, _align, _reserved in entries
|
|
if cpu == ARM64
|
|
]
|
|
elif data[:4] == b"\xcf\xfa\xed\xfe":
|
|
arm = [(0, len(data))]
|
|
else:
|
|
raise ValueError("not a supported arm64 Mach-O")
|
|
if len(arm) != 1:
|
|
raise ValueError(f"expected one arm64 slice, found {len(arm)}")
|
|
base, slice_size = arm[0]
|
|
if base + slice_size > len(data) or base + 32 > len(data):
|
|
raise ValueError("arm64 slice exceeds file")
|
|
magic, cpu, _sub, _type, ncmds, sizeofcmds, _flags, _res = struct.unpack_from(
|
|
"<IiiIIIII", data, base
|
|
)
|
|
if magic != 0xFEEDFACF or cpu != ARM64 or ncmds > 1000:
|
|
raise ValueError("invalid arm64 Mach-O header")
|
|
pos = base + 32
|
|
commands_end = pos + sizeofcmds
|
|
if commands_end > base + slice_size:
|
|
raise ValueError("load commands exceed arm64 slice")
|
|
found = []
|
|
for _ in range(ncmds):
|
|
if pos + 8 > commands_end:
|
|
raise ValueError("truncated load command")
|
|
cmd, size = struct.unpack_from("<II", data, pos)
|
|
if size < 8 or pos + size > commands_end:
|
|
raise ValueError("invalid load command")
|
|
if cmd == 0x2C:
|
|
if size < 24:
|
|
raise ValueError("short encryption info command")
|
|
cryptoff, cryptsize, cryptid = struct.unpack_from("<III", data, pos + 8)
|
|
found.append((cryptoff, cryptsize, cryptid, pos + 16))
|
|
pos += size
|
|
if len(found) != 1:
|
|
raise ValueError(f"expected one encryption command, found {len(found)}")
|
|
cryptoff, cryptsize, cryptid, cryptid_offset = found[0]
|
|
if cryptid != 1 or cryptsize == 0 or cryptsize % PAGE_SIZE:
|
|
raise ValueError(f"unsupported cryptid/size: {cryptid}, {cryptsize}")
|
|
if cryptoff + cryptsize > slice_size or cryptid_offset >= base + cryptoff:
|
|
raise ValueError("invalid encrypted region bounds")
|
|
return MachoInfo(base, cryptoff, cryptsize, cryptid_offset)
|
|
|
|
|
|
def _children(data: bytes, start: int, end: int) -> Iterator[tuple[bytes, int, int]]:
|
|
offset = start
|
|
while offset < end:
|
|
if offset + 8 > end:
|
|
raise ValueError("truncated SINF atom")
|
|
size = int.from_bytes(data[offset : offset + 4], "big")
|
|
if size < 8 or offset + size > end:
|
|
raise ValueError(f"invalid SINF atom size at {offset:#x}")
|
|
yield data[offset + 4 : offset + 8], offset + 8, offset + size
|
|
offset += size
|
|
|
|
|
|
def _sinf_field(sinf: bytes, wanted: bytes) -> bytes:
|
|
roots = list(_children(sinf, 0, len(sinf)))
|
|
if len(roots) != 1 or roots[0][0] != b"sinf":
|
|
raise ValueError("expected outer sinf atom")
|
|
schi = [x for x in _children(sinf, roots[0][1], roots[0][2]) if x[0] == b"schi"]
|
|
if len(schi) != 1:
|
|
raise ValueError("expected one schi container")
|
|
fields = [x for x in _children(sinf, schi[0][1], schi[0][2]) if x[0] == wanted]
|
|
if len(fields) != 1:
|
|
raise ValueError(f"expected one {wanted!r} field")
|
|
return sinf[fields[0][1] : fields[0][2]]
|
|
|
|
|
|
def derive_work16(credential: bytes, sinf: bytes) -> bytes:
|
|
name = _sinf_field(sinf, b"name").split(b"\0", 1)[0]
|
|
iviv = _sinf_field(sinf, b"iviv")
|
|
raw_priv = _sinf_field(sinf, b"priv")
|
|
if len(raw_priv) not in (440, 448):
|
|
raise ValueError("unsupported SINF priv-record length")
|
|
if len(raw_priv) == 440:
|
|
if raw_priv[-8:] != bytes(8):
|
|
raise ValueError("invalid SINF priv-record trailer")
|
|
raw_priv = raw_priv[:-8]
|
|
if len(credential) != 16 or len(iviv) != 16:
|
|
raise ValueError("invalid SINF priv-record dimensions")
|
|
iv = hashlib.md5(name + iviv).digest()
|
|
op = Cipher(algorithms.AES(_xor(credential, b"0" * 16)), modes.CBC(iv)).decryptor()
|
|
clear = op.update(raw_priv) + op.finalize()
|
|
if clear[:4] != b"itun" or clear[20:24] != b"key " or clear[44:48] != b"iviv":
|
|
raise ValueError("credential does not decode this SINF priv record")
|
|
return clear[24:40]
|
|
|
|
|
|
def arm64_supf_record_offset(supf: bytes, required_pages: int) -> int:
|
|
if (
|
|
len(supf) < 12
|
|
or supf[0] not in (3, 5)
|
|
or supf[1:4] not in (b"507", b"510", b"612")
|
|
):
|
|
raise ValueError("unsupported SUPF format")
|
|
header_size = 32 if supf[0] == 3 else 12
|
|
count = struct.unpack_from(">I", supf, 8)[0]
|
|
if not 0 < count <= 16:
|
|
raise ValueError("invalid SUPF architecture count")
|
|
pos = 12
|
|
for _ in range(count):
|
|
if pos + 4 + header_size > len(supf):
|
|
raise ValueError("truncated SUPF architecture header")
|
|
length, cpu, subtype = struct.unpack_from(">III", supf, pos)
|
|
if length < header_size or pos + 4 + length > len(supf):
|
|
raise ValueError("invalid SUPF architecture length")
|
|
pages = struct.unpack_from(">I", supf, pos + header_size)[0]
|
|
if cpu == ARM64 and subtype == 0:
|
|
if pages < required_pages or length < header_size + pages * 32:
|
|
raise ValueError("SUPF arm64 page records are incomplete")
|
|
return pos + 4 + header_size
|
|
pos += length + 4
|
|
raise ValueError("arm64 SUPF record missing")
|
|
|
|
|
|
def source_blocks(wrapped: bytes, absolute_index: int) -> tuple[bytes, bytes, bytes]:
|
|
if len(wrapped) != 16 or not 0 <= absolute_index < 1 << 32:
|
|
raise ValueError("invalid wrapped record or page index")
|
|
page_offset = (absolute_index << 12).to_bytes(8, "big")
|
|
first = wrapped[8:16] + page_offset
|
|
second = wrapped
|
|
third = page_offset + wrapped[:8]
|
|
return first, second, third
|
|
|
|
|
|
def finish(state: Sequence[int]) -> Sequence[int]:
|
|
# 16-bit
|
|
src = state
|
|
for selectors in ROUND_SELECTORS:
|
|
dst: list[int] = []
|
|
for pair, constant in zip(selectors, ROUND_CONSTANTS):
|
|
mixed = src[pair[0]] + src[pair[1]] # carry bit kept
|
|
square = mixed * mixed
|
|
x = (square & MASK32) ^ (mixed >> 16) ^ constant
|
|
dst.extend((x & 0xFFFF, x >> 16))
|
|
src = dst
|
|
|
|
# 32-bit
|
|
dst = []
|
|
for quartet, constant in zip(FINAL_SELECTORS, FINAL_CONSTANTS):
|
|
a = (src[quartet[1]] << 16) | src[quartet[0]]
|
|
b = (src[quartet[3]] << 16) | src[quartet[2]]
|
|
mixed = (a + b) & MASK32 # carry bit dropped
|
|
square = mixed * mixed
|
|
x = (square & MASK32) ^ (square >> 32) ^ constant
|
|
dst.extend((x & 0xFFFF, x >> 16))
|
|
|
|
return _xor_int(state, dst)
|
|
|
|
|
|
def block_transform(source: bytes, work: bytes) -> bytes:
|
|
"""AES-128-ECB encrypt of ``source`` block under ``work``."""
|
|
if len(source) != 16 or len(work) != 16:
|
|
raise ValueError("block-transform inputs require 16 bytes")
|
|
op = Cipher(algorithms.AES(work), modes.ECB()).encryptor()
|
|
return op.update(source) + op.finalize()
|
|
|
|
|
|
def derive_page_key(wrapped: bytes, absolute_index: int, work: bytes) -> bytes:
|
|
a, b, c = (
|
|
struct.unpack(">8H", block_transform(s, work))
|
|
for s in source_blocks(wrapped, absolute_index)
|
|
)
|
|
return struct.pack(">8H", *finish(_xor_int(finish(_xor_int(finish(a), b)), c)))
|
|
|
|
|
|
def decrypt_binary(
|
|
source: bytes, sinf: bytes, supf: bytes, credential: bytes
|
|
) -> tuple[bytes, int]:
|
|
info = arm64_encryption_info(source)
|
|
page_count = info.cryptsize // PAGE_SIZE
|
|
record_start = arm64_supf_record_offset(supf, page_count)
|
|
work = derive_work16(credential, sinf)
|
|
result = bytearray(source)
|
|
for local_page in range(page_count):
|
|
record = supf[
|
|
record_start + 32 * local_page : record_start + 32 * (local_page + 1)
|
|
]
|
|
if len(record) != 32:
|
|
raise ValueError("truncated SUPF page record")
|
|
absolute_index = (info.base + info.cryptoff) // PAGE_SIZE + local_page
|
|
key = derive_page_key(record[:16], absolute_index, work)
|
|
start = info.base + info.cryptoff + PAGE_SIZE * local_page
|
|
ciphertext = source[start : start + PAGE_SIZE]
|
|
op = Cipher(algorithms.AES(key), modes.CBC(record[16:32])).decryptor()
|
|
result[start : start + PAGE_SIZE] = op.update(ciphertext) + op.finalize()
|
|
struct.pack_into("<I", result, info.cryptid_offset, 0)
|
|
return bytes(result), page_count
|