Files
QEMU-S5L8950X/scripts/a6-lab-apticket.py
T
Yaya48 5d9a60a926 hw/arm: add authenticated A6 IMG3 boot lab
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.
2026-09-01 09:51:49 -07:00

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)