#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0-or-later """Create a lab-signed A6 APTicket and prefix it to a ticketed iBEC IMG3.""" import argparse import hashlib import json from pathlib import Path import struct import subprocess import sys import tempfile IMG3_HEADER = struct.Struct("<4s4I") IMG3_MAGIC = b"3gmI" SHA1_WITH_RSA = bytes.fromhex("300b06092a864886f70d010105") SIGNATURE_SIZE = 128 DEFAULT_ECID = 0x200000 DEFAULT_CHIP_ID = 0x8950 DEFAULT_BOARD_ID = 0 DEFAULT_PRODUCTION_MODE = 1 DEFAULT_SECURITY_DOMAIN = 3 DEFAULT_BUILD_IDENTITY = "iBoot-3406.60.10~70" # iBoot 3406 expects the complete IMG3-era manifest shape even though the # boot-only lab path validates just the device binding, nonce, and iBEC digest. # Values for components that are not sent by this emulator are deterministic # lab placeholders. They do not come from an Apple-issued ticket. MANIFEST_DIGEST_TAGS = ( 7, 8, 9, 10, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28, 75, 78, 79, 80, 228, 229, ) MANIFEST_FLAG_TAGS = ( 48, 49, 50, 51, 54, 55, 56, 57, 59, 60, 61, 62, 84, 85, 86, 231, 232, 233, ) MANIFEST_BUILD_TAGS = (6, 20, 22) class TicketBuildError(RuntimeError): pass def boot_nonce(value): try: decoded = bytes.fromhex(value.removeprefix("0x")) except ValueError as error: raise argparse.ArgumentTypeError( "boot nonce must be hexadecimal" ) from error if len(decoded) != 8: raise argparse.ArgumentTypeError( "boot nonce must contain exactly 8 bytes" ) return decoded def encode_length(length): if length < 0: raise TicketBuildError("negative DER length") if length < 0x80: return bytes((length,)) encoded = length.to_bytes((length.bit_length() + 7) // 8, "big") return bytes((0x80 | len(encoded),)) + encoded def encode_tlv(identifier, payload): return bytes((identifier,)) + encode_length(len(payload)) + payload def encode_context(tag, payload, constructed=False): identifier = 0xA0 if constructed else 0x80 if tag < 31: return encode_tlv(identifier | tag, payload) encoded_tag = bytearray((tag & 0x7F,)) tag >>= 7 while tag: encoded_tag.insert(0, 0x80 | (tag & 0x7F)) tag >>= 7 prefix = bytes((identifier | 0x1F,)) + bytes(encoded_tag) return prefix + encode_length(len(payload)) + payload def sign_sha1(payload, key): try: result = subprocess.run( ["openssl", "dgst", "-sha1", "-sign", str(key)], input=payload, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, ) except FileNotFoundError as error: raise TicketBuildError("openssl is required") from error except subprocess.CalledProcessError as error: detail = error.stderr.decode("utf-8", errors="replace").strip() raise TicketBuildError(f"openssl failed: {detail}") from error return result.stdout def component_digest(component): if len(component) < IMG3_HEADER.size: raise TicketBuildError("iBEC is shorter than its IMG3 header") magic, full_size, data_size, shsh_offset, _ = IMG3_HEADER.unpack_from( component ) if magic != IMG3_MAGIC or full_size != len(component): raise TicketBuildError("iBEC is not a bounded IMG3 container") if data_size != len(component) - IMG3_HEADER.size: raise TicketBuildError("iBEC IMG3 data_size is inconsistent") if shsh_offset != data_size: raise TicketBuildError("iBEC must use external-ticket IMG3 layout") return hashlib.sha1(component[12:]).digest() def placeholder_digest(tag): label = f"QEMU A6 lab placeholder manifest tag {tag}".encode("ascii") return hashlib.sha1(label).digest() def build_manifest(args, digest): fields = {tag: placeholder_digest(tag) for tag in MANIFEST_DIGEST_TAGS} fields.update( {tag: (1).to_bytes(4, "little") for tag in MANIFEST_FLAG_TAGS} ) fields.update( { tag: args.build_identity.encode("ascii") for tag in MANIFEST_BUILD_TAGS } ) fields.update({ 1: args.ecid.to_bytes(8, "little"), 2: args.chip_id.to_bytes(4, "little"), 3: args.board_id.to_bytes(4, "little"), 4: args.production_mode.to_bytes(4, "little"), 5: args.security_domain.to_bytes(4, "little"), 18: hashlib.sha1(args.boot_nonce).digest(), 230: digest, }) return fields def atomic_write(path, payload): path.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( prefix=path.name + ".", dir=path.parent, delete=False ) as temporary: temporary.write(payload) temporary_path = Path(temporary.name) temporary_path.replace(path) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--component", required=True, type=Path) parser.add_argument("--identity", required=True, type=Path) parser.add_argument("--ticket-output", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument( "--ecid", type=lambda value: int(value, 0), default=DEFAULT_ECID ) parser.add_argument( "--chip-id", type=lambda value: int(value, 0), default=DEFAULT_CHIP_ID ) parser.add_argument( "--board-id", type=lambda value: int(value, 0), default=DEFAULT_BOARD_ID ) parser.add_argument( "--production-mode", type=lambda value: int(value, 0), choices=(0, 1), default=DEFAULT_PRODUCTION_MODE, ) parser.add_argument( "--security-domain", type=lambda value: int(value, 0), default=DEFAULT_SECURITY_DOMAIN, ) parser.add_argument("--build-identity", default=DEFAULT_BUILD_IDENTITY) parser.add_argument( "--boot-nonce", type=boot_nonce, required=True, help=( "raw 8-byte iBSS boot nonce in hexadecimal; stored as the " "original APTicket [18] SHA-1 nonce binding" ), ) args = parser.parse_args() required = ( (args.component, "ticketed iBEC"), (args.identity / "ticket-leaf-key.pem", "ticket leaf private key"), (args.identity / "ticket-cert-chain.der", "ticket certificate chain"), ) for path, description in required: if not path.is_file(): raise TicketBuildError(f"{description} not found: {path}") for path in (args.ticket_output, args.output): if path.exists(): raise TicketBuildError(f"refusing to overwrite output: {path}") component = args.component.read_bytes() digest = component_digest(component) replacements = build_manifest(args, digest) # DER SET members must be sorted by their complete encoded byte strings. # No external or previously issued ticket is used as input. encoded_fields = [ encode_context(tag, payload) for tag, payload in replacements.items() ] fields = b"".join(sorted(encoded_fields)) manifest_der = encode_tlv(0x31, fields) signature = sign_sha1(manifest_der, args.identity / "ticket-leaf-key.pem") if len(signature) != SIGNATURE_SIZE: raise TicketBuildError( f"ticket leaf produced {len(signature)} signature bytes; " "expected 128" ) chain = (args.identity / "ticket-cert-chain.der").read_bytes() ticket = encode_tlv( 0x30, SHA1_WITH_RSA + encode_tlv(0x31, fields) + encode_tlv(0x04, signature) + encode_context(1, chain, constructed=True), ) padded_size = (len(ticket) + 63) & ~63 combined = ticket + bytes((0xFF,)) * (padded_size - len(ticket)) + component atomic_write(args.ticket_output, ticket) atomic_write(args.output, combined) metadata = { "format": 1, "ticket_size": len(ticket), "ticket_padded_size": padded_size, "component_size": len(component), "combined_size": len(combined), "ecid": f"0x{args.ecid:016x}", "chip_id": f"0x{args.chip_id:04x}", "board_id": args.board_id, "production_mode": args.production_mode, "security_domain": args.security_domain, "build_identity": args.build_identity, "boot_nonce": args.boot_nonce.hex(), "boot_nonce_sha1": hashlib.sha1(args.boot_nonce).hexdigest(), "manifest_tags": sorted(replacements), "ibec_manifest_tag": 230, "ibec_sha1_range": "0x0c..EOF", "ibec_sha1": digest.hex(), "ticket_sha256": hashlib.sha256(ticket).hexdigest(), "combined_sha256": hashlib.sha256(combined).hexdigest(), } args.output.with_name(args.output.name + ".json").write_text( json.dumps(metadata, indent=2) + "\n", encoding="utf-8" ) print( f"Created lab APTicket ({len(ticket)} bytes) and ticketed iBEC " f"({len(combined)} bytes): {args.output}" ) print(f"Manifest [230] iBEC SHA-1: {digest.hex()}") if __name__ == "__main__": try: main() except (OSError, TicketBuildError, ValueError) as error: print(f"error: {error}", file=sys.stderr) sys.exit(2)