Model the A6 crypto, interrupt, USB, and platform blocks needed to boot SecureROM through iBSS into iBEC Recovery. Add local lab identity, IMG3, and APTicket tooling, patched macOS recovery utilities, UART and GDB access, and English end-user documentation.
264 lines
9.0 KiB
Python
Executable File
264 lines
9.0 KiB
Python
Executable File
#!/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)
|