diff --git a/NOTICE b/NOTICE index 22de3187..9dd88271 100644 --- a/NOTICE +++ b/NOTICE @@ -21,3 +21,10 @@ framing are informed by Corellium's Project Sandcastle and Paul Praschl's Gen2 mtprops and MTFW HBPP item conversions (mtfw.c, makez2fw.c): https://github.com/Pauli1Go/HoolockLinux-linux-firmware Reference revision: 944541e67b4c603df328c77d30093551de802c17. + +The on-device ARMv7 handoff utility in boot/ios-launcher/kloader preserves +winocm's ios-kexec-utils source, distributed under GPL-2.0-or-later: +https://github.com/axi0mX/ios-kexec-utils +Reference revision: a1bbca8a8f2847d9c53070e76e0b5d093bda5993. +The original files are byte-pinned. A separate Orchard patch bounds the active +I/O/search paths and permits one sleep request; source.json records provenance. diff --git a/boot/cascadia/NOTICE b/boot/cascadia/NOTICE new file mode 100644 index 00000000..a20b5572 --- /dev/null +++ b/boot/cascadia/NOTICE @@ -0,0 +1,14 @@ +The ARMv7 cache-maintenance and Linux handoff sequence is derived from the +Project Cascadia A5 staging loader: + + https://github.com/rzxvx/Project-Cascadia + reference revision: 091d441f19e101699f7e1835a80ccb48d6e54eec + license: GPL-2.0 + +The local P105 work fixed the executable entry by discarding ARM unwind +metadata and asserting the upload address in the linker script. + +This producer keeps those entry safeguards. Hardware addresses come from +the device's profile. Orchard's configuration record and DTB editor add +bounded external-initramfs placement; that extension requires a fresh +hardware boot before it is described as working. diff --git a/boot/cascadia/README.md b/boot/cascadia/README.md new file mode 100644 index 00000000..e94076f0 --- /dev/null +++ b/boot/cascadia/README.md @@ -0,0 +1,43 @@ +# ARM32 iBEC staging payload + +`build --profile devices/p105/cascadia.toml --output DIR` builds a loader +with Orchard's kernel toolchain through `buildenv`. The output directory +contains `loader.bin`, its ELF and a source/profile/toolchain receipt. +The ELF check requires executable ARM code at the upload address and checks +every allocated section against the raw bytes. ARM unwind tables are +discarded so they cannot displace the entry instruction. + +`python -m orchard.image.cascadia --profile devices/p105/cascadia.toml +--loader DIR/loader.bin --loader-elf DIR/loader.elf --kernel zImage +--dtb s5l8942x-p105.dtb --initramfs recovery.cpio.gz --bootargs 'rdinit=/init' +--output linux.bin` assembles one device's payload. Kernel bytes remain +unchanged. The DTB editor preserves properties while updating `/chosen` +boot arguments and its initrd addresses, then reserves that destination. + +The layout is a checked loader followed by padding to 32 KiB, the zImage, +the edited DTB and the supplied initramfs, with each data address aligned to +eight bytes. The assembly entry disables the profile's watchdog before stack +setup; the C entry also clears it before validation. A rejected image remains +stopped for inspection. Validation checks the configuration checksum, bounds +and relocation alignment while touching only the uploaded low-memory image. +The loader then flushes caches and disables the inherited MMU/cache state +before copying the initramfs to reserved high RAM. Word copies handle the +aligned body and byte copies preserve the exact final tail. A barrier precedes +the Linux handoff with the DTB in `r2`. High RAM therefore needs no inherited +iBEC virtual mapping. +The fixed loader stack uses the gap below offset `0x7000`; code must finish +below `0x6000`, and the configuration starts at `0x7f00`. + +The zImage must carry the v6.12 ARM size table and end on an eight-byte +boundary. Its DTB follows immediately so the appended-DTB decompressor can +relocate it. Assembly bounds the inflated kernel together with the relocated +decompressor's workspace below the initramfs destination; unknown table +formats and overlapping layouts are refused. + +The receipt describes every byte range and its hash. It records +`architecture = "arm"` and `initramfs_encoding = "cascadia-dtb"`, allowing a +distribution to reject an image assembled with another architecture or +payload format. This external-initramfs extension awaits hardware testing; +the earlier P105 boot embedded its initramfs in the kernel. + +See `NOTICE` for source provenance. This producer contacts no device. diff --git a/boot/cascadia/build b/boot/cascadia/build new file mode 100755 index 00000000..9a1c927f --- /dev/null +++ b/boot/cascadia/build @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Build the ARM32 staging loader from source and check its executable entry.""" +import argparse +import json +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'src')) +from orchard import buildenv +from orchard.cascadia_payload import digest, profile, verify_loader + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--profile', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + described = profile(args.profile) + out = args.output.resolve() + if out.is_symlink() or (out.exists() and (not out.is_dir() or any(out.iterdir()))): + parser.error('The loader output must be a new or empty directory') + out.mkdir(parents=True, exist_ok=True) + (out / 'cascadia-generated.h').write_text(''.join( + f'#define {name.upper()} {hex(value)}\n' for name, value in sorted(described.items()) if name != 'schema')) + template = (ROOT / 'boot/cascadia/linux-boot.ld.in').read_text() + (out / 'linux-boot.ld').write_text(template.replace('@LOAD_ADDRESS@', hex(described['load_address']))) + image = 'localhost/orchard-hardware-kernel-toolchain:bookworm' + buildenv.build(image, ROOT / 'kernel/Containerfile', ROOT / 'kernel') + buildenv.run(image, ['sh', '/src/boot/cascadia/compile', '/out'], + [(ROOT, '/src', True), (out, '/out', False)]) + binary, elf = (out / 'loader.bin').read_bytes(), (out / 'loader.elf').read_bytes() + verify_loader(binary, described, elf) + source = ROOT / 'boot/cascadia' + receipt = {'schema': 1, 'kind': 'cascadia-loader', 'architecture': 'arm', + 'loader_format': 'cascadia', 'profile_sha256': digest(args.profile.read_bytes()), + 'toolchain': buildenv.identity(image), + 'sources': {path.name: digest(path.read_bytes()) for path in sorted(source.iterdir()) if path.is_file()}, + 'loader': {'bytes': len(binary), 'sha256': digest(binary)}, + 'elf': {'bytes': len(elf), 'sha256': digest(elf)}, + 'entry': described['load_address'], 'hardware_boot_verified': False} + (out / 'loader.json').write_text(json.dumps(receipt, indent=2, sort_keys=True) + '\n') + for name in ('linux_boot.o', 'linux_boot_start.o', 'cascadia-generated.h', 'linux-boot.ld'): + (out / name).unlink() + print(f'Loader: {out / "loader.bin"}') + + +if __name__ == '__main__': + main() diff --git a/boot/cascadia/compile b/boot/cascadia/compile new file mode 100755 index 00000000..c824c114 --- /dev/null +++ b/boot/cascadia/compile @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +# SPDX-License-Identifier: GPL-2.0-only +set -eu +out=$1 +src=${ORCHARD_SRC:-$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)} +clang-19 --target=armv7a-none-eabi -march=armv7-a -marm -mfloat-abi=soft \ + -ffreestanding -fno-builtin -fno-stack-protector -fno-unwind-tables \ + -fno-asynchronous-unwind-tables -Os -Wall -Wextra -Werror \ + -I "$out" -c "$src/boot/cascadia/linux_boot.c" -o "$out/linux_boot.o" +clang-19 --target=armv7a-none-eabi -march=armv7-a -marm -I "$out" \ + -c "$src/boot/cascadia/linux_boot_start.S" -o "$out/linux_boot_start.o" +ld.lld-19 --build-id=none -T "$out/linux-boot.ld" \ + "$out/linux_boot_start.o" "$out/linux_boot.o" -o "$out/loader.elf" +llvm-objcopy-19 -O binary "$out/loader.elf" "$out/loader.bin" diff --git a/boot/cascadia/linux-boot.ld.in b/boot/cascadia/linux-boot.ld.in new file mode 100644 index 00000000..aa0ddcbc --- /dev/null +++ b/boot/cascadia/linux-boot.ld.in @@ -0,0 +1,22 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +ENTRY(_start) +SECTIONS +{ + . = @LOAD_ADDRESS@; + .text : { + KEEP(*(.text.start)) + *(.text .text.*) + *(.rodata .rodata.*) + *(.data .data.*) + } + ASSERT(. <= @LOAD_ADDRESS@ + 0x6000, "loader overlaps its stack") + . = @LOAD_ADDRESS@ + 0x7f00; + .loader_config : { KEEP(*(.loader_config)) } + ASSERT(SIZEOF(.loader_config) == 68, "loader configuration changed") + .ARM.attributes 0 : { *(.ARM.attributes) } + /DISCARD/ : { + *(.comment) *(.note*) *(.eh_frame*) + *(.ARM.exidx*) *(.ARM.extab*) + } +} +ASSERT(_start == @LOAD_ADDRESS@, "loader entry differs from the upload address") diff --git a/boot/cascadia/linux_boot.c b/boot/cascadia/linux_boot.c new file mode 100644 index 00000000..952fc39e --- /dev/null +++ b/boot/cascadia/linux_boot.c @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* ARM32 iBEC handoff. See NOTICE for the Cascadia source and local changes. */ +typedef unsigned int u32; +typedef unsigned char u8; + +#include "cascadia-generated.h" + +struct config { + u8 magic[4]; + u32 version, header_size, load_address, kernel_offset; + u32 dtb_offset, dtb_size, initrd_offset, initrd_size; + u32 initrd_address, image_size, watchdog_address, l2_address; + u32 ram_start, ram_end, initrd_limit, crc32; +}; + +/* The assembler fills the dynamic fields and checksum before upload. */ +__attribute__((section(".loader_config"), used)) +volatile const struct config cascadia_config = { + { 'C', 'B', '3', '2' }, 1, sizeof(struct config), LOAD_ADDRESS, + KERNEL_OFFSET, 0, 0, 0, 0, INITRD_ADDRESS, 0, WATCHDOG_ADDRESS, + L2_ADDRESS, RAM_START, RAM_END, INITRD_LIMIT, 0, +}; + +static int range(u32 start, u32 size, u32 first, u32 end) +{ + return start >= first && start < end && size && size <= end - start; +} + +static u32 checksum(void) +{ + volatile const u8 *p = (volatile const u8 *)&cascadia_config; + u32 crc = ~0u, i, bit; + for (i = 0; i < sizeof(struct config) - sizeof(u32); i++) { + crc ^= p[i]; + for (bit = 0; bit < 8; bit++) + crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1))); + } + return ~crc; +} + +static u32 big_endian(volatile const u8 *p) +{ + return ((u32)p[0] << 24) | ((u32)p[1] << 16) | + ((u32)p[2] << 8) | p[3]; +} + +u32 cascadia_main(void) +{ + volatile const struct config *c = &cascadia_config; + volatile const u8 *dtb; + u32 i; + + if (WATCHDOG_ADDRESS) { + volatile u32 *wdt = (volatile u32 *)WATCHDOG_ADDRESS; + for (i = 0; i < 4; i++) + wdt[i] = 0; + } + if (c->magic[0] != 'C' || c->magic[1] != 'B' || + c->magic[2] != '3' || c->magic[3] != '2' || + c->version != 1 || c->header_size != sizeof(struct config) || + c->load_address != LOAD_ADDRESS || c->kernel_offset != KERNEL_OFFSET || + c->watchdog_address != WATCHDOG_ADDRESS || c->l2_address != L2_ADDRESS || + c->ram_start != RAM_START || c->ram_end != RAM_END || + c->initrd_limit != INITRD_LIMIT || + checksum() != c->crc32) + return 0; + if (!range(LOAD_ADDRESS, c->image_size, RAM_START, RAM_END) || + c->dtb_offset < KERNEL_OFFSET || c->dtb_size < 40 || + c->dtb_offset >= c->image_size || + c->dtb_size > c->image_size - c->dtb_offset || + c->initrd_offset < c->dtb_offset + c->dtb_size || + (c->initrd_offset & 3) || + c->initrd_offset >= c->image_size || + c->initrd_size > c->image_size - c->initrd_offset || + c->initrd_address != INITRD_ADDRESS || + !range(INITRD_ADDRESS, c->initrd_size, RAM_START, INITRD_LIMIT) || + INITRD_ADDRESS < LOAD_ADDRESS + c->image_size) + return 0; + dtb = (volatile const u8 *)(LOAD_ADDRESS + c->dtb_offset); + if (big_endian(dtb) != 0xd00dfeedu || big_endian(dtb + 4) != c->dtb_size) + return 0; + return LOAD_ADDRESS + c->dtb_offset; +} + +/* Called only after validation and MMU/cache teardown, using physical RAM. */ +void cascadia_relocate_initrd(u32 source, u32 target, u32 size) +{ + volatile const u32 *from = (volatile const u32 *)source; + volatile u32 *to = (volatile u32 *)target; + volatile const u8 *tail_from; + volatile u8 *tail_to; + + while (size >= sizeof(u32)) { + *to++ = *from++; + size -= sizeof(u32); + } + tail_from = (volatile const u8 *)from; + tail_to = (volatile u8 *)to; + while (size--) + *tail_to++ = *tail_from++; +} diff --git a/boot/cascadia/linux_boot_start.S b/boot/cascadia/linux_boot_start.S new file mode 100644 index 00000000..b2c10160 --- /dev/null +++ b/boot/cascadia/linux_boot_start.S @@ -0,0 +1,158 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Derived from Project Cascadia; see NOTICE. */ +#include "cascadia-generated.h" + + .arch armv7-a + .syntax unified + .arm + + .equ KERNEL_ENTRY, LOAD_ADDRESS + KERNEL_OFFSET + + .section .text.start, "ax" + .global _start + .global cascadia_main + .global linux_boot_jump +_start: + cpsid if + msr cpsr_c, #0xd3 + + /* Stop the profile's watchdog before depending on a stack or C entry. */ + ldr r0, =WATCHDOG_ADDRESS + cmp r0, #0 + movne r1, #0 + strne r1, [r0] + strne r1, [r0, #4] + strne r1, [r0, #8] + strne r1, [r0, #12] + dsb + ldr sp, =LOAD_ADDRESS + 0x7000 + bl cascadia_main + /* main validates the image and returns DTB physical address. */ + cmp r0, #0 + beq 1f + b linux_boot_jump +1: wfi + b 1b + +/* + * r0 = DTB physical address + * Tear down MMU/caches, then bx KERNEL_ENTRY with + * r0=0, r1=~0, r2=DTB + */ +linux_boot_jump: + mov r4, r0 /* save DTB */ + /* Capture the validated relocation before flushing inherited mappings. */ + ldr r3, =cascadia_config + ldr r5, [r3, #28] /* initrd_offset */ + ldr r6, [r3, #36] /* initrd_address */ + ldr r7, [r3, #32] /* initrd_size */ + ldr r0, =LOAD_ADDRESS + add r5, r5, r0 + + bl v7_flush_dcache_all + bl l2x0_flush_all + + mrc p15, 0, r0, c1, c0, 0 + bic r0, r0, #(1 << 0) /* MMU */ + bic r0, r0, #(1 << 2) /* D-cache */ + bic r0, r0, #(1 << 12) /* I-cache */ + bic r0, r0, #(1 << 11) /* Z */ + mcr p15, 0, r0, c1, c0, 0 + isb + + mov r0, #0 + mcr p15, 0, r0, c7, c5, 0 + mcr p15, 0, r0, c7, c5, 6 + mcr p15, 0, r0, c8, c7, 0 + dsb + isb + + /* iBEC need not identity-map the reserved high-RAM destination. */ + mov r0, r5 + mov r1, r6 + mov r2, r7 + bl cascadia_relocate_initrd + dsb + + mov r0, #0 + mvn r1, #0 + mov r2, r4 + ldr r3, =KERNEL_ENTRY + bx r3 + +1: wfi + b 1b + +v7_flush_dcache_all: + push {r4-r11, lr} + dmb + mrc p15, 1, r0, c0, c0, 1 + ands r3, r0, #0x7000000 + mov r3, r3, lsr #23 + beq .Lfinished + mov r10, #0 +.Lloop_level: + add r2, r10, r10, lsr #1 + mov r1, r0, lsr r2 + and r1, r1, #7 + cmp r1, #2 + blt .Lskip_level + mcr p15, 2, r10, c0, c0, 0 + isb + mrc p15, 1, r1, c0, c0, 0 + and r2, r1, #7 + add r2, r2, #4 + movw r4, #0x3ff + ands r4, r4, r1, lsr #3 + clz r5, r4 + movw r7, #0x7fff + ands r7, r7, r1, lsr #13 +.Lloop_way: + mov r9, r7 +.Lloop_set: + orr r11, r10, r4, lsl r5 + orr r11, r11, r9, lsl r2 + mcr p15, 0, r11, c7, c14, 2 + subs r9, r9, #1 + bge .Lloop_set + subs r4, r4, #1 + bge .Lloop_way +.Lskip_level: + add r10, r10, #2 + cmp r3, r10 + bgt .Lloop_level +.Lfinished: + mov r10, #0 + mcr p15, 2, r10, c0, c0, 0 + dsb + isb + pop {r4-r11, pc} + +l2x0_flush_all: + push {r0, r1, r2, lr} + ldr r0, =L2_ADDRESS + cmp r0, #0 + beq 2f + ldr r1, [r0] + lsr r2, r1, #6 + and r2, r2, #0x3f + cmp r2, #3 + cmpne r2, #2 + cmpne r2, #1 + bne 2f + ldr r1, [r0, #0x100] + tst r1, #1 + beq 2f + mov r1, #0xff00 + orr r1, r1, #0xff + str r1, [r0, #0x7fc] + ldr r2, =1000000 +1: ldr r1, [r0, #0x7fc] + tst r1, #1 + beq 2f + subs r2, r2, #1 + bne 1b +3: wfi + b 3b +2: dsb + pop {r0, r1, r2, pc} diff --git a/boot/initramfs/recovery/build-tools b/boot/initramfs/recovery/build-tools index a5ea574e..dd8a7fa5 100755 --- a/boot/initramfs/recovery/build-tools +++ b/boot/initramfs/recovery/build-tools @@ -1,12 +1,20 @@ #!/usr/bin/env bash -# Acquire pinned public inputs and build static AArch64 recovery tools. +# Acquire pinned public inputs and build static recovery tools for the selected ARM architecture. set -euo pipefail # Recipes need only Python 3.11+ and its standard library; Orchard passes its own. python=${ORCHARD_PYTHON:-python3} +arch=arm64 +while [[ $# -gt 0 ]]; do + case "$1" in + --arch) [[ $# -ge 2 ]] || { echo '--arch needs arm or arm64' >&2; exit 1; }; arch=$2; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac +done +case "$arch" in arm|arm64) ;; *) echo '--arch needs arm or arm64' >&2; exit 1 ;; esac here=$(cd "$(dirname "$0")" && pwd) -work="${OMA_RECOVERY_TOOLS_WORK:-$here/.work/tools}" +work="${OMA_RECOVERY_TOOLS_WORK:-$here/.work/tools-$arch}" mkdir -p "$work" -"$python" - "$here/upstream.lock" "$work" <<'PY' +"$python" - "$here/upstream.lock" "$work" "$arch" <<'PY' import hashlib import json import pathlib @@ -23,7 +31,8 @@ for component, filename in (('busybox', 'busybox.deb'), ('iw', 'iw.tar.xz'), ('zstd', 'zstd.tar.gz'), ('e2fsprogs', 'e2fsprogs.tar.gz')): - item = lock[component] + key = 'busybox_arm' if component == 'busybox' and sys.argv[3] == 'arm' else component + item = lock[key] target = work / filename if target.exists(): source = target.open('rb') @@ -49,9 +58,11 @@ for component, filename in (('busybox', 'busybox.deb'), pathlib.Path(temporary.name).rename(target) print(f'Verified {component}: {target}') PY -"$python" "$here/../../../src/orchard/buildenv.py" build --arch arm64 --file "$here/Containerfile" \ - --image localhost/orchard-hardware-recovery-tools:bookworm --context "$here" -"$python" "$here/../../../src/orchard/buildenv.py" run --arch arm64 \ - --bind "$work:/work:rw" --bind "$here:/recipe:ro" \ - --image localhost/orchard-hardware-recovery-tools:bookworm -- bash /recipe/build-tools-in-container +image=localhost/orchard-hardware-recovery-tools:bookworm +[[ "$arch" == arm64 ]] || image=localhost/orchard-hardware-recovery-tools-arm:bookworm +"$python" "$here/../../../src/orchard/buildenv.py" build --arch "$arch" --file "$here/Containerfile" \ + --image "$image" --context "$here" +"$python" "$here/../../../src/orchard/buildenv.py" run --arch "$arch" \ + --bind "$work:/work:rw" --bind "$here:/recipe:ro" --env "ORCHARD_RECOVERY_ARCH=$arch" \ + --image "$image" -- bash /recipe/build-tools-in-container echo "Recovery tools: $work/bin" diff --git a/boot/initramfs/recovery/build-tools-in-container b/boot/initramfs/recovery/build-tools-in-container index d6b04b48..8186df88 100755 --- a/boot/initramfs/recovery/build-tools-in-container +++ b/boot/initramfs/recovery/build-tools-in-container @@ -1,6 +1,9 @@ #!/bin/bash set -euo pipefail work=${ORCHARD_WORK:-/work} +recipe=${ORCHARD_RECIPE:-/recipe} +arch=${ORCHARD_RECOVERY_ARCH:-arm64} +case "$arch" in arm) deb_arch=armhf ;; arm64) deb_arch=arm64 ;; *) echo 'Unsupported recovery architecture' >&2; exit 1 ;; esac [[ ! -e "$work/bin" ]] || { echo 'Tool output exists; choose a fresh work directory' >&2; exit 1; } build=$(mktemp -d "$work/build.XXXXXX") mkdir "$build/bin" @@ -67,7 +70,7 @@ tar -xJf "$work/iw.tar.xz" -C "$build" tar -xzf "$work/zstd.tar.gz" -C "$build" make -C "$build/zstd-1.5.7/lib" -j2 libzstd.a CC=musl-gcc ZSTD_LEGACY_SUPPORT=0 $CC $CFLAGS -static -Wall -Wextra -pthread -I"$build/zstd-1.5.7/lib" -o "$build/bin/orchard-write" \ - "${ORCHARD_RECIPE:-/recipe}/orchard-write.c" "$build/zstd-1.5.7/lib/libzstd.a" + "$recipe/orchard-write.c" "$build/zstd-1.5.7/lib/libzstd.a" tar -xzf "$work/e2fsprogs.tar.gz" -C "$build" ( cd "$build/e2fsprogs-1.47.4" @@ -78,11 +81,13 @@ tar -xzf "$work/e2fsprogs.tar.gz" -C "$build" cp resize/resize2fs misc/tune2fs "$build/bin/" ) +[[ $(dpkg-deb -f "$work/busybox.deb" Architecture) == "$deb_arch" ]] || { + echo 'BusyBox package architecture differs from the target' >&2; exit 1; +} + dpkg-deb -x "$work/busybox.deb" "$build/package" cp "$build/package/bin/busybox" "$build/bin/busybox" chmod 755 "$build/bin/"* -for tool in "$build/bin/"*; do - file "$tool" | grep -q 'statically linked' || { echo "$tool is not static" >&2; exit 1; } -done +python3 "$recipe/check-tools.py" --arch "$arch" "$build/bin/"* mv "$build/bin" "$work/bin" echo "Static recovery tools copied to $work/bin" diff --git a/boot/initramfs/recovery/check-tools.py b/boot/initramfs/recovery/check-tools.py new file mode 100644 index 00000000..5ff6eb61 --- /dev/null +++ b/boot/initramfs/recovery/check-tools.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Check the recovery tools' target ISA and static ELF linkage without running them.""" +import argparse +from pathlib import Path +import stat +import struct + + +TARGETS = {'arm': (1, 40), 'arm64': (2, 183)} + + +def validate(data, arch): + if arch not in TARGETS: + raise ValueError('Unsupported recovery architecture: ' + arch) + elf_class, machine = TARGETS[arch] + if len(data) < 16 or data[:7] != b'\x7fELF' + bytes((elf_class, 1, 1)): + raise ValueError('ELF class or byte order differs from target ' + arch) + header = '<16sHHIIIIIHHHHHH' if elf_class == 1 else '<16sHHIQQQIHHHHHH' + if len(data) < struct.calcsize(header): + raise ValueError('Incomplete ELF header') + fields = struct.unpack_from(header, data) + if fields[1] not in (2, 3) or fields[2] != machine or fields[3] != 1: + raise ValueError('ELF machine or executable type differs from target ' + arch) + if fields[8] != struct.calcsize(header): + raise ValueError('Invalid ELF header size') + if arch == 'arm' and (fields[7] & 0xff000000 != 0x05000000 or + fields[7] & 0x600 != 0x400): + raise ValueError('ARM recovery requires EABI5 hard-float executables') + segment = ' len(data): + raise ValueError('Invalid ELF program header table') + dynamic = [] + for index in range(count): + values = struct.unpack_from(segment, data, offset + index * stride) + kind = values[0] + start, size = (values[1], values[4]) if elf_class == 1 else (values[2], values[5]) + if start + size > len(data): + raise ValueError('ELF segment exceeds the file') + if kind == 3: + raise ValueError('Recovery tools must be static: PT_INTERP is present') + if kind == 2: + dynamic.append((start, size)) + entry = ' 64 * 1024 * 1024: + raise ValueError('Expected a bounded regular executable: ' + str(path)) + validate(path.read_bytes(), arch) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--arch', choices=TARGETS, required=True) + parser.add_argument('tools', nargs='+', type=Path) + args = parser.parse_args() + for path in args.tools: + try: + check(path, args.arch) + except (OSError, ValueError, struct.error) as error: + parser.exit(1, str(path) + ': ' + str(error) + '\n') + + +if __name__ == '__main__': + main() diff --git a/boot/initramfs/recovery/init b/boot/initramfs/recovery/init index 4b6fd2fa..e8802263 100755 --- a/boot/initramfs/recovery/init +++ b/boot/initramfs/recovery/init @@ -17,19 +17,30 @@ case "$board" in apple,*) ;; *) echo "Not an Apple device for Orchard recovery: $board"; exec sh ;; esac -/sbin/recovery-usb -ip address add 172.16.42.1/30 dev usb0 -ip link set usb0 up -udhcpd -f /etc/udhcpd.conf & -echo "${board#apple,} recovery: USB serial ready; provision an SSH key before SSH starts." -# Respawn unconditionally: a USB re-enumeration hangs up the shell, which then -# exits non-zero, and serial is the only way to provision SSH. +# Serial survives networking failures and repeated USB enumeration. ( while true; do getty -n -l /bin/sh ttyGS0 115200 vt100; sleep 1; done ) & -# Internal storage comes up read-only in the background (storage-fence); the -# host installs over SSH once /run/orchard/storage-ready exists. -/usr/libexec/orchard/storage-fence > /run/orchard/storage.log 2>&1 & +if ! /sbin/recovery-usb > /run/orchard/usb.log 2>&1; then + echo 'Recovery USB setup failed; inspect /run/orchard/usb.log.' + while :; do sleep 60; done +fi +if ip address add 172.16.42.1/30 dev usb0 && ip link set usb0 up; then + udhcpd -f /etc/udhcpd.conf & +else + echo 'Recovery networking failed; USB serial remains available.' +fi +echo "${board#apple,} recovery: USB serial ready; provision an SSH key before SSH starts." +if storage=$(/usr/bin/python3 /usr/libexec/orchard/policy.py storage); then + if [ "$storage" = automatic ]; then + /usr/libexec/orchard/storage-fence > /run/orchard/storage.log 2>&1 & + else + echo manual > /run/orchard/storage-manual + echo 'Storage waits for a manual request after serial and authenticated SSH are ready.' + fi +else + echo 'Recovery storage policy failed; storage remains unloaded.' +fi # After an install, `orchard-device start` asks for the new root: if its boot # entry boots this same kernel, PID 1 switches into it, so the first start # needs no second DFU boot. Later starts go through the entry (orchard launch). diff --git a/boot/initramfs/recovery/install-toolchain b/boot/initramfs/recovery/install-toolchain index e0375165..6c33aaad 100644 --- a/boot/initramfs/recovery/install-toolchain +++ b/boot/initramfs/recovery/install-toolchain @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -packages=(bison build-essential bzip2 ca-certificates dpkg file flex linux-libc-dev musl-tools pkg-config xz-utils) +packages=(bison build-essential bzip2 ca-certificates dpkg file flex linux-libc-dev musl-tools pkg-config python3 xz-utils) if [[ ${1:-} == --packages ]]; then printf '%s\n' "${packages[@]}"; exit 0; fi apt-get update apt-get install -y --no-install-recommends "${packages[@]}" diff --git a/boot/initramfs/recovery/upstream.lock b/boot/initramfs/recovery/upstream.lock index c0cd9e62..0a8e32a1 100644 --- a/boot/initramfs/recovery/upstream.lock +++ b/boot/initramfs/recovery/upstream.lock @@ -26,5 +26,9 @@ "e2fsprogs": { "url": "https://mirrors.edge.kernel.org/pub/linux/kernel/people/tytso/e2fsprogs/v1.47.4/e2fsprogs-1.47.4.tar.gz", "sha256": "da274408bebbfd13a5a2fc3cfc66e3ffff17c48534673aa67f88d49b99123b96" + }, + "busybox_arm": { + "url": "https://deb.debian.org/debian/pool/main/b/busybox/busybox-static_1.35.0-4+deb12u1+b1_armhf.deb", + "sha256": "2f68dbeaebeda49f3cde34ac421d5add85fb1c837ff7c4a4971945340d9d66ff" } } diff --git a/boot/initramfs/recovery/usb-gadget b/boot/initramfs/recovery/usb-gadget index e5b7a826..4356b57a 100755 --- a/boot/initramfs/recovery/usb-gadget +++ b/boot/initramfs/recovery/usb-gadget @@ -1,6 +1,9 @@ #!/bin/sh # USB NCM networking and ACM serial, restricted to the attached host. set -eu +network=$(/usr/bin/python3 /usr/libexec/orchard/policy.py network) +modules=$(/usr/bin/python3 /usr/libexec/orchard/policy.py modules) +for module in $modules; do modprobe "$module"; done mkdir -p /sys/kernel/config mount -t configfs -o nodev,noexec,nosuid configfs /sys/kernel/config g=/sys/kernel/config/usb_gadget/linux-recovery @@ -16,8 +19,8 @@ echo 'Orchard Recovery' > "$g/strings/0x409/product" printf '%s\n' "$identity" > "$g/strings/0x409/serialnumber" echo 'USB network and serial' > "$g/configs/c.1/strings/0x409/configuration" echo 500 > "$g/configs/c.1/MaxPower" -mkdir "$g/functions/ncm.usb0" "$g/functions/acm.usb0" -ln -s "$g/functions/ncm.usb0" "$g/configs/c.1/ncm.usb0" +mkdir "$g/functions/$network.usb0" "$g/functions/acm.usb0" +ln -s "$g/functions/$network.usb0" "$g/configs/c.1/$network.usb0" ln -s "$g/functions/acm.usb0" "$g/configs/c.1/acm.usb0" set -- /sys/class/udc/* [ "$#" -eq 1 ] && [ -e "$1" ] diff --git a/boot/ios-launcher/README.md b/boot/ios-launcher/README.md new file mode 100644 index 00000000..20aff1d0 --- /dev/null +++ b/boot/ios-launcher/README.md @@ -0,0 +1,133 @@ +# On-device iOS launcher + +This producer prepares an iOS app and fixed-purpose privileged helper. The +helper verifies the installed assets, schedules one owned wake event and starts +kloader once. The patched iBEC reads the ARM32 staging bundle from the System +filesystem and enters it after checking its complete length and CRC-32. + +The device's `boot.json` supplies the native function addresses and the exact +prepared-iBEC hash. The shared source contains no board addresses. The supported +P105 recipe requires `iPad2,5` on iOS build `13G36`, with the exact prepared +`12H321` iBEC identified in that recipe. The input is a private plaintext image +prepared from the owner's firmware; this command verifies it before compiling. +Apple firmware and its generated derivative stay in the private output. + +## Prepare + +On macOS with Xcode command-line tools, first build the pinned kloader source: + +```sh +python3 boot/ios-launcher/kloader/build.py --output /private/tmp/ios-kloader +bin/orchard prepare-ios-launcher --device p105 \ + --ibec /private/path/ibec.raw --boot-image /private/path/staging-bundle.bin \ + --kloader /private/tmp/ios-kloader/kloader \ + --kloader-receipt /private/tmp/ios-kloader/result.json \ + --output /private/tmp/ios-launcher +``` + +The app and helper use Apple clang and ad-hoc signatures with SHA-1 and SHA-256 +CodeDirectories for iOS 9. The freestanding iBEC shim uses Orchard's existing +LLVM 19 kernel toolchain through Podman. `--host-toolchain` selects local LLVM +executables instead. Neither producer contacts a device. + +`installation.json` inventories every file under `files/`, with its hash and +intended destination, owner and mode. An installer must verify the receipt, +create protected directories and apply those modes, including root ownership +and mode 04755 for the helper. Preparing this archive leaves installation and +physical startup to a separately authorized operation. The app's Start button +initiates the attempt. Every new build reports device qualification as pending. + +The P105 recipe keeps its existing app directory and bundle identifier so installation replaces that launcher. The app label defaults to Orchard; `--title Omarchy-A` changes its display text. +The supported boot paths stay in the device recipe, so a display label does +not change the installed filenames or firmware compatibility. + +Public CB32 recovery images need a policy that explicitly permits `ios-device` +identity. Preparation creates a deterministic launch template with a bounded +whitespace slot in `/chosen/bootargs`; the receipt retains both the original +public input SHA-256 and the exact installed template SHA-256. The kernel and +initramfs bytes remain unchanged. A host-only public template is refused. + +On each permitted tap, the helper reads the live iOS registry's exact CFData +ECID, chip/board IDs, model and compatible aliases using the selected recipe's +guards. It creates a fresh nonce and writes separate attempt copies of the +payload and iBEC. Only the reserved bootargs slot and one generated iBEC CRC word +change. Both files pass hash/CRC checks before the helper schedules its owned +wake or starts kloader. The original installed assets stay hash-pinned, and the +existing one-attempt-per-iOS-boot lease remains in force. + +An offline start reports `identity_source=ios-device` and USB `ORG:i` alongside +its boot UUID and nonce. To attach from a Mac later, select the private output +that prepared that launcher: + +```sh +export ORCHARD_IOS_LAUNCHER=/private/path/to/prepared-launcher +``` + +The host checks its provision inventory and recipe, then requires matching +template/origin/nonce/chip/board fields through serial bootstrap and authenticated +SSH. A prior host-upload receipt is unnecessary for this origin. Preparing the +files does not qualify the current source on hardware. + +## Source and validation + +The launcher/helper and shim derive from Shayne Legault's P105 local-storage +bring-up. Its original iOS 9.3.5 app started the installed Omarchy-A desktop +with the cable unplugged. The sources here move the board constants into its +boot recipe and add an exact iOS-build check. They require a fresh physical +qualification after their host checks pass. + +The wake fixture executes the production wake lifecycle with real +CoreFoundation values and fake scheduling/process APIs. It checks cancellation +of the owned event on every returning path, and preserves foreign events. +Host tests also build the ARMv7 native executables and reject the historical +LC_MAIN instruction-mode mismatch. Synthetic iBEC tests verify exact input +checks and restrict changes to the declared hook and zero padding. + +The source-built kloader is pinned separately under `kloader/`; its receipt +records the exact compiler input and derived artifact. Its hardware status +is independent of the earlier installed binary. + +## Host recovery through iBEC + +`recovery-autogo` supplies the cable path for a Cascadia recovery payload. An +external entry tool must first place the board in a pwned iBSS, or an owner +must enter its prepared iBEC Recovery. Orchard verifies the selected device +and never runs the exploit entry automatically through this path. + +Prepare the board's private stage from its exact patched raw iBEC and the +original iBEC IMG3 member named in `boot.json`: + +```sh +bin/orchard prepare-recovery-autogo --device p105 \ + --template /private/path/iBEC.p105.RELEASE.dfu \ + --ibec-raw /private/path/ibec.raw \ + --output /private/path/prepared-boot +ORCHARD_PREPARED_BOOT=/private/path/prepared-boot bin/orchard run /path/to/payload --no-build +``` + +Omitting `--output` uses the private cache path for that board/build. The +producer consumes an already-patched plaintext image with the exact recipe +hash. Decrypting and signature-patching the original firmware remains an +external preparation step. Its provenance and hash are explicit in the recipe; +Orchard refuses a different raw input before writing output. + +The IMG3 packer derives from Project Cascadia's `scripts/img3pack.py`. It +preserves the template's tags while removing KBAG, replaces DATA with the +checked plaintext image, and verifies the complete resulting container. +Prepared stages stay on the owner's host. Public release manifests contain +the platform's Linux payload; they carry no Apple boot stage. + +Before each upload, Orchard checks the selected ECID with its chip/board +identity. A plain DFU device is refused. Each attempt freezes the validated +inputs in its private directory, records the next operation as uncertain, +then uploads that snapshot once. The CB32 payload contains its populated +loader and all Linux inputs in one complete bundle, which is uploaded once. +The legacy two-upload payload format is unsupported. Completion requires the +same ECID's identified Linux recovery. +An error retains the outcome receipt and ends the command. + +The host tests use synthetic IMG3 containers and a CB32 payload. They exercise +identity changes and failed transfers without connecting to USB. Current-base +recovery still needs an owner-authorized physical attempt. `orchard launch`'s +standalone m1n1 storage reader remains a separate ARM32 integration task; +the iOS app path above starts its installed file through iBEC directly. diff --git a/boot/ios-launcher/build b/boot/ios-launcher/build new file mode 100755 index 00000000..0d1de784 --- /dev/null +++ b/boot/ios-launcher/build @@ -0,0 +1,6 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +# A host-only producer from this Orchard snapshot. +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd -P) +PYTHONPATH="$root/src${PYTHONPATH:+:$PYTHONPATH}" exec python3 -m orchard.apple.ios_launcher "$@" diff --git a/boot/ios-launcher/ibec-local.c b/boot/ios-launcher/ibec-local.c new file mode 100644 index 00000000..e4556326 --- /dev/null +++ b/boot/ios-launcher/ibec-local.c @@ -0,0 +1,50 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Compile as freestanding Thumb for one hash-pinned, owner-supplied iBEC. + * The launcher checks SHA-256 before kexec. CRC-32 here catches a short or + * damaged filesystem read after iOS has stopped. No flash API is called. + */ +#include "local-boot-config.h" +typedef unsigned int u32; +typedef unsigned char u8; + +#ifdef LOCAL_BOOT_DYNAMIC_IDENTITY +__attribute__((used)) volatile const u32 orchard_local_boot_crc32 = LOCAL_BOOT_CRC32; +#endif + +__attribute__((section(".text.entry"))) +int orchard_local_boot(void) +{ + int (*finish_init)(void) = (void *)IBEC_FINISH_INIT; + int (*mount_boot)(const char *) = (void *)IBEC_MOUNT_BOOT; + int (*load_file)(const char *, void *, u32 *) = (void *)IBEC_LOAD_FILE; + u32 length = LOCAL_BOOT_BYTES; + u32 crc = ~0u; + u8 *data = (void *)LOCAL_BOOT_ADDRESS; + finish_init(); + if (mount_boot(LOCAL_BOOT_DEVICE) || load_file(LOCAL_BOOT_PATH, data, &length) || + length != LOCAL_BOOT_BYTES) + return 0; + for (u32 i = 0; i < length; i++) { + crc ^= data[i]; + for (u32 bit = 0; bit < 8; bit++) + crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1u))); + } +#ifdef LOCAL_BOOT_DYNAMIC_IDENTITY + if (~crc != orchard_local_boot_crc32) +#else + if (~crc != LOCAL_BOOT_CRC32) +#endif + return 0; + /* Filesystem reads may populate the buffer through the data cache. + * Clean each possible A9 cache line before instruction fetch can use it. + */ + __asm__ volatile("dsb sy" : : : "memory"); + for (u32 address = (u32)data; address < (u32)data + length; address += 32) + __asm__ volatile("mcr p15, 0, %0, c7, c10, 1" : : "r"(address) : "memory"); + __asm__ volatile("dsb sy\n" + "mcr p15, 0, %0, c7, c5, 0\n" + "dsb sy\n" + "isb sy\n" : : "r"(0) : "memory"); + ((void (*)(void))data)(); + return 0; +} diff --git a/boot/ios-launcher/ios-identity-bind.h b/boot/ios-launcher/ios-identity-bind.h new file mode 100644 index 00000000..e0b0102d --- /dev/null +++ b/boot/ios-launcher/ios-identity-bind.h @@ -0,0 +1,175 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +#ifndef ORCHARD_IOS_IDENTITY_BIND_H +#define ORCHARD_IOS_IDENTITY_BIND_H + +#include +#include +#include + +#define ORCHARD_IOS_IMAGE_MAX (64u * 1024u * 1024u) +#define ORCHARD_IOS_IBEC_MAX (1u * 1024u * 1024u) +#define ORCHARD_IOS_HANDOFF_SLOT_MAX 1023u +#define ORCHARD_IOS_HANDOFF_JSON_MAX 512u +#define ORCHARD_IOS_HANDOFF_PREFIX "orchard.recovery-handoff=" + +struct orchard_ios_text { + char bytes[ORCHARD_IOS_HANDOFF_JSON_MAX]; + size_t count; + int failed; +}; + +static inline void orchard_ios_text_append(struct orchard_ios_text *text, + const char *bytes, size_t count) +{ + if (text->failed || count > sizeof(text->bytes) - text->count) { + text->failed = 1; + return; + } + for (size_t i = 0; i < count; i++) + text->bytes[text->count++] = bytes[i]; +} + +static inline void orchard_ios_text_number(struct orchard_ios_text *text, uint64_t value) +{ + char number[21]; + int count = snprintf(number, sizeof(number), "%llu", (unsigned long long)value); + if (count <= 0 || count >= (int)sizeof(number)) { + text->failed = 1; + return; + } + orchard_ios_text_append(text, number, (size_t)count); +} + +static inline int orchard_ios_profile_length(const char *profile, size_t *length) +{ + if (!profile || !length) + return -1; + for (size_t i = 0; i <= 80u; i++) { + unsigned char value = (unsigned char)profile[i]; + if (!value) { + if (!i) + return -1; + *length = i; + return 0; + } + if (i == 80u || !((value >= 'a' && value <= 'z') || + (value >= '0' && value <= '9') || + (i && (value == '.' || value == '_' || value == '-')))) + return -1; + } + return -1; +} + +static inline int orchard_ios_hex_string(const char *text, size_t length, int nonzero) +{ + unsigned char any = 0; + if (!text) + return -1; + for (size_t i = 0; i < length; i++) { + unsigned char value = (unsigned char)text[i]; + if (!((value >= '0' && value <= '9') || (value >= 'a' && value <= 'f'))) + return -1; + any |= (unsigned char)(value != '0'); + } + return text[length] || (nonzero && !any) ? -1 : 0; +} + +/* The caller supplies the parsed /chosen bootargs whitespace slot. This + * function changes only that range, after validating every input and byte. + * Its JSON order and unpadded base64url match recovery_identity.py. + */ +static inline int orchard_ios_bind_handoff(unsigned char *image, size_t length, + size_t slot_offset, size_t slot_bytes, const char *profile, + uint64_t ecid, uint32_t chip, uint32_t board, + const char *nonce, const char *template_sha256) +{ + static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + static const char prefix[] = ORCHARD_IOS_HANDOFF_PREFIX; + struct orchard_ios_text json = {{0}, 0, 0}; + char encoded[(ORCHARD_IOS_HANDOFF_JSON_MAX * 4u + 2u) / 3u]; + size_t profile_bytes, encoded_bytes = 0; + if (!image || !length || length > ORCHARD_IOS_IMAGE_MAX || + !slot_bytes || slot_bytes > ORCHARD_IOS_HANDOFF_SLOT_MAX || + slot_offset > length || slot_bytes > length - slot_offset || !ecid || !chip || + orchard_ios_profile_length(profile, &profile_bytes) || + orchard_ios_hex_string(nonce, 32, 1) || + orchard_ios_hex_string(template_sha256, 64, 0)) + return -1; + +#define ORCHARD_IOS_TEXT(literal) orchard_ios_text_append(&json, literal, sizeof(literal) - 1u) + ORCHARD_IOS_TEXT("{\"board\":"); + orchard_ios_text_number(&json, board); + ORCHARD_IOS_TEXT(",\"chip\":"); + orchard_ios_text_number(&json, chip); + ORCHARD_IOS_TEXT(",\"ecid\":"); + orchard_ios_text_number(&json, ecid); + ORCHARD_IOS_TEXT(",\"handoff_id\":\""); + orchard_ios_text_append(&json, nonce, 32); + ORCHARD_IOS_TEXT("\",\"profile\":\""); + orchard_ios_text_append(&json, profile, profile_bytes); + ORCHARD_IOS_TEXT("\",\"schema\":1,\"source\":\"ios-device\",\"template_sha256\":\""); + orchard_ios_text_append(&json, template_sha256, 64); + ORCHARD_IOS_TEXT("\"}"); +#undef ORCHARD_IOS_TEXT + if (json.failed) + return -1; + for (size_t i = 0; i < json.count; i += 3u) { + size_t remaining = json.count - i; + uint32_t value = (uint32_t)(unsigned char)json.bytes[i] << 16; + if (remaining > 1u) + value |= (uint32_t)(unsigned char)json.bytes[i + 1u] << 8; + if (remaining > 2u) + value |= (uint32_t)(unsigned char)json.bytes[i + 2u]; + encoded[encoded_bytes++] = alphabet[(value >> 18) & 63u]; + encoded[encoded_bytes++] = alphabet[(value >> 12) & 63u]; + if (remaining > 1u) + encoded[encoded_bytes++] = alphabet[(value >> 6) & 63u]; + if (remaining > 2u) + encoded[encoded_bytes++] = alphabet[value & 63u]; + } + const size_t prefix_bytes = sizeof(prefix) - 1u; + if (prefix_bytes + encoded_bytes > slot_bytes) + return -1; + for (size_t i = 0; i < slot_bytes; i++) + if (image[slot_offset + i] != ' ') + return -1; + for (size_t i = 0; i < prefix_bytes; i++) + image[slot_offset + i] = (unsigned char)prefix[i]; + for (size_t i = 0; i < encoded_bytes; i++) + image[slot_offset + prefix_bytes + i] = (unsigned char)encoded[i]; + /* The original whitespace already pads the rest of this fixed slot. */ + return 0; +} + +static inline int orchard_ios_crc32(const unsigned char *data, size_t length, + uint32_t *result) +{ + uint32_t crc = UINT32_MAX; + if ((!data && length) || length > ORCHARD_IOS_IMAGE_MAX || !result) + return -1; + for (size_t i = 0; i < length; i++) { + crc ^= data[i]; + for (unsigned int bit = 0; bit < 8; bit++) + crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1u))); + } + *result = ~crc; + return 0; +} + +/* A generated CRC data word is the only permitted iBEC copy modification. */ +static inline int orchard_ios_patch_ibec_crc(unsigned char *image, size_t length, + size_t offset, uint32_t expected, uint32_t replacement) +{ + if (!image || length < 4u || length > ORCHARD_IOS_IBEC_MAX || + offset > length || 4u > length - offset) + return -1; + uint32_t observed = (uint32_t)image[offset] | (uint32_t)image[offset + 1u] << 8 | + (uint32_t)image[offset + 2u] << 16 | (uint32_t)image[offset + 3u] << 24; + if (observed != expected) + return -1; + for (unsigned int i = 0; i < 4; i++) + image[offset + i] = (unsigned char)(replacement >> (i * 8u)); + return 0; +} + +#endif diff --git a/boot/ios-launcher/ios-launcher.m b/boot/ios-launcher/ios-launcher.m new file mode 100644 index 00000000..dc9df9c0 --- /dev/null +++ b/boot/ios-launcher/ios-launcher.m @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-2.0-only +#import +#include +#include "ios-start-config.h" +#include +#include +#include + +@interface OrchardLauncher : UIResponder +@property(strong, nonatomic) UIWindow *window; +@property(strong, nonatomic) UILabel *message; +@property(strong, nonatomic) UIButton *start; +@end + +@implementation OrchardLauncher +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options +{ + (void)application; + (void)options; + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + UIViewController *controller = [UIViewController new]; + self.window.rootViewController = controller; + UIView *view = controller.view; + view.backgroundColor = [UIColor colorWithRed:0.06 green:0.09 blue:0.12 alpha:1]; + self.message = [[UILabel alloc] initWithFrame:CGRectZero]; + self.message.text = LAUNCHER_TITLE; + self.message.textColor = [UIColor whiteColor]; + self.message.font = [UIFont systemFontOfSize:26]; + self.message.textAlignment = NSTextAlignmentCenter; + self.message.numberOfLines = 0; + self.message.translatesAutoresizingMaskIntoConstraints = NO; + [view addSubview:self.message]; + self.start = [UIButton buttonWithType:UIButtonTypeSystem]; + [self.start setTitle:LAUNCHER_START_TITLE forState:UIControlStateNormal]; + self.start.titleLabel.font = [UIFont systemFontOfSize:22]; + self.start.translatesAutoresizingMaskIntoConstraints = NO; + [self.start addTarget:self action:@selector(startLinux) forControlEvents:UIControlEventTouchUpInside]; + [view addSubview:self.start]; + [NSClassFromString(@"NSLayoutConstraint") activateConstraints:@[ + [self.message.centerXAnchor constraintEqualToAnchor:view.centerXAnchor], + [self.message.centerYAnchor constraintEqualToAnchor:view.centerYAnchor constant:-60], + [self.message.widthAnchor constraintEqualToAnchor:view.widthAnchor multiplier:0.85], + [self.start.centerXAnchor constraintEqualToAnchor:view.centerXAnchor], + [self.start.topAnchor constraintEqualToAnchor:self.message.bottomAnchor constant:30], + [self.start.heightAnchor constraintEqualToConstant:60] + ]]; + [self.window makeKeyAndVisible]; + return YES; +} +- (void)startLinux +{ + self.start.enabled = NO; + self.message.text = LAUNCHER_STARTING_TITLE; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + pid_t child; + char *const argv[] = {HELPER_PATH, NULL}; + char *const environment[] = {"PATH=/usr/bin:/bin:/usr/sbin:/sbin", NULL}; + int status = 0; + int error = posix_spawn(&child, argv[0], NULL, NULL, argv, environment); + if (!error && waitpid(child, &status, 0) != child) + error = 1; + NSString *message = @"Startup stopped. Restart the iPad before trying again."; + if (!error && WIFEXITED(status) && WEXITSTATUS(status) == 11) + message = @"Enable the jailbreak, then open this app again."; + if (!error && WIFEXITED(status) && WEXITSTATUS(status) == 12) + message = @"The installed boot files need repair."; + if (!error && WIFEXITED(status) && WEXITSTATUS(status) == 14) + message = @"The startup wake timer could not be prepared. Restart the iPad before trying again."; + if (!error && WIFEXITED(status) && WEXITSTATUS(status) == 16) + message = @"The startup wake timer could not be cleared. Restart the iPad before trying again."; + dispatch_async(dispatch_get_main_queue(), ^{ self.message.text = message; }); + }); +} +@end + +int main(int argc, char **argv) +{ + @autoreleasepool { + if (argc == 2 && !strcmp(argv[1], "--check-runtime")) { + NSObject *object = [[NSObject alloc] init]; + if (!object) + return 20; + NSString *required[] = {@"NSArray", @"UIButton", @"UIColor", @"UIFont", + @"UILabel", @"UIResponder", @"UIScreen", @"UIViewController", @"UIWindow", + @"NSLayoutConstraint", @"NSLayoutXAxisAnchor", @"NSLayoutYAxisAnchor", + @"NSLayoutDimension", @"OrchardLauncher"}; + for (unsigned int index = 0; index < sizeof(required) / sizeof(required[0]); index++) { + if (!NSClassFromString(required[index])) + return 21; + } + puts("Orchard launcher runtime verified"); + return 0; + } + return UIApplicationMain(argc, argv, nil, NSStringFromClass([OrchardLauncher class])); + } +} diff --git a/boot/ios-launcher/ios-live-identity.h b/boot/ios-launcher/ios-live-identity.h new file mode 100644 index 00000000..1983e70e --- /dev/null +++ b/boot/ios-launcher/ios-live-identity.h @@ -0,0 +1,125 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +#ifndef ORCHARD_IOS_LIVE_IDENTITY_H +#define ORCHARD_IOS_LIVE_IDENTITY_H + +#include +#include +#include +#include +#include + +/* All target values are generated from the selected device and boot recipe. */ +_Static_assert(sizeof(TARGET_MACHINE) > 1u && sizeof(TARGET_MACHINE) <= 128u, + "TARGET_MACHINE must be a bounded C string"); +_Static_assert(sizeof(TARGET_MODEL) > 1u && sizeof(TARGET_MODEL) <= 128u, + "TARGET_MODEL must be a bounded C string"); +_Static_assert(sizeof(TARGET_IOS_BUILD) > 1u && sizeof(TARGET_IOS_BUILD) <= 128u, + "TARGET_IOS_BUILD must be a bounded C string"); +_Static_assert((uint64_t)TARGET_CHIP > 0u && (uint64_t)TARGET_CHIP <= UINT32_MAX, + "TARGET_CHIP must be a nonzero 32-bit integer"); +_Static_assert((uint64_t)TARGET_BOARD <= UINT32_MAX, "TARGET_BOARD must be a 32-bit integer"); +_Static_assert(TARGET_REGISTRY_COMPATIBLE_BYTES > 0u && + TARGET_REGISTRY_COMPATIBLE_BYTES <= 128u && + TARGET_REGISTRY_COMPATIBLE_BYTES <= sizeof(TARGET_REGISTRY_COMPATIBLE), + "TARGET_REGISTRY_COMPATIBLE must carry its bounded exact byte count"); + +typedef mach_port_t orchard_ios_registry_entry_t; +extern orchard_ios_registry_entry_t IORegistryEntryFromPath(mach_port_t, const char *); +extern CFTypeRef IORegistryEntryCreateCFProperty(orchard_ios_registry_entry_t, CFStringRef, + CFAllocatorRef, uint32_t); +extern kern_return_t IOObjectRelease(mach_port_t); + +static inline int orchard_ios_same_bytes(const unsigned char *first, + const unsigned char *second, size_t count) +{ + for (size_t i = 0; i < count; i++) + if (first[i] != second[i]) + return -1; + return 0; +} + +static inline int orchard_ios_sysctl_exact(const char *name, const char *expected, + size_t expected_bytes) +{ + unsigned char value[128] = {0}; + size_t length = sizeof(value); + if (!expected_bytes || expected_bytes > sizeof(value) || + sysctlbyname(name, value, &length, NULL, 0) || length != expected_bytes) + return -1; + return orchard_ios_same_bytes(value, (const unsigned char *)expected, length); +} + +static inline int orchard_ios_data_exact(orchard_ios_registry_entry_t entry, + CFStringRef name, unsigned char *out, size_t bytes) +{ + if (!entry || !out || !bytes || bytes > 128u) + return -1; + CFTypeRef value = IORegistryEntryCreateCFProperty(entry, name, NULL, 0); + if (!value) + return -1; + int result = -1; + if (CFGetTypeID(value) == CFDataGetTypeID() && + CFDataGetLength((CFDataRef)value) == (CFIndex)bytes) { + const unsigned char *source = CFDataGetBytePtr((CFDataRef)value); + if (source) { + for (size_t i = 0; i < bytes; i++) + out[i] = source[i]; + result = 0; + } + } + CFRelease(value); + return result; +} + +static inline uint32_t orchard_ios_le32(const unsigned char bytes[4]) +{ + return (uint32_t)bytes[0] | (uint32_t)bytes[1] << 8 | + (uint32_t)bytes[2] << 16 | (uint32_t)bytes[3] << 24; +} + +/* Only the exact live sysctls and named IODeviceTree CFData properties are + * accepted. The caller's ECID is written after all guards have passed. + */ +static inline int orchard_ios_live_identity(uint64_t *ecid) +{ + unsigned char unique[8], chip[4], board[4]; + unsigned char model[sizeof(TARGET_MACHINE)]; + unsigned char compatible[TARGET_REGISTRY_COMPATIBLE_BYTES]; + if (!ecid || + TARGET_REGISTRY_COMPATIBLE[TARGET_REGISTRY_COMPATIBLE_BYTES - 1u] || + orchard_ios_sysctl_exact("hw.machine", TARGET_MACHINE, sizeof(TARGET_MACHINE)) || + orchard_ios_sysctl_exact("hw.model", TARGET_MODEL, sizeof(TARGET_MODEL)) || + orchard_ios_sysctl_exact("kern.osversion", TARGET_IOS_BUILD, sizeof(TARGET_IOS_BUILD))) + return -1; + + orchard_ios_registry_entry_t chosen = IORegistryEntryFromPath(MACH_PORT_NULL, "IODeviceTree:/chosen"); + if (!chosen) + return -1; + int failed = orchard_ios_data_exact(chosen, CFSTR("unique-chip-id"), unique, sizeof(unique)) || + orchard_ios_data_exact(chosen, CFSTR("chip-id"), chip, sizeof(chip)) || + orchard_ios_data_exact(chosen, CFSTR("board-id"), board, sizeof(board)); + IOObjectRelease(chosen); + if (failed || orchard_ios_le32(chip) != TARGET_CHIP || + orchard_ios_le32(board) != TARGET_BOARD) + return -1; + uint64_t observed = 0; + for (unsigned int i = 0; i < 8; i++) + observed |= (uint64_t)unique[i] << (i * 8u); + if (!observed) + return -1; + + orchard_ios_registry_entry_t root = IORegistryEntryFromPath(MACH_PORT_NULL, "IODeviceTree:/"); + if (!root) + return -1; + failed = orchard_ios_data_exact(root, CFSTR("model"), model, sizeof(model)) || + orchard_ios_data_exact(root, CFSTR("compatible"), compatible, sizeof(compatible)); + IOObjectRelease(root); + if (failed || orchard_ios_same_bytes(model, (const unsigned char *)TARGET_MACHINE, sizeof(model)) || + orchard_ios_same_bytes(compatible, (const unsigned char *)TARGET_REGISTRY_COMPATIBLE, + sizeof(compatible))) + return -1; + *ecid = observed; + return 0; +} + +#endif diff --git a/boot/ios-launcher/ios-start.c b/boot/ios-launcher/ios-start.c new file mode 100644 index 00000000..4cf924de --- /dev/null +++ b/boot/ios-launcher/ios-start.c @@ -0,0 +1,446 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Fixed-purpose setuid helper for the owner-installed iOS launcher. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ios-start-config.h" +#ifdef IOS_IDENTITY_SLOT_OFFSET +#include "ios-identity-bind.h" +#include "ios-live-identity.h" +#endif + +static int checked(const char *path, size_t length, const char *expected) +{ + int fd = open(path, O_RDONLY | O_NOFOLLOW); + struct stat info; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + char text[CC_SHA256_DIGEST_LENGTH * 2 + 1]; + if (fd < 0) + return -1; + if (fstat(fd, &info) || !S_ISREG(info.st_mode) || info.st_uid || + (info.st_mode & 022) || (uint64_t)info.st_size != length) { + close(fd); + return -1; + } + void *bytes = mmap(NULL, length, PROT_READ, MAP_PRIVATE, fd, 0); + if (bytes == MAP_FAILED) { + close(fd); + return -1; + } + CC_SHA256(bytes, (CC_LONG)length, digest); + munmap(bytes, length); + close(fd); + for (unsigned int i = 0; i < sizeof(digest); i++) + snprintf(text + i * 2, 3, "%02x", digest[i]); + return strcmp(text, expected) ? -1 : 0; +} + +#ifdef IOS_IDENTITY_SLOT_OFFSET +static void sha_text(const unsigned char *bytes, size_t length, char text[65]) +{ + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(bytes, (CC_LONG)length, digest); + for (unsigned int i = 0; i < sizeof(digest); i++) + snprintf(text + i * 2, 3, "%02x", digest[i]); +} + +static unsigned char *checked_copy(const char *path, size_t length, const char *expected) +{ + if (!length || length > 64u * 1024u * 1024u) + return NULL; + int fd = open(path, O_RDONLY | O_NOFOLLOW); + struct stat info; + if (fd < 0) + return NULL; + if (fstat(fd, &info) || !S_ISREG(info.st_mode) || info.st_uid || + (info.st_mode & 022) || (uint64_t)info.st_size != length) { + close(fd); + return NULL; + } + unsigned char *copy = malloc(length); + if (!copy) { + close(fd); + return NULL; + } + size_t at = 0; + while (at < length) { + ssize_t count = pread(fd, copy + at, length - at, (off_t)at); + if (count <= 0) { + close(fd); + free(copy); + return NULL; + } + at += (size_t)count; + } + char digest[65]; + sha_text(copy, length, digest); + if (close(fd) || strcmp(digest, expected)) { + free(copy); + return NULL; + } + return copy; +} + +static int sync_parent(const char *path) +{ + char directory[1024]; + const char *slash; + size_t length; + if (!path || path[0] != '/') + return -1; + slash = strrchr(path, '/'); + if (!slash || !slash[1]) + return -1; + length = slash == path ? 1 : (size_t)(slash - path); + if (length >= sizeof(directory)) + return -1; + memcpy(directory, path, length); + directory[length] = '\0'; + int fd = open(directory, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (fd < 0) + return -1; + int result = fsync(fd); + if (close(fd)) + result = -1; + return result ? -1 : 0; +} + +static int write_attempt(const char *path, const unsigned char *bytes, size_t length) +{ + char pending[1024], digest[65]; + int count = snprintf(pending, sizeof(pending), "%s.pending", path); + if (count <= 0 || count >= (int)sizeof(pending)) + return -1; + int fd = open(pending, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600); + if (fd < 0) + return -1; + size_t at = 0; + while (at < length) { + ssize_t written = write(fd, bytes + at, length - at); + if (written <= 0) + goto failed; + at += (size_t)written; + } + if (fsync(fd)) + goto failed; + if (close(fd)) { + unlink(pending); + return -1; + } + if (rename(pending, path)) { + unlink(pending); + return -1; + } + if (sync_parent(path)) + return -1; + sha_text(bytes, length, digest); + return checked(path, length, digest); +failed: + close(fd); + unlink(pending); + return -1; +} + +static int prepare_attempt(uint64_t ecid) +{ + unsigned char random[16]; + char nonce[33]; + arc4random_buf(random, sizeof(random)); + for (unsigned int i = 0; i < sizeof(random); i++) + snprintf(nonce + i * 2, 3, "%02x", random[i]); + unsigned char *image = checked_copy(ROOT_BOOT_PATH, ROOT_BOOT_BYTES, ROOT_BOOT_SHA256); + unsigned char *ibec = checked_copy(IBEC_PATH, IBEC_BYTES, IBEC_SHA256); + int result = -1; + uint32_t crc; + if (!image || !ibec || orchard_ios_crc32(image, ROOT_BOOT_BYTES, &crc) || + crc != IOS_TEMPLATE_CRC32 || + orchard_ios_bind_handoff(image, ROOT_BOOT_BYTES, IOS_IDENTITY_SLOT_OFFSET, + IOS_IDENTITY_SLOT_BYTES, TARGET_PROFILE, ecid, TARGET_CHIP, TARGET_BOARD, + nonce, ROOT_BOOT_SHA256) || + orchard_ios_crc32(image, ROOT_BOOT_BYTES, &crc) || + orchard_ios_patch_ibec_crc(ibec, IBEC_BYTES, IOS_IBEC_CRC_OFFSET, + IOS_TEMPLATE_CRC32, crc) || + write_attempt(ATTEMPT_BOOT_PATH, image, ROOT_BOOT_BYTES) || + write_attempt(ATTEMPT_IBEC_PATH, ibec, IBEC_BYTES)) + goto done; + result = 0; +done: + free(image); + free(ibec); + return result; +} +#endif + +static int lease_number(const char *text, size_t length, size_t *at, + uint64_t cutoff, uint64_t last_digit, uint64_t *value) +{ + size_t start = *at; + uint64_t number = 0; + while (*at < length && text[*at] >= '0' && text[*at] <= '9') { + uint64_t digit = (uint64_t)(text[*at] - '0'); + if (number > cutoff || (number == cutoff && digit > last_digit)) + return -1; + number = number * 10 + digit; + (*at)++; + } + if (*at == start) + return -1; + *value = number; + return 0; +} + +static int valid_lease_token(const char *text, size_t length) +{ + char canonical[64]; + size_t at = 3; + uint64_t seconds, microseconds; + if (length < 7 || memcmp(text, "v1:", 3) || + lease_number(text, length, &at, UINT64_C(922337203685477580), 7, &seconds) || + at >= length || text[at++] != ':' || + lease_number(text, length, &at, 99999, 9, µseconds) || + seconds == 0 || at + 1 != length || text[at] != '\n') + return 0; + int count = snprintf(canonical, sizeof(canonical), "v1:%lld:%d\n", + (long long)seconds, (int)microseconds); + return count > 0 && count == (int)length && !memcmp(text, canonical, length); +} + +static int boot_lease(void) +{ + struct timeval boot; + size_t length = sizeof(boot); + char token[64], previous[64] = {0}; + struct stat info; + if (sysctlbyname("kern.boottime", &boot, &length, NULL, 0) || + length != sizeof(boot) || boot.tv_sec <= 0 || boot.tv_usec < 0 || + boot.tv_usec >= 1000000) + return -1; + int count = snprintf(token, sizeof(token), "v1:%lld:%d\n", + (long long)boot.tv_sec, (int)boot.tv_usec); + if (count <= 0 || count >= (int)sizeof(token)) + return -1; + int created = 1; + int fd = open(LEASE_PATH, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW, 0600); + if (fd < 0) { + if (errno != EEXIST) + return -1; + created = 0; + fd = open(LEASE_PATH, O_RDWR | O_NOFOLLOW); + if (fd < 0) + return -1; + } + if (flock(fd, LOCK_EX | LOCK_NB) || fstat(fd, &info) || + !S_ISREG(info.st_mode) || info.st_uid || (info.st_mode & 077) || + info.st_size < 0 || info.st_size >= (off_t)sizeof(previous) || + (!created && !info.st_size)) + goto refused; + if (pread(fd, previous, (size_t)info.st_size, 0) != info.st_size) + goto refused; + if (info.st_size && (!valid_lease_token(previous, (size_t)info.st_size) || + !strcmp(previous, token))) + goto refused; + if (ftruncate(fd, 0) || write(fd, token, (size_t)count) != count || fsync(fd)) + goto refused; + /* Keep the lock in the parent and child. A new iOS boot has a different token even + * when /var/run retains this file, so the on-device path remains usable. + */ + return fd; +refused: + close(fd); + return -1; +} + +struct startup_wake { + void *kit; + CFDateRef date; + CFStringRef owner; + CFStringRef type; + kern_return_t (*schedule)(CFDateRef, CFStringRef, CFStringRef); + kern_return_t (*cancel)(CFDateRef, CFStringRef, CFStringRef); + CFArrayRef (*copy_events)(void); + int armed; +}; + +static void release_wake(struct startup_wake *wake) +{ + if (wake->date) + CFRelease(wake->date); + if (wake->owner) + CFRelease(wake->owner); + if (wake->kit) + dlclose(wake->kit); +} + +static int cancel_wake(struct startup_wake *wake) +{ + int failed = wake->armed && wake->cancel(wake->date, wake->owner, wake->type); + wake->armed = 0; + return failed ? 16 : 0; +} + +static int queue_contains_wake(CFArrayRef events, const struct startup_wake *wake) +{ + if (!events || CFGetTypeID(events) != CFArrayGetTypeID()) + return 0; + CFIndex count = CFArrayGetCount(events); + if (count < 0 || count > 4096) + return 0; + for (CFIndex i = 0; i < count; i++) { + CFTypeRef event = CFArrayGetValueAtIndex(events, i); + if (!event || CFGetTypeID(event) != CFDictionaryGetTypeID()) + continue; + CFTypeRef date = CFDictionaryGetValue(event, CFSTR("time")); + CFTypeRef owner = CFDictionaryGetValue(event, CFSTR("scheduledby")); + CFTypeRef type = CFDictionaryGetValue(event, CFSTR("eventtype")); + if (date && owner && type && CFEqual(date, wake->date) && + CFEqual(owner, wake->owner) && CFEqual(type, wake->type)) + return 1; + } + return 0; +} + +static int open_wake_api(struct startup_wake *wake) +{ + wake->kit = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW | RTLD_LOCAL); + if (!wake->kit) + return 14; + wake->schedule = + (kern_return_t (*)(CFDateRef, CFStringRef, CFStringRef))dlsym(wake->kit, "IOPMSchedulePowerEvent"); + wake->cancel = (kern_return_t (*)(CFDateRef, CFStringRef, CFStringRef)) + dlsym(wake->kit, "IOPMCancelScheduledPowerEvent"); + wake->copy_events = (CFArrayRef (*)(void))dlsym(wake->kit, "IOPMCopyScheduledPowerEvents"); + return wake->schedule && wake->cancel && wake->copy_events ? 0 : 14; +} + +static int prepare_wake(struct startup_wake *wake) +{ + if (open_wake_api(wake)) + return 14; + CFAbsoluteTime now = CFAbsoluteTimeGetCurrent(); + if (!(now > 0 && now < 1e12)) + return 14; + char owner[96]; + int length = snprintf(owner, sizeof(owner), WAKE_OWNER ".%ld", (long)getpid()); + if (length <= 0 || length >= (int)sizeof(owner)) + return 14; + wake->date = CFDateCreate(NULL, floor(now) + WAKE_DELAY_SECONDS); + wake->owner = CFStringCreateWithCString(NULL, owner, kCFStringEncodingUTF8); + wake->type = CFSTR("wake"); + if (!wake->date || !wake->owner) + return 14; + /* A failed schedule is treated as uncertain until this exact tuple is + * cancelled. Existing events owned by iOS remain outside our operation. + */ + wake->armed = 1; + if (wake->schedule(wake->date, wake->owner, wake->type)) + return 14; + CFArrayRef events = wake->copy_events(); + int present = queue_contains_wake(events, wake); + if (events) + CFRelease(events); + return present ? 0 : 14; +} + +static int start_with_wake(int lease) +{ + struct startup_wake wake = {0}; + int result = prepare_wake(&wake); + if (!result) { + char *const arguments[] = {KLOADER_PATH, +#ifdef IOS_IDENTITY_SLOT_OFFSET + ATTEMPT_IBEC_PATH, NULL}; +#else + IBEC_PATH, NULL}; +#endif + char *const environment[] = {"PATH=/usr/bin:/bin:/usr/sbin:/sbin", "HOME=/var/root", NULL}; + pid_t child; + int spawned = posix_spawn(&child, arguments[0], NULL, NULL, arguments, environment); + result = 15; + if (!spawned) { + int status; + pid_t waited; + do { + waited = waitpid(child, &status, 0); + } while (waited < 0 && errno == EINTR); + if (waited == child) { + if (WIFEXITED(status) && WEXITSTATUS(status)) + result = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) + result = 128 + WTERMSIG(status); + } + } + } + /* A successful kernel handoff never returns to this process. Any + * return, including a child error, cancels only our scheduled event. + */ + int cancelled = cancel_wake(&wake); + release_wake(&wake); + close(lease); + return cancelled ? cancelled : result; +} + +int main(int argc, char **argv) +{ + char model[32] = {0}, build[32] = {0}; + size_t length = sizeof(model); + mach_port_t kernel = MACH_PORT_NULL; + const int inspect = argc == 2 && !strcmp(argv[1], "--check-installed"); + if ((argc != 1 && !inspect) || geteuid() || + sysctlbyname("hw.machine", model, &length, NULL, 0) || + !length || length > sizeof(model) || model[length - 1] || + strcmp(model, TARGET_MACHINE)) + return 10; + length = sizeof(build); + if (sysctlbyname("kern.osversion", build, &length, NULL, 0) || + !length || length > sizeof(build) || build[length - 1] || + strcmp(build, TARGET_IOS_BUILD)) + return 10; + if (setgid(0) || setuid(0) || + task_for_pid(mach_task_self(), 0, &kernel) != KERN_SUCCESS) + return 11; + mach_port_deallocate(mach_task_self(), kernel); + if (checked(ROOT_BOOT_PATH, ROOT_BOOT_BYTES, ROOT_BOOT_SHA256) || + checked(IBEC_PATH, IBEC_BYTES, IBEC_SHA256) || + checked(KLOADER_PATH, KLOADER_BYTES, KLOADER_SHA256)) + return 12; +#ifdef IOS_IDENTITY_SLOT_OFFSET + uint64_t ecid; + if (orchard_ios_live_identity(&ecid)) + return 16; +#endif + if (inspect) { + struct startup_wake wake = {0}; + int available = open_wake_api(&wake); + release_wake(&wake); + if (available) + return available; + puts("Orchard installed boot files verified"); + return 0; + } + int once = boot_lease(); + if (once < 0) + return 13; +#ifdef IOS_IDENTITY_SLOT_OFFSET + if (prepare_attempt(ecid)) { + close(once); + return 16; + } +#endif + return start_with_wake(once); +} diff --git a/boot/ios-launcher/kloader/.gitattributes b/boot/ios-launcher/kloader/.gitattributes new file mode 100644 index 00000000..1780efcb --- /dev/null +++ b/boot/ios-launcher/kloader/.gitattributes @@ -0,0 +1,3 @@ +# Preserve the exact upstream input and unified-patch context bytes. +kloader.c whitespace=-blank-at-eol +hardening.patch whitespace=-blank-at-eol diff --git a/boot/ios-launcher/kloader/README.md b/boot/ios-launcher/kloader/README.md new file mode 100644 index 00000000..b2dc40d9 --- /dev/null +++ b/boot/ios-launcher/kloader/README.md @@ -0,0 +1,53 @@ +# ARMv7 kloader source + +`kloader.c` and `tfp0.plist` are unchanged files from +[axi0mX/ios-kexec-utils at a1bbca8a8f2847d9c53070e76e0b5d093bda5993](https://github.com/axi0mX/ios-kexec-utils/tree/a1bbca8a8f2847d9c53070e76e0b5d093bda5993). +The source retains winocm's copyright and GPL-2.0-or-later license notice; +Orchard's root `LICENSE` contains the license text. `source.json` records +the complete source inventory and its SHA-256 hashes. The imported source +contains trailing spaces, also present in the exact-context patch. A scoped +`.gitattributes` rule preserves those bytes while normal whitespace checks +continue to cover the producer and tests. + +This revision calls `IOPMSleepSystem` after staging the image and changing +the wakeup trampoline. The iOS launcher owns the scheduled wake event. Later +ios-kexec-utils revisions use a different sleep-notification/autowake path. + +Build on a Mac with Xcode: + +```sh +python3 boot/ios-launcher/kloader/build.py --output /tmp/orchard-kloader +``` + +The builder applies `hardening.patch` to a private output copy with exact +context and no fuzz. It verifies the patch and resulting source hashes. +The patch bounds the kernel address scan and refuses incomplete reads. +It validates the image before changing the kernel map and removes the +fallback for unrecognized chips. One sleep request keeps its caller alive +for at most 90 seconds while the scheduled wake can transfer control. An +error returns to the launcher without repeating that request or rebooting. +Host tests compile these actual patched I/O helpers against fake Mach and +power APIs under AddressSanitizer and UndefinedBehaviorSanitizer. + +The builder uses the current SDK's ARMv7 compiler and describes the existing +iOS 9 imports with generated TAPI files. It supplies missing declarations in +a generated header. The upstream C path uses three storage words also +defined by its unused inline shellcode; the builder defines those words in +a small C object because current assemblers reject the unused shellcode's +relocations. The upstream source remains byte-identical to its pin. + +The result is a thin ARMv7 executable, signed ad hoc with SHA-1 and SHA-256 +and the pinned tfp0 entitlements. `--unsigned` omits signing. The receipt +records the compiler and output hash, with separate identities for the +upstream source, hardening patch and derived source. It supplies +`source_commit`, `source_sha256`, `hardening_patch_sha256`, +`derived_source_sha256`, `artifact_sha256`, `artifact_bytes`, `signed` and +`sleep_wait_seconds`. `on_device_qualified` remains false for each host +build. This command performs no device operation and never executes the +result. + +The installed P105 binary has the same imports and diagnostic strings as +this older source, including its lack of scheduled-wake imports. Its exact +build provenance is unknown. A successful host build establishes the new +executable's structure and deterministic output. The revised handoff needs +its own on-device qualification before installation. diff --git a/boot/ios-launcher/kloader/build.py b/boot/ios-launcher/kloader/build.py new file mode 100644 index 00000000..5833f393 --- /dev/null +++ b/boot/ios-launcher/kloader/build.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-or-later +"""Build the pinned ARMv7 kloader as an ordinary host-side output. + +The result needs a jailbreak that provides tfp0. This command never runs the +result, contacts a device, or modifies an existing output directory. +""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import struct +import subprocess + + +ROOT = Path(__file__).resolve().parent +SOURCE_COMMIT = "a1bbca8a8f2847d9c53070e76e0b5d093bda5993" +SOURCE_SHA256 = "c64f3252118d65b0ab52f10c8eaadc1dd1d7a989352a6e497075f15fb174296a" +HARDENING_PATCH_SHA256 = "efd2b63a85a448701a33c80f2e42323f8e4560d685c5342553f36225595d4a87" +DERIVED_SOURCE_SHA256 = "ddcea7cd58afb437cbe7ac65d09e14f6f7ba98b04814d5e088bf4404c23360fa" +SLEEP_WAIT_SECONDS = 90 +SYSTEM_SYMBOLS = ( + "__stack_chk_fail", "__stack_chk_guard", "bcopy", "exit", "fclose", + "fopen", "fread", "free", "mach_port_deallocate", "mach_task_self_", "malloc", + "memmem", "memset", "printf", "signal", "sleep", "stat", + "strcasestr", "sync", "sysctlbyname", "task_for_pid", "vm_read", + "vm_region_recurse_64", "vm_write", "vm_deallocate", +) +POWER_SYMBOLS = ("IOPMFindPowerManagement", "IOPMSleepSystem") + + +def run(*arguments): + result = subprocess.run(arguments, capture_output=True, text=True, timeout=120) + if result.returncode: + raise RuntimeError(result.stdout + result.stderr) + return result.stdout + + +def inputs(root=ROOT): + pin = json.loads((root / "source.json").read_text()) + if (pin.get("schema") != 1 or pin.get("license") != "GPL-2.0-or-later" + or pin.get("repository") != "https://github.com/axi0mX/ios-kexec-utils" + or pin.get("commit") != SOURCE_COMMIT + or pin.get("files", {}).get("kloader.c") != SOURCE_SHA256): + raise ValueError("Unsupported kloader source description") + if set(pin["files"]) != {"kloader.c", "tfp0.plist"}: + raise ValueError("Unexpected kloader source inventory") + for name, expected in pin["files"].items(): + actual = hashlib.sha256((root / name).read_bytes()).hexdigest() + if actual != expected: + raise ValueError("Pinned kloader source differs: " + name) + return pin + + +def patched_source(root=ROOT): + """Apply the reviewed unified patch with exact context and no fuzz.""" + inputs(root) + patch = (root / "hardening.patch").read_bytes() + if hashlib.sha256(patch).hexdigest() != HARDENING_PATCH_SHA256: + raise ValueError("kloader hardening patch differs from its pin") + lines = patch.decode().splitlines(keepends=True) + if lines[:2] != ["--- a/kloader.c\n", "+++ b/kloader.c\n"]: + raise ValueError("Unexpected kloader patch paths") + source = (root / "kloader.c").read_text().splitlines(keepends=True) + result = [] + cursor = 0 + index = 2 + while index < len(lines): + hunk = re.fullmatch(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@\n", lines[index]) + if not hunk: + raise ValueError("Invalid kloader patch hunk") + start = int(hunk[1]) - 1 + old_count = int(hunk[2] or 1) + new_count = int(hunk[4] or 1) + if start < cursor or start > len(source): + raise ValueError("Overlapping kloader patch hunk") + result.extend(source[cursor:start]) + cursor = start + read = written = 0 + index += 1 + while index < len(lines) and not lines[index].startswith("@@ "): + line = lines[index] + if not line or line[0] not in " +-": + raise ValueError("Unsupported kloader patch record") + if line[0] in " -": + if cursor >= len(source) or source[cursor] != line[1:]: + raise ValueError("kloader patch context differs") + cursor += 1 + read += 1 + if line[0] in " +": + result.append(line[1:]) + written += 1 + index += 1 + if (read, written) != (old_count, new_count): + raise ValueError("kloader patch counts differ") + result.extend(source[cursor:]) + text = "".join(result) + if hashlib.sha256(text.encode()).hexdigest() != DERIVED_SOURCE_SHA256: + raise ValueError("Derived kloader source differs from its pin") + return text + + +def library(folder, name, install_name, symbols): + """Describe imports available on iOS 9 to the modern ARM64-only SDK.""" + path = folder / (name + ".tbd") + path.write_text( + "--- !tapi-tbd\ntbd-version: 4\ntargets: [ armv7-ios ]\n" + + "install-name: " + json.dumps(install_name) + "\n" + + "exports:\n - targets: [ armv7-ios ]\n symbols: " + + json.dumps(["_" + symbol for symbol in symbols]) + "\n...\n" + ) + return path + + +def image(path): + raw = path.read_bytes() + if len(raw) < 28: + raise ValueError("Incomplete kloader Mach-O") + magic, cpu, subtype, kind, count, size, _ = struct.unpack_from("<7I", raw) + if (magic, cpu, subtype & 0xFFFFFF, kind) != (0xFEEDFACE, 12, 9, 2): + raise ValueError("Expected a thin ARMv7 kloader executable") + end = 28 + size + if end > len(raw) or count > size // 8: + raise ValueError("Invalid kloader load-command table") + cursor = 28 + main = None + text = None + for _ in range(count): + if cursor + 8 > end: + raise ValueError("Incomplete kloader load command") + command, length = struct.unpack_from(" end: + raise ValueError("Invalid kloader load command") + if command == 1 and length >= 56: + name = raw[cursor + 8:cursor + 24].rstrip(b"\0") + if name == b"__TEXT": + if text is not None: + raise ValueError("Repeated kloader text segment") + text = struct.unpack_from("<4I", raw, cursor + 24) + if command == 0x80000028: + if length != 24 or main is not None: + raise ValueError("Unexpected kloader LC_MAIN") + main = struct.unpack_from(" len(raw) or main % 4 or not 28 <= main < length: + raise ValueError("kloader entry must address ARM code in its text segment") + return {"architecture": "armv7", "entry_file_offset": main, "bytes": len(raw), + "sha256": hashlib.sha256(raw).hexdigest()} + + +def build(output, *, sign=True): + pin = inputs() + derived = patched_source() + output = Path(output) + if output.is_symlink(): + raise ValueError("kloader output must not be a symlink") + output = output.resolve() + output.mkdir(mode=0o700, parents=True, exist_ok=False) + # Modern SDK headers omit declarations used by this older source. The + # omitted inline shellcode is in an upstream #if 0 branch. Its three + # shared storage words still need definitions without that assembler. + header = output / "compat.h" + header.write_text("#include \n#include \nextern int reboot(int);\n") + globals_file = output / "globals.c" + globals_file.write_text( + "#include \n" + "uint32_t larm_init_tramp = 0xdeadbeef;\n" + "uint32_t kern_base = 0xdeadbeef;\n" + "uint32_t kern_tramp_phys = 0x9bf00000;\n" + ) + compiled_source = output / "kloader.c" + compiled_source.write_text(derived) + compiler = ["xcrun", "--sdk", "iphoneos", "clang", "-arch", "armv7", + "-miphoneos-version-min=9.0", "-marm", "-Os", "-std=gnu11", + "-fno-builtin", "-D_FORTIFY_SOURCE=0", "-DINLINE_IT_ALL=0"] + run(*compiler, "-include", str(header), "-c", str(compiled_source), + "-o", str(output / "kloader.o")) + run(*compiler, "-c", str(globals_file), "-o", str(output / "globals.o")) + system = library(output, "libSystem", "/usr/lib/libSystem.B.dylib", SYSTEM_SYMBOLS) + power = library(output, "IOKit", "/System/Library/Frameworks/IOKit.framework/IOKit", + POWER_SYMBOLS) + target = output / "kloader" + run(*compiler, "-nostdlib", str(output / "kloader.o"), str(output / "globals.o"), + str(system), str(power), "-Wl,-bind_at_load", "-Wl,-no_uuid", "-o", str(target)) + if sign: + run("/usr/bin/codesign", "--force", "--sign", "-", "--timestamp=none", + "--digest-algorithm=sha1,sha256", "--entitlements", str(ROOT / "tfp0.plist"), + str(target)) + run("/usr/bin/codesign", "--verify", "--strict", str(target)) + target.chmod(0o755) + imported = set(run("xcrun", "nm", "-u", str(target)).splitlines()) + expected = {"_" + name for name in (*SYSTEM_SYMBOLS, *POWER_SYMBOLS)} + if imported - {"dyld_stub_binder"} != expected: + raise ValueError("kloader imports differ from the pinned ARMv7 path") + artifact = image(target) + report = {"schema": 1, "device_operations": 0, "source": pin, + "kind": "ios-kloader-build", "source_commit": SOURCE_COMMIT, + "source_sha256": SOURCE_SHA256, + "hardening_patch_sha256": HARDENING_PATCH_SHA256, + "derived_source_sha256": DERIVED_SOURCE_SHA256, + "sleep_wait_seconds": SLEEP_WAIT_SECONDS, + "artifact_sha256": artifact["sha256"], "artifact_bytes": artifact["bytes"], + "signed": sign, "on_device_qualified": False, + "compiler": run("xcrun", "--sdk", "iphoneos", "clang", "--version").splitlines()[0], + "minimum_ios": "9.0", "signing": "ad-hoc-sha1-sha256" if sign else "unsigned", + "output": artifact, "on_device_startup_qualified": False, + "installed_binary_equivalence": "unverified"} + (output / "result.json").write_text(json.dumps(report, indent=2) + "\n") + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--unsigned", action="store_true") + args = parser.parse_args() + print(json.dumps(build(args.output, sign=not args.unsigned), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/boot/ios-launcher/kloader/hardening.patch b/boot/ios-launcher/kloader/hardening.patch new file mode 100644 index 00000000..76e13297 --- /dev/null +++ b/boot/ios-launcher/kloader/hardening.patch @@ -0,0 +1,461 @@ +--- a/kloader.c ++++ b/kloader.c +@@ -585,7 +585,7 @@ + // Given an instruction, search backwards until an instruction is found matching the specified criterion. + static uint16_t *find_last_insn_matching(uint32_t region, uint8_t * kdata, size_t ksize, uint16_t * current_instruction, int (*match_func) (uint16_t *)) + { +- while ((uintptr_t) current_instruction > (uintptr_t) kdata) { ++ while ((uintptr_t) current_instruction >= (uintptr_t) kdata + 6) { + if (insn_is_32bit(current_instruction - 2) && !insn_is_32bit(current_instruction - 3)) { + current_instruction -= 2; + } else { +@@ -606,7 +606,7 @@ + // Find the last instruction that completely wiped out this register + int found = 0; + uint16_t *current_instruction = insn; +- while ((uintptr_t) current_instruction > (uintptr_t) kdata) { ++ while ((uintptr_t) current_instruction >= (uintptr_t) kdata + 6) { + if (insn_is_32bit(current_instruction - 2)) { + current_instruction -= 2; + } else { +@@ -629,11 +629,17 @@ + + // Step through instructions, executing them as a virtual machine, only caring about instructions that affect the target register and are commonly used for PC-relative addressing. + uint32_t value = 0; +- while ((uintptr_t) current_instruction < (uintptr_t) insn) { ++ while ((uintptr_t) current_instruction < (uintptr_t) insn && ++ (uintptr_t) current_instruction + 4 <= (uintptr_t) (kdata + ksize)) { + if (insn_is_mov_imm(current_instruction) && insn_mov_imm_rd(current_instruction) == reg) { + value = insn_mov_imm_imm(current_instruction); + } else if (insn_is_ldr_literal(current_instruction) && insn_ldr_literal_rt(current_instruction) == reg) { +- value = *(uint32_t *) (kdata + (((((uintptr_t) current_instruction - (uintptr_t) kdata) + 4) & 0xFFFFFFFC) + insn_ldr_literal_imm(current_instruction))); ++ uintptr_t literal = (uintptr_t)kdata + ++ (((((uintptr_t)current_instruction - (uintptr_t)kdata) + 4) & 0xFFFFFFFC) + ++ insn_ldr_literal_imm(current_instruction)); ++ if (literal < (uintptr_t)kdata || literal + 4 > (uintptr_t)kdata + ksize) ++ return 0; ++ value = *(const uint32_t *)literal; + } else if (insn_is_movt(current_instruction) && insn_movt_rd(current_instruction) == reg) { + value |= insn_movt_imm(current_instruction) << 16; + } else if (insn_is_add_reg(current_instruction) && insn_add_reg_rd(current_instruction) == reg) { +@@ -658,7 +664,7 @@ + uint32_t value[16]; + memset(value, 0, sizeof(value)); + +- while ((uintptr_t) current_instruction < (uintptr_t) (kdata + ksize)) { ++ while ((uintptr_t) current_instruction + 4 <= (uintptr_t) (kdata + ksize)) { + if (insn_is_mov_imm(current_instruction)) { + value[insn_mov_imm_rd(current_instruction)] = insn_mov_imm_imm(current_instruction); + } else if (insn_is_ldr_literal(current_instruction)) { +@@ -718,7 +724,7 @@ + // Find the last LDR R2, [R*] before it that's before any branches. If there are branches, then we have a version of the function that assumes kernel_pmap instead of being passed it. + uint16_t *ldr_r2 = NULL; + uint16_t *current_instruction = bl; +- while ((uintptr_t) current_instruction > (uintptr_t) kdata) { ++ while ((uintptr_t) current_instruction >= (uintptr_t) kdata + 6) { + if (insn_is_32bit(current_instruction - 2) && !insn_is_32bit(current_instruction - 3)) { + current_instruction -= 2; + } else { +@@ -740,7 +746,7 @@ + // The function has no third argument, Follow the BL. + uint32_t imm32 = insn_bl_imm32(bl); + uint32_t target = ((uintptr_t) bl - (uintptr_t) kdata) + 4 + imm32; +- if (target > ksize) ++ if (target > ksize || ksize - target < 4) + return 0; + + // Find the first PC-relative reference in this function. +@@ -748,7 +754,7 @@ + + int rd; + current_instruction = (uint16_t *) (kdata + target); +- while ((uintptr_t) current_instruction < (uintptr_t) (kdata + ksize)) { ++ while ((uintptr_t) current_instruction + 4 <= (uintptr_t) (kdata + ksize)) { + if (insn_is_add_reg(current_instruction) && insn_add_reg_rm(current_instruction) == 15) { + found = 1; + rd = insn_add_reg_rd(current_instruction); +@@ -804,46 +810,111 @@ + + uint32_t phys_addr_remap = 0x5fe00000; + ++/* Orchard: bounded I/O and one sleep request. */ ++#define ORCHARD_MAX_KERNEL_REGIONS 65536u ++#define ORCHARD_HANDOFF_WAIT_SECONDS 90u ++#define ORCHARD_KERNEL_DUMP_BYTES 0xd00000u ++#define ORCHARD_KERNEL_HEADER_BYTES 0x1000u ++#define ORCHARD_KERNEL_READ_BYTES 2048u ++ ++static kern_return_t orchard_read_exact(task_t task, vm_address_t address, ++ vm_size_t length, pointer_t *buffer, ++ mach_msg_type_number_t *size) ++{ ++ *buffer = 0; ++ *size = 0; ++ kern_return_t result = vm_read(task, address, length, buffer, size); ++ if (result != KERN_SUCCESS || !*buffer || *size != length) { ++ if (*buffer && *size) ++ vm_deallocate(mach_task_self(), *buffer, *size); ++ *buffer = 0; ++ *size = 0; ++ return KERN_FAILURE; ++ } ++ return KERN_SUCCESS; ++} ++ ++static kern_return_t orchard_dump_kernel(task_t task, vm_address_t base, ++ uint8_t *destination) ++{ ++ if (base > UINT32_MAX - ORCHARD_KERNEL_DUMP_BYTES) ++ return KERN_FAILURE; ++ for (uint32_t offset = ORCHARD_KERNEL_HEADER_BYTES; ++ offset < ORCHARD_KERNEL_DUMP_BYTES; offset += ORCHARD_KERNEL_READ_BYTES) { ++ pointer_t buffer = 0; ++ mach_msg_type_number_t size = 0; ++ if (orchard_read_exact(task, base + offset, ORCHARD_KERNEL_READ_BYTES, ++ &buffer, &size) != KERN_SUCCESS) ++ return KERN_FAILURE; ++ bcopy((const void *)buffer, ++ destination + offset - ORCHARD_KERNEL_HEADER_BYTES, size); ++ vm_deallocate(mach_task_self(), buffer, size); ++ } ++ return KERN_SUCCESS; ++} ++ ++static kern_return_t orchard_sleep_once(void) ++{ ++ mach_port_t port = IOPMFindPowerManagement(MACH_PORT_NULL); ++ if (port == MACH_PORT_NULL) ++ return KERN_FAILURE; ++ kern_return_t result = IOPMSleepSystem(port); ++ mach_port_deallocate(mach_task_self(), port); ++ return result; ++} ++static kern_return_t orchard_handoff_once(void) ++{ ++ kern_return_t result = orchard_sleep_once(); ++ if (result != KERN_SUCCESS) ++ return result; ++ /* Keep the parent's owned wake event alive while iOS enters sleep. */ ++ sleep(ORCHARD_HANDOFF_WAIT_SECONDS); ++ return KERN_FAILURE; ++} ++/* End Orchard bounded I/O. */ ++ + vm_address_t get_kernel_base() + { +- kern_return_t ret; +- task_t kernel_task; ++ task_t task; + vm_region_submap_info_data_64_t info; +- vm_size_t size; +- mach_msg_type_number_t info_count = VM_REGION_SUBMAP_INFO_COUNT_64; + unsigned int depth = 0; +- vm_address_t addr = 0x81200000; /* arm64: addr = 0xffffff8000000000 */ +- +- ret = task_for_pid(mach_task_self(), 0, &kernel_task); +- if (ret != KERN_SUCCESS) +- return -1; +- +- while (1) { +- ret = vm_region_recurse_64(kernel_task, &addr, &size, &depth, (vm_region_info_t) & info, &info_count); +- if (ret != KERN_SUCCESS) ++ vm_address_t address = 0x81200000; ++ if (task_for_pid(mach_task_self(), 0, &task) != KERN_SUCCESS) ++ return (vm_address_t)-1; ++ for (unsigned int region = 0; region < ORCHARD_MAX_KERNEL_REGIONS; region++) { ++ vm_size_t size = 0; ++ mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64; ++ vm_address_t previous = address; ++ if (vm_region_recurse_64(task, &address, &size, &depth, ++ (vm_region_info_t)&info, &count) != KERN_SUCCESS) + break; +- if (size > 1024 * 1024 * 1024) { +- /* +- * https://code.google.com/p/iphone-dataprotection/ +- * hax, sometimes on iOS7 kernel starts at +0x200000 in the 1Gb region +- */ +- pointer_t buf; +- mach_msg_type_number_t sz = 0; +- addr += 0x200000; +- vm_read(kernel_task, addr + 0x1000, 512, &buf, &sz); +- if (*((uint32_t *)buf) != 0xfeedface) { +- addr -= 0x200000; +- vm_read(kernel_task, addr + 0x1000, 512, &buf, &sz); +- if (*((uint32_t*)buf) != 0xfeedface) { ++ if (!size || size > UINT32_MAX || address < previous || address > UINT32_MAX - size) ++ break; ++ if (size > 1024u * 1024u * 1024u) { ++ /* Preserve upstream's two candidate offsets, with checked reads. */ ++ const uint32_t offsets[] = { 0x200000, 0 }; ++ for (unsigned int i = 0; i < sizeof(offsets)/sizeof(offsets[0]); i++) { ++ pointer_t buffer = 0; ++ mach_msg_type_number_t bytes = 0; ++ vm_address_t candidate = address + offsets[i]; ++ if (candidate > UINT32_MAX - ORCHARD_KERNEL_DUMP_BYTES) ++ continue; ++ if (orchard_read_exact(task, candidate + 0x1000, 512, ++ &buffer, &bytes) != KERN_SUCCESS) + break; ++ uint32_t magic = *(const uint32_t *)buffer; ++ vm_deallocate(mach_task_self(), buffer, bytes); ++ if (magic == 0xfeedface) { ++ mach_port_deallocate(mach_task_self(), task); ++ return candidate; + } + } +- return addr; ++ break; + } +- addr += size; +- } +- +- return -1; ++ address += size; ++ } ++ mach_port_deallocate(mach_task_self(), task); ++ return (vm_address_t)-1; + } + + static void generate_ttb_entries(void) +@@ -884,7 +955,6 @@ + + int main(int argc, char *argv[]) + { +- uint32_t chunksize = 2048; + struct stat st; + + if (argc != 2) { +@@ -892,8 +962,25 @@ + return -1; + } + +- if (stat(argv[1], &st) == -1) { +- printf("Failed to open %s.\n", argv[1]); ++ if (stat(argv[1], &st) == -1 || !S_ISREG(st.st_mode) || ++ st.st_size < 0x2c0 || st.st_size > SHADOWMAP_GRANULARITY) { ++ printf("Boot image must fit the single mapped L1 section.\n"); ++ return -1; ++ } ++ FILE *f = fopen(argv[1], "rb"); ++ if (!f) ++ return -1; ++ int length = (int)st.st_size; ++ void *vp = malloc((size_t)length); ++ if (!vp) { ++ fclose(f); ++ return -1; ++ } ++ size_t received = fread(vp, 1, (size_t)length, f); ++ int closed = fclose(f); ++ if (received != (size_t)length || closed || *(uint32_t *)vp != 0xea00000e) { ++ printf("Boot image read or ARM header verification failed.\n"); ++ free(vp); + return -1; + } + +@@ -930,12 +1017,10 @@ + PHYS_OFF = S5L8940_PHYS_OFF; + phys_addr_remap = 0xbfe00000; + } else { +- printf("Bravely assuming you're on an 8940-class device (unrecognized). You are on your own.\n"); +- /* +- * All others have the high ram base. +- */ +- PHYS_OFF = S5L8940_PHYS_OFF; +- phys_addr_remap = 0x9fe00000; ++ printf("Unsupported chip; no kernel state changed.\n"); ++ free(osversion); ++ free(vp); ++ return -1; + } + + /* +@@ -970,35 +1055,43 @@ + /* + * kill + */ +- vm_address_t addr = kernel_base + 0x1000, e = 0, sz = 0; ++ mach_msg_type_number_t sz = 0; + uint8_t *p = malloc(DMPSIZE + 0x1000); +- pointer_t buf; +- +- if (!p) { +- printf("failed to malloc memory for kernel dump...\n"); +- return -1; +- } +- while (addr < (kernel_base + DMPSIZE)) { +- vm_read(kernel_task, addr, chunksize, &buf, &sz); +- if (!buf || sz == 0) +- continue; +- uint8_t *z = (uint8_t *) buf; +- addr += chunksize; +- bcopy(z, p + e, chunksize); +- e += chunksize; ++ pointer_t buf = 0; ++ if (!p) ++ return -1; ++ memset(p, 0, DMPSIZE + 0x1000); ++ if (orchard_dump_kernel(kernel_task, kernel_base, p) != KERN_SUCCESS) { ++ printf("Kernel dump failed; no handoff was attempted.\n"); ++ free(p); ++ free(vp); ++ return -1; + } + + /* + * kernel dumped, now find pmap. + */ +- uint32_t kernel_pmap = kernel_base + 0x1000 + find_pmap_location(kernel_base, (uint8_t *) p, DMPSIZE); ++ uint32_t pmap_offset = find_pmap_location(kernel_base, p, DMPSIZE); ++ uint32_t tramp_offset = find_larm_init_tramp(kernel_base, p, DMPSIZE); ++ if (!pmap_offset || pmap_offset >= DMPSIZE || ++ !tramp_offset || tramp_offset > DMPSIZE - 8) { ++ printf("Kernel map or wake trampoline was not found.\n"); ++ free(p); ++ free(vp); ++ return -1; ++ } ++ uint32_t kernel_pmap = kernel_base + 0x1000 + pmap_offset; + printf("kernel pmap is at 0x%08x.\n", kernel_pmap); + + /* + * Read for kernel_pmap, dereference it for pmap_store. + */ +- vm_read(kernel_task, kernel_pmap, 2048, &buf, &sz); +- vm_read(kernel_task, *(vm_address_t *) (buf), 2048, &buf, &sz); ++ if (orchard_read_exact(kernel_task, kernel_pmap, 2048, &buf, &sz) != KERN_SUCCESS) ++ return -1; ++ vm_address_t pmap_store = *(const vm_address_t *)buf; ++ vm_deallocate(mach_task_self(), buf, sz); ++ if (orchard_read_exact(kernel_task, pmap_store, 2048, &buf, &sz) != KERN_SUCCESS) ++ return -1; + + /* + * We now have the struct. Let's copy it out to get the TTE base (we don't really need to do this +@@ -1007,6 +1100,7 @@ + pmap_partial_t *part = (pmap_partial_t *) buf; + uint32_t tte_virt = part->tte_virt; + uint32_t tte_phys = part->tte_phys; ++ vm_deallocate(mach_task_self(), buf, sz); + + printf("kernel pmap details: tte_virt: 0x%08x tte_phys: 0x%08x\n", tte_virt, tte_phys); + if (PHYS_OFF != (tte_phys & ~0xFFFFFFF)) { +@@ -1023,9 +1117,16 @@ + * Now, we can start reading at the TTE base and start writing in the descriptors. + */ + uint32_t tte_off = SHADOWMAP_BEGIN_OFF; +- vm_read(kernel_task, tte_virt + tte_off, 2048, &buf, &sz); +- bcopy((char *) ttb_template_ptr + tte_off, (void *) buf, SHADOWMAP_SIZE); +- vm_write(kernel_task, tte_virt + tte_off, buf, sz); ++ if (tte_virt > UINT32_MAX - tte_off - 2048 || ++ orchard_read_exact(kernel_task, tte_virt + tte_off, 2048, &buf, &sz) != KERN_SUCCESS) ++ return -1; ++ bcopy((char *) ttb_template_ptr + tte_off, (void *)buf, SHADOWMAP_SIZE); ++ kern_return_t write_result = vm_write(kernel_task, tte_virt + tte_off, buf, sz); ++ vm_deallocate(mach_task_self(), buf, sz); ++ if (write_result != KERN_SUCCESS) { ++ printf("Kernel map write failed; restart iOS before another attempt.\n"); ++ return -1; ++ } + + printf("======================================================================================\n"); + printf("!!!! Kernel TTE entries written. System stability is no longer guaranteed.\n"); +@@ -1038,59 +1139,20 @@ + /* + * remap_address = 0x7ff00000 + */ +- FILE *f = fopen(argv[1], "rb"); +- if (!f) { +- printf("Failed to open iBEC. Rebooting momentarily...\n"); +- sleep(3); +- reboot(0); +- } +- +- fseek(f, 0, SEEK_END); +- int length = ftell(f); +- fseek(f, 0, SEEK_SET); +- void *vp = malloc(length); +- fread(vp, length, 1, f); +- fclose(f); +- printf("Read bootloader into buffer %p, length %d\n", vp, length); +- +- bcopy((void *) vp, (void *) 0x7fe00000, length); +- +- /* +- * Verify ARM header. +- */ +- if (*(uint32_t *) 0x7fe00000 != 0xea00000e) { +- printf("This doesn't seem like an ARM image, perhaps it failed to copy? Continuing though.\n"); +- } +- +- printf("Image information: %s\n", (char *) 0x7fe00000 + 0x200); +- printf("Image information: %s\n", (char *) 0x7fe00000 + 0x240); +- printf("Image information: %s\n", (char *) 0x7fe00000 + 0x280); +- ++ bcopy(vp, (void *)0x7fe00000, (size_t)length); ++ if (*(uint32_t *)0x7fe00000 != 0xea00000e) { ++ printf("Copied ARM header differs; restart iOS before another attempt.\n"); ++ return -1; ++ } ++ printf("Image information: %.*s\n", 64, (char *)0x7fe00000 + 0x200); ++ printf("Image information: %.*s\n", 64, (char *)0x7fe00000 + 0x240); ++ printf("Image information: %.*s\n", 64, (char *)0x7fe00000 + 0x280); + free(vp); + + /* + * iBEC copied, we need to copy over the shellcode now. + */ +- uint32_t sysent_common = 0x1000 + find_syscall0(kernel_base + 0x1000, (uint8_t *) p, DMPSIZE) + SHADOWMAP_BEGIN; +- printf("sysent_common_base: 0x%08x\n", sysent_common); +- +- /* +- * fuck evasi0n7 +- */ +- if (*(uint32_t *) (sysent_common) == 0) { +- printf("iOS 7 detected, adjusting base to 0x%08x = 0x%08x\n", sysent_common, *(uint32_t *) (sysent_common)); +- sysent_common += 4; +- if (*(uint32_t *) (sysent_common) == 0) { +- printf("Something is severely wrong (blaming iOS 7 anyhow). Rebooting momentarily.\n"); +- sleep(3); +- reboot(0); +- } +- } +- +- /* +- * Set offsets. +- */ +- larm_init_tramp = 0x1000 + find_larm_init_tramp(kernel_base + 0x1000, (uint8_t *) p, DMPSIZE) + SHADOWMAP_BEGIN; ++ larm_init_tramp = 0x1000 + tramp_offset + SHADOWMAP_BEGIN; + + kern_base = kernel_base; + kern_tramp_phys = phys_addr_remap; +@@ -1124,21 +1186,14 @@ + sync(); + sleep(1); + +- while (1) { +- printf("Magic happening now. (attempted!)\n"); +- mach_port_t fb = IOPMFindPowerManagement(MACH_PORT_NULL); +- if (fb != MACH_PORT_NULL) { +- kern_return_t kr = IOPMSleepSystem(fb); +- if (kr) { +- err(1, "IOPMSleepSystem returned %x\n", kr); +- } +- } else { +- err(1, "failed to get PM root port\n"); +- } +- sleep(3); +- } +- +- return 0; ++ printf("Requesting one sleep handoff.\n"); ++ kern_return_t sleep_result = orchard_handoff_once(); ++ if (sleep_result != KERN_SUCCESS) { ++ printf("Sleep handoff failed (%x); restart iOS before another attempt.\n", sleep_result); ++ return -1; ++ } ++ ++ return -1; + } + + /* how evil can you get???? */ diff --git a/boot/ios-launcher/kloader/kloader.c b/boot/ios-launcher/kloader/kloader.c new file mode 100644 index 00000000..b86380fa --- /dev/null +++ b/boot/ios-launcher/kloader/kloader.c @@ -0,0 +1,1194 @@ +/* + * Copyright 2014, winocm. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * $Id$ + */ +/* + * kloader + * Requires iOS 6.x or 7.x. (This version only.) + * + * Remap addresses: + * 0x7fe00000 -> 0x9fe00000 (0x5fe00000) iBSS (jump-to only.) + * + * xcrun -sdk iphoneos clang kloader.c -arch armv7 -framework IOKit -framework CoreFoundation -no-integrated-as \ + * -DINLINE_IT_ALL=1 -Wall -o kloader -miphoneos-version-min=6.0; ldid -Stfp0.plist kloader + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef mach_port_t io_service_t; +extern mach_port_t kIOMasterPortDefault; +extern mach_port_t IOPMFindPowerManagement(mach_port_t); +extern kern_return_t IOPMSleepSystem(mach_port_t); + +/* + * ARM page bits for L1 sections. + */ +#define L1_SHIFT 20 /* log2(1MB) */ + +#define L1_SECT_PROTO (1 << 1) /* 0b10 */ + +#define L1_SECT_B_BIT (1 << 2) +#define L1_SECT_C_BIT (1 << 3) + +#define L1_SECT_SORDER (0) /* 0b00, not cacheable, strongly ordered. */ +#define L1_SECT_SH_DEVICE (L1_SECT_B_BIT) +#define L1_SECT_WT_NWA (L1_SECT_C_BIT) +#define L1_SECT_WB_NWA (L1_SECT_B_BIT | L1_SECT_C_BIT) +#define L1_SECT_S_BIT (1 << 16) + +#define L1_SECT_AP_URW (1 << 10) | (1 << 11) +#define L1_SECT_PFN(x) (x & 0xFFF00000) + +#define L1_SECT_DEFPROT (L1_SECT_AP_URW) +#define L1_SECT_DEFCACHE (L1_SECT_SORDER) + +#define L1_PROTO_TTE(paddr) (L1_SECT_PFN(paddr) | L1_SECT_S_BIT | L1_SECT_DEFPROT | L1_SECT_DEFCACHE | L1_SECT_PROTO) + +#define PFN_SHIFT 2 +#define TTB_OFFSET(vaddr) ((vaddr >> L1_SHIFT) << PFN_SHIFT) + +/* + * RAM physical base begin. + */ +#define S5L8930_PHYS_OFF 0x40000000 +#define S5L8940_PHYS_OFF 0x80000000 /* Note: RAM base is identical for 8940-8955. */ + +uint32_t PHYS_OFF = S5L8930_PHYS_OFF; + +/* + * Shadowmap begin and end. 15MB of shadowmap is enough for the kernel. + * We don't need to invalidate unified D/I TLB or any cache lines + * since the kernel is mapped as writethrough memory, and these + * addresses are guaranteed to not be translated. + * (Accesses will cause segmentation faults due to failure on L1 translation.) + * + * Clear the shadowmappings when done owning the kernel. + * + * 0x7ff0'0000 is also below the limit for vm_read and such, so that's also *great*. + * (2048 bytes) + */ +#define SHADOWMAP_BEGIN 0x7f000000 +#define SHADOWMAP_END 0x7ff00000 +#define SHADOWMAP_GRANULARITY 0x00100000 + +#define SHADOWMAP_SIZE_BYTES (SHADOWMAP_END - SHADOWMAP_BEGIN) + +#define SHADOWMAP_BEGIN_OFF TTB_OFFSET(SHADOWMAP_BEGIN) +#define SHADOWMAP_END_OFF TTB_OFFSET(SHADOWMAP_END) +#define SHADOWMAP_SIZE (SHADOWMAP_END_OFF - SHADOWMAP_BEGIN_OFF) + +#define SHADOWMAP_BEGIN_IDX (SHADOWMAP_BEGIN_OFF >> PFN_SHIFT) +#define SHADOWMAP_END_IDX (SHADOWMAP_END_OFF >> PFN_SHIFT) + +#define TTB_SIZE 4096 +#define DEFAULT_KERNEL_SLIDE 0x80000000 + +static mach_port_t kernel_task = 0; +static uint32_t ttb_template[TTB_SIZE] = { }; + +static void *ttb_template_ptr = &ttb_template[0]; +static vm_address_t kernel_base = DEFAULT_KERNEL_SLIDE; + +typedef struct pmap_partial_t { + uint32_t tte_virt; + uint32_t tte_phys; + /* + * ... + */ +} pmap_partial_t; + +/* --- planetbeing patchfinder --- */ + +static uint32_t bit_range(uint32_t x, int start, int end) +{ + x = (x << (31 - start)) >> (31 - start); + x = (x >> end); + return x; +} + +static uint32_t ror(uint32_t x, int places) +{ + return (x >> places) | (x << (32 - places)); +} + +static int thumb_expand_imm_c(uint16_t imm12) +{ + if (bit_range(imm12, 11, 10) == 0) { + switch (bit_range(imm12, 9, 8)) { + case 0: + return bit_range(imm12, 7, 0); + case 1: + return (bit_range(imm12, 7, 0) << 16) | bit_range(imm12, 7, 0); + case 2: + return (bit_range(imm12, 7, 0) << 24) | (bit_range(imm12, 7, 0) << 8); + case 3: + return (bit_range(imm12, 7, 0) << 24) | (bit_range(imm12, 7, 0) << 16) | (bit_range(imm12, 7, 0) << 8) | bit_range(imm12, 7, 0); + default: + return 0; + } + } else { + uint32_t unrotated_value = 0x80 | bit_range(imm12, 6, 0); + return ror(unrotated_value, bit_range(imm12, 11, 7)); + } +} + +static int insn_is_32bit(uint16_t * i) +{ + return (*i & 0xe000) == 0xe000 && (*i & 0x1800) != 0x0; +} + +static int insn_is_bl(uint16_t * i) +{ + if ((*i & 0xf800) == 0xf000 && (*(i + 1) & 0xd000) == 0xd000) + return 1; + else if ((*i & 0xf800) == 0xf000 && (*(i + 1) & 0xd001) == 0xc000) + return 1; + else + return 0; +} + +static uint32_t insn_bl_imm32(uint16_t * i) +{ + uint16_t insn0 = *i; + uint16_t insn1 = *(i + 1); + uint32_t s = (insn0 >> 10) & 1; + uint32_t j1 = (insn1 >> 13) & 1; + uint32_t j2 = (insn1 >> 11) & 1; + uint32_t i1 = ~(j1 ^ s) & 1; + uint32_t i2 = ~(j2 ^ s) & 1; + uint32_t imm10 = insn0 & 0x3ff; + uint32_t imm11 = insn1 & 0x7ff; + uint32_t imm32 = (imm11 << 1) | (imm10 << 12) | (i2 << 22) | (i1 << 23) | (s ? 0xff000000 : 0); + return imm32; +} + +static int insn_is_b_conditional(uint16_t * i) +{ + return (*i & 0xF000) == 0xD000 && (*i & 0x0F00) != 0x0F00 && (*i & 0x0F00) != 0xE; +} + +static int insn_is_b_unconditional(uint16_t * i) +{ + if ((*i & 0xF800) == 0xE000) + return 1; + else if ((*i & 0xF800) == 0xF000 && (*(i + 1) & 0xD000) == 9) + return 1; + else + return 0; +} + +static int insn_is_ldr_literal(uint16_t * i) +{ + return (*i & 0xF800) == 0x4800 || (*i & 0xFF7F) == 0xF85F; +} + +static int insn_ldr_literal_rt(uint16_t * i) +{ + if ((*i & 0xF800) == 0x4800) + return (*i >> 8) & 7; + else if ((*i & 0xFF7F) == 0xF85F) + return (*(i + 1) >> 12) & 0xF; + else + return 0; +} + +static int insn_ldr_literal_imm(uint16_t * i) +{ + if ((*i & 0xF800) == 0x4800) + return (*i & 0xF) << 2; + else if ((*i & 0xFF7F) == 0xF85F) + return (*(i + 1) & 0xFFF) * (((*i & 0x0800) == 0x0800) ? 1 : -1); + else + return 0; +} + +// TODO: More encodings +static int insn_is_ldr_imm(uint16_t * i) +{ + uint8_t opA = bit_range(*i, 15, 12); + uint8_t opB = bit_range(*i, 11, 9); + + return opA == 6 && (opB & 4) == 4; +} + +static int insn_ldr_imm_rt(uint16_t * i) +{ + return (*i & 7); +} + +static int insn_ldr_imm_rn(uint16_t * i) +{ + return ((*i >> 3) & 7); +} + +static int insn_ldr_imm_imm(uint16_t * i) +{ + return ((*i >> 6) & 0x1F); +} + +// TODO: More encodings +static int insn_is_ldrb_imm(uint16_t * i) +{ + return (*i & 0xF800) == 0x7800; +} + +static int insn_ldrb_imm_rt(uint16_t * i) +{ + return (*i & 7); +} + +static int insn_ldrb_imm_rn(uint16_t * i) +{ + return ((*i >> 3) & 7); +} + +static int insn_ldrb_imm_imm(uint16_t * i) +{ + return ((*i >> 6) & 0x1F); +} + +static int insn_is_ldr_reg(uint16_t * i) +{ + if ((*i & 0xFE00) == 0x5800) + return 1; + else if ((*i & 0xFFF0) == 0xF850 && (*(i + 1) & 0x0FC0) == 0x0000) + return 1; + else + return 0; +} + +static int insn_ldr_reg_rn(uint16_t * i) +{ + if ((*i & 0xFE00) == 0x5800) + return (*i >> 3) & 0x7; + else if ((*i & 0xFFF0) == 0xF850 && (*(i + 1) & 0x0FC0) == 0x0000) + return (*i & 0xF); + else + return 0; +} + +int insn_ldr_reg_rt(uint16_t * i) +{ + if ((*i & 0xFE00) == 0x5800) + return *i & 0x7; + else if ((*i & 0xFFF0) == 0xF850 && (*(i + 1) & 0x0FC0) == 0x0000) + return (*(i + 1) >> 12) & 0xF; + else + return 0; +} + +int insn_ldr_reg_rm(uint16_t * i) +{ + if ((*i & 0xFE00) == 0x5800) + return (*i >> 6) & 0x7; + else if ((*i & 0xFFF0) == 0xF850 && (*(i + 1) & 0x0FC0) == 0x0000) + return *(i + 1) & 0xF; + else + return 0; +} + +static int insn_ldr_reg_lsl(uint16_t * i) +{ + if ((*i & 0xFE00) == 0x5800) + return 0; + else if ((*i & 0xFFF0) == 0xF850 && (*(i + 1) & 0x0FC0) == 0x0000) + return (*(i + 1) >> 4) & 0x3; + else + return 0; +} + +static int insn_is_add_reg(uint16_t * i) +{ + if ((*i & 0xFE00) == 0x1800) + return 1; + else if ((*i & 0xFF00) == 0x4400) + return 1; + else if ((*i & 0xFFE0) == 0xEB00) + return 1; + else + return 0; +} + +static int insn_add_reg_rd(uint16_t * i) +{ + if ((*i & 0xFE00) == 0x1800) + return (*i & 7); + else if ((*i & 0xFF00) == 0x4400) + return (*i & 7) | ((*i & 0x80) >> 4); + else if ((*i & 0xFFE0) == 0xEB00) + return (*(i + 1) >> 8) & 0xF; + else + return 0; +} + +static int insn_add_reg_rn(uint16_t * i) +{ + if ((*i & 0xFE00) == 0x1800) + return ((*i >> 3) & 7); + else if ((*i & 0xFF00) == 0x4400) + return (*i & 7) | ((*i & 0x80) >> 4); + else if ((*i & 0xFFE0) == 0xEB00) + return (*i & 0xF); + else + return 0; +} + +static int insn_add_reg_rm(uint16_t * i) +{ + if ((*i & 0xFE00) == 0x1800) + return (*i >> 6) & 7; + else if ((*i & 0xFF00) == 0x4400) + return (*i >> 3) & 0xF; + else if ((*i & 0xFFE0) == 0xEB00) + return *(i + 1) & 0xF; + else + return 0; +} + +static int insn_is_movt(uint16_t * i) +{ + return (*i & 0xFBF0) == 0xF2C0 && (*(i + 1) & 0x8000) == 0; +} + +static int insn_movt_rd(uint16_t * i) +{ + return (*(i + 1) >> 8) & 0xF; +} + +static int insn_movt_imm(uint16_t * i) +{ + return ((*i & 0xF) << 12) | ((*i & 0x0400) << 1) | ((*(i + 1) & 0x7000) >> 4) | (*(i + 1) & 0xFF); +} + +static int insn_is_mov_imm(uint16_t * i) +{ + if ((*i & 0xF800) == 0x2000) + return 1; + else if ((*i & 0xFBEF) == 0xF04F && (*(i + 1) & 0x8000) == 0) + return 1; + else if ((*i & 0xFBF0) == 0xF240 && (*(i + 1) & 0x8000) == 0) + return 1; + else + return 0; +} + +static int insn_mov_imm_rd(uint16_t * i) +{ + if ((*i & 0xF800) == 0x2000) + return (*i >> 8) & 7; + else if ((*i & 0xFBEF) == 0xF04F && (*(i + 1) & 0x8000) == 0) + return (*(i + 1) >> 8) & 0xF; + else if ((*i & 0xFBF0) == 0xF240 && (*(i + 1) & 0x8000) == 0) + return (*(i + 1) >> 8) & 0xF; + else + return 0; +} + +static int insn_mov_imm_imm(uint16_t * i) +{ + if ((*i & 0xF800) == 0x2000) + return *i & 0xF; + else if ((*i & 0xFBEF) == 0xF04F && (*(i + 1) & 0x8000) == 0) + return thumb_expand_imm_c(((*i & 0x0400) << 1) | ((*(i + 1) & 0x7000) >> 4) | (*(i + 1) & 0xFF)); + else if ((*i & 0xFBF0) == 0xF240 && (*(i + 1) & 0x8000) == 0) + return ((*i & 0xF) << 12) | ((*i & 0x0400) << 1) | ((*(i + 1) & 0x7000) >> 4) | (*(i + 1) & 0xFF); + else + return 0; +} + +static int insn_is_cmp_imm(uint16_t * i) +{ + if ((*i & 0xF800) == 0x2800) + return 1; + else if ((*i & 0xFBF0) == 0xF1B0 && (*(i + 1) & 0x8F00) == 0x0F00) + return 1; + else + return 0; +} + +static int insn_cmp_imm_rn(uint16_t * i) +{ + if ((*i & 0xF800) == 0x2800) + return (*i >> 8) & 7; + else if ((*i & 0xFBF0) == 0xF1B0 && (*(i + 1) & 0x8F00) == 0x0F00) + return *i & 0xF; + else + return 0; +} + +static int insn_cmp_imm_imm(uint16_t * i) +{ + if ((*i & 0xF800) == 0x2800) + return *i & 0xFF; + else if ((*i & 0xFBF0) == 0xF1B0 && (*(i + 1) & 0x8F00) == 0x0F00) + return thumb_expand_imm_c(((*i & 0x0400) << 1) | ((*(i + 1) & 0x7000) >> 4) | (*(i + 1) & 0xFF)); + else + return 0; +} + +static int insn_is_and_imm(uint16_t * i) +{ + return (*i & 0xFBE0) == 0xF000 && (*(i + 1) & 0x8000) == 0; +} + +static int insn_and_imm_rn(uint16_t * i) +{ + return *i & 0xF; +} + +static int insn_and_imm_rd(uint16_t * i) +{ + return (*(i + 1) >> 8) & 0xF; +} + +static int insn_and_imm_imm(uint16_t * i) +{ + return thumb_expand_imm_c(((*i & 0x0400) << 1) | ((*(i + 1) & 0x7000) >> 4) | (*(i + 1) & 0xFF)); +} + +static int insn_is_push(uint16_t * i) +{ + if ((*i & 0xFE00) == 0xB400) + return 1; + else if (*i == 0xE92D) + return 1; + else if (*i == 0xF84D && (*(i + 1) & 0x0FFF) == 0x0D04) + return 1; + else + return 0; +} + +static int insn_push_registers(uint16_t * i) +{ + if ((*i & 0xFE00) == 0xB400) + return (*i & 0x00FF) | ((*i & 0x0100) << 6); + else if (*i == 0xE92D) + return *(i + 1); + else if (*i == 0xF84D && (*(i + 1) & 0x0FFF) == 0x0D04) + return 1 << ((*(i + 1) >> 12) & 0xF); + else + return 0; +} + +static int insn_is_preamble_push(uint16_t * i) +{ + return insn_is_push(i) && (insn_push_registers(i) & (1 << 14)) != 0; +} + +static int insn_is_str_imm(uint16_t * i) +{ + if ((*i & 0xF800) == 0x6000) + return 1; + else if ((*i & 0xF800) == 0x9000) + return 1; + else if ((*i & 0xFFF0) == 0xF8C0) + return 1; + else if ((*i & 0xFFF0) == 0xF840 && (*(i + 1) & 0x0800) == 0x0800) + return 1; + else + return 0; +} + +static int insn_str_imm_postindexed(uint16_t * i) +{ + if ((*i & 0xF800) == 0x6000) + return 1; + else if ((*i & 0xF800) == 0x9000) + return 1; + else if ((*i & 0xFFF0) == 0xF8C0) + return 1; + else if ((*i & 0xFFF0) == 0xF840 && (*(i + 1) & 0x0800) == 0x0800) + return (*(i + 1) >> 10) & 1; + else + return 0; +} + +static int insn_str_imm_wback(uint16_t * i) +{ + if ((*i & 0xF800) == 0x6000) + return 0; + else if ((*i & 0xF800) == 0x9000) + return 0; + else if ((*i & 0xFFF0) == 0xF8C0) + return 0; + else if ((*i & 0xFFF0) == 0xF840 && (*(i + 1) & 0x0800) == 0x0800) + return (*(i + 1) >> 8) & 1; + else + return 0; +} + +static int insn_str_imm_imm(uint16_t * i) +{ + if ((*i & 0xF800) == 0x6000) + return (*i & 0x07C0) >> 4; + else if ((*i & 0xF800) == 0x9000) + return (*i & 0xFF) << 2; + else if ((*i & 0xFFF0) == 0xF8C0) + return (*(i + 1) & 0xFFF); + else if ((*i & 0xFFF0) == 0xF840 && (*(i + 1) & 0x0800) == 0x0800) + return (*(i + 1) & 0xFF); + else + return 0; +} + +static int insn_str_imm_rt(uint16_t * i) +{ + if ((*i & 0xF800) == 0x6000) + return (*i & 7); + else if ((*i & 0xF800) == 0x9000) + return (*i >> 8) & 7; + else if ((*i & 0xFFF0) == 0xF8C0) + return (*(i + 1) >> 12) & 0xF; + else if ((*i & 0xFFF0) == 0xF840 && (*(i + 1) & 0x0800) == 0x0800) + return (*(i + 1) >> 12) & 0xF; + else + return 0; +} + +static int insn_str_imm_rn(uint16_t * i) +{ + if ((*i & 0xF800) == 0x6000) + return (*i >> 3) & 7; + else if ((*i & 0xF800) == 0x9000) + return 13; + else if ((*i & 0xFFF0) == 0xF8C0) + return (*i & 0xF); + else if ((*i & 0xFFF0) == 0xF840 && (*(i + 1) & 0x0800) == 0x0800) + return (*i & 0xF); + else + return 0; +} + +// Given an instruction, search backwards until an instruction is found matching the specified criterion. +static uint16_t *find_last_insn_matching(uint32_t region, uint8_t * kdata, size_t ksize, uint16_t * current_instruction, int (*match_func) (uint16_t *)) +{ + while ((uintptr_t) current_instruction > (uintptr_t) kdata) { + if (insn_is_32bit(current_instruction - 2) && !insn_is_32bit(current_instruction - 3)) { + current_instruction -= 2; + } else { + --current_instruction; + } + + if (match_func(current_instruction)) { + return current_instruction; + } + } + + return NULL; +} + +// Given an instruction and a register, find the PC-relative address that was stored inside the register by the time the instruction was reached. +static uint32_t find_pc_rel_value(uint32_t region, uint8_t * kdata, size_t ksize, uint16_t * insn, int reg) +{ + // Find the last instruction that completely wiped out this register + int found = 0; + uint16_t *current_instruction = insn; + while ((uintptr_t) current_instruction > (uintptr_t) kdata) { + if (insn_is_32bit(current_instruction - 2)) { + current_instruction -= 2; + } else { + --current_instruction; + } + + if (insn_is_mov_imm(current_instruction) && insn_mov_imm_rd(current_instruction) == reg) { + found = 1; + break; + } + + if (insn_is_ldr_literal(current_instruction) && insn_ldr_literal_rt(current_instruction) == reg) { + found = 1; + break; + } + } + + if (!found) + return 0; + + // Step through instructions, executing them as a virtual machine, only caring about instructions that affect the target register and are commonly used for PC-relative addressing. + uint32_t value = 0; + while ((uintptr_t) current_instruction < (uintptr_t) insn) { + if (insn_is_mov_imm(current_instruction) && insn_mov_imm_rd(current_instruction) == reg) { + value = insn_mov_imm_imm(current_instruction); + } else if (insn_is_ldr_literal(current_instruction) && insn_ldr_literal_rt(current_instruction) == reg) { + value = *(uint32_t *) (kdata + (((((uintptr_t) current_instruction - (uintptr_t) kdata) + 4) & 0xFFFFFFFC) + insn_ldr_literal_imm(current_instruction))); + } else if (insn_is_movt(current_instruction) && insn_movt_rd(current_instruction) == reg) { + value |= insn_movt_imm(current_instruction) << 16; + } else if (insn_is_add_reg(current_instruction) && insn_add_reg_rd(current_instruction) == reg) { + if (insn_add_reg_rm(current_instruction) != 15 || insn_add_reg_rn(current_instruction) != reg) { + // Can't handle this kind of operation! + return 0; + } + + value += ((uintptr_t) current_instruction - (uintptr_t) kdata) + 4; + } + + current_instruction += insn_is_32bit(current_instruction) ? 2 : 1; + } + + return value; +} + +// Find PC-relative references to a certain address (relative to kdata). This is basically a virtual machine that only cares about instructions used in PC-relative addressing, so no branches, etc. +static uint16_t *find_literal_ref(uint32_t region, uint8_t * kdata, size_t ksize, uint16_t * insn, uint32_t address) +{ + uint16_t *current_instruction = insn; + uint32_t value[16]; + memset(value, 0, sizeof(value)); + + while ((uintptr_t) current_instruction < (uintptr_t) (kdata + ksize)) { + if (insn_is_mov_imm(current_instruction)) { + value[insn_mov_imm_rd(current_instruction)] = insn_mov_imm_imm(current_instruction); + } else if (insn_is_ldr_literal(current_instruction)) { + uintptr_t literal_address = (uintptr_t) kdata + ((((uintptr_t) current_instruction - (uintptr_t) kdata) + 4) & 0xFFFFFFFC) + insn_ldr_literal_imm(current_instruction); + if (literal_address >= (uintptr_t) kdata && (literal_address + 4) <= ((uintptr_t) kdata + ksize)) { + value[insn_ldr_literal_rt(current_instruction)] = *(uint32_t *) (literal_address); + } + } else if (insn_is_movt(current_instruction)) { + value[insn_movt_rd(current_instruction)] |= insn_movt_imm(current_instruction) << 16; + } else if (insn_is_add_reg(current_instruction)) { + int reg = insn_add_reg_rd(current_instruction); + if (insn_add_reg_rm(current_instruction) == 15 && insn_add_reg_rn(current_instruction) == reg) { + value[reg] += ((uintptr_t) current_instruction - (uintptr_t) kdata) + 4; + if (value[reg] == address) { + return current_instruction; + } + } + } + + current_instruction += insn_is_32bit(current_instruction) ? 2 : 1; + } + + return NULL; +} + +// This points to kernel_pmap. Use that to change the page tables if necessary. +uint32_t find_pmap_location(uint32_t region, uint8_t * kdata, size_t ksize) +{ + // Find location of the pmap_map_bd string. + uint8_t *pmap_map_bd = memmem(kdata, ksize, "\"pmap_map_bd\"", sizeof("\"pmap_map_bd\"")); + if (!pmap_map_bd) + return 0; + + // Find a reference to the pmap_map_bd string. That function also references kernel_pmap + uint16_t *ptr = find_literal_ref(region, kdata, ksize, (uint16_t *) kdata, (uintptr_t) pmap_map_bd - (uintptr_t) kdata); + if (!ptr) + return 0; + + // Find the beginning of it (we may have a version that throws panic after the function end). + while (*ptr != 0xB5F0) { + if ((uint8_t *)ptr == kdata) + return 0; + ptr--; + } + + // Find the end of it. + const uint8_t search_function_end[] = { 0xF0, 0xBD }; + ptr = memmem(ptr, ksize - ((uintptr_t) ptr - (uintptr_t) kdata), search_function_end, sizeof(search_function_end)); + if (!ptr) + return 0; + + // Find the last BL before the end of it. The third argument to it should be kernel_pmap + uint16_t *bl = find_last_insn_matching(region, kdata, ksize, ptr, insn_is_bl); + if (!bl) + return 0; + + // Find the last LDR R2, [R*] before it that's before any branches. If there are branches, then we have a version of the function that assumes kernel_pmap instead of being passed it. + uint16_t *ldr_r2 = NULL; + uint16_t *current_instruction = bl; + while ((uintptr_t) current_instruction > (uintptr_t) kdata) { + if (insn_is_32bit(current_instruction - 2) && !insn_is_32bit(current_instruction - 3)) { + current_instruction -= 2; + } else { + --current_instruction; + } + + if (insn_ldr_imm_rt(current_instruction) == 2 && insn_ldr_imm_imm(current_instruction) == 0) { + ldr_r2 = current_instruction; + break; + } else if (insn_is_b_conditional(current_instruction) || insn_is_b_unconditional(current_instruction)) { + break; + } + } + + // The function has a third argument, which must be kernel_pmap. Find out its address + if (ldr_r2) + return find_pc_rel_value(region, kdata, ksize, ldr_r2, insn_ldr_imm_rn(ldr_r2)); + + // The function has no third argument, Follow the BL. + uint32_t imm32 = insn_bl_imm32(bl); + uint32_t target = ((uintptr_t) bl - (uintptr_t) kdata) + 4 + imm32; + if (target > ksize) + return 0; + + // Find the first PC-relative reference in this function. + int found = 0; + + int rd; + current_instruction = (uint16_t *) (kdata + target); + while ((uintptr_t) current_instruction < (uintptr_t) (kdata + ksize)) { + if (insn_is_add_reg(current_instruction) && insn_add_reg_rm(current_instruction) == 15) { + found = 1; + rd = insn_add_reg_rd(current_instruction); + current_instruction += insn_is_32bit(current_instruction) ? 2 : 1; + break; + } + + current_instruction += insn_is_32bit(current_instruction) ? 2 : 1; + } + + if (!found) + return 0; + + return find_pc_rel_value(region, kdata, ksize, current_instruction, rd); +} + +// Function to find the syscall 0 function pointer. Used to modify the syscall table to call our own code. +uint32_t find_syscall0(uint32_t region, uint8_t * kdata, size_t ksize) +{ + // Search for the preamble to syscall 1 + const uint8_t syscall1_search[] = { 0x90, 0xB5, 0x01, 0xAF, 0x82, 0xB0, 0x09, 0x68, 0x01, 0x24, 0x00, 0x23 }; + void *ptr = memmem(kdata, ksize, syscall1_search, sizeof(syscall1_search)); + if (!ptr) + return 0; + + // Search for a pointer to syscall 1 + uint32_t ptr_address = (uintptr_t) ptr - (uintptr_t) kdata + region; + uint32_t function = ptr_address | 1; + void *syscall1_entry = memmem(kdata, ksize, &function, sizeof(function)); + if (!syscall1_entry) + return 0; + + // Calculate the address of syscall 0 from the address of the syscall 1 entry + return (uintptr_t) syscall1_entry - (uintptr_t) kdata - 0x18; +} + +// 0E E0 9F E7 FF FF FF EA C0 00 0C F1 +// ldr lr, [pc, lr] +// b +0x0 +// cpsid if + +uint32_t find_larm_init_tramp(uint32_t region, uint8_t * kdata, size_t ksize) +{ + const uint8_t search[] = { 0x0E, 0xE0, 0x9F, 0xE7, 0xFF, 0xFF, 0xFF, 0xEA, 0xC0, 0x00, 0x0C, 0xF1 }; + void *ptr = memmem(kdata, ksize, search, sizeof(search)); + if (!ptr) + return 0; + + return ((uintptr_t) ptr) - ((uintptr_t) kdata); +} + +/* --- planetbeing patchfinder --- */ + +uint32_t phys_addr_remap = 0x5fe00000; + +vm_address_t get_kernel_base() +{ + kern_return_t ret; + task_t kernel_task; + vm_region_submap_info_data_64_t info; + vm_size_t size; + mach_msg_type_number_t info_count = VM_REGION_SUBMAP_INFO_COUNT_64; + unsigned int depth = 0; + vm_address_t addr = 0x81200000; /* arm64: addr = 0xffffff8000000000 */ + + ret = task_for_pid(mach_task_self(), 0, &kernel_task); + if (ret != KERN_SUCCESS) + return -1; + + while (1) { + ret = vm_region_recurse_64(kernel_task, &addr, &size, &depth, (vm_region_info_t) & info, &info_count); + if (ret != KERN_SUCCESS) + break; + if (size > 1024 * 1024 * 1024) { + /* + * https://code.google.com/p/iphone-dataprotection/ + * hax, sometimes on iOS7 kernel starts at +0x200000 in the 1Gb region + */ + pointer_t buf; + mach_msg_type_number_t sz = 0; + addr += 0x200000; + vm_read(kernel_task, addr + 0x1000, 512, &buf, &sz); + if (*((uint32_t *)buf) != 0xfeedface) { + addr -= 0x200000; + vm_read(kernel_task, addr + 0x1000, 512, &buf, &sz); + if (*((uint32_t*)buf) != 0xfeedface) { + break; + } + } + return addr; + } + addr += size; + } + + return -1; +} + +static void generate_ttb_entries(void) +{ + uint32_t vaddr, vaddr_end, paddr, i; + + paddr = PHYS_OFF; + vaddr = SHADOWMAP_BEGIN; + vaddr_end = SHADOWMAP_END; + + for (i = vaddr; i <= vaddr_end; i += SHADOWMAP_GRANULARITY, paddr += SHADOWMAP_GRANULARITY) { +#if SPURIOUS_DEBUG_OUTPUT + printf("ProtoTTE: 0x%08x for VA 0x%08x -> PA 0x%08x\n", L1_PROTO_TTE(paddr), i, paddr); +#endif + ttb_template[TTB_OFFSET(i) >> PFN_SHIFT] = L1_PROTO_TTE(paddr); + } + + /* + * Remap TTE for iBoot load address. + */ + uint32_t ttb_remap_addr_base = 0x7fe00000; + ttb_template[TTB_OFFSET(ttb_remap_addr_base) >> PFN_SHIFT] = L1_PROTO_TTE(phys_addr_remap); +#if SPURIOUS_DEBUG_OUTPUT + printf("remap -> 0x%08x => 0x%08x (TTE: 0x%08x)\n", ttb_remap_addr_base, phys_addr_remap, L1_PROTO_TTE(phys_addr_remap)); + + printf("TTE offset begin for shadowmap: 0x%08x\n" "TTE offset end for shadowmap: 0x%08x\n" "TTE size: 0x%08x\n", SHADOWMAP_BEGIN_OFF, SHADOWMAP_END_OFF, SHADOWMAP_SIZE); +#endif + + printf("New TTEs generated, base address for remap: 0x%08x, physBase: 0x%08x\n", PHYS_OFF, phys_addr_remap); + return; +} + +#define DMPSIZE 0xd00000 +extern char shellcode_begin[], shellcode_end[]; +extern uint32_t larm_init_tramp; +extern uint32_t flush_dcache, invalidate_icache; +extern uint32_t kern_base, kern_tramp_phys; + +int main(int argc, char *argv[]) +{ + uint32_t chunksize = 2048; + struct stat st; + + if (argc != 2) { + printf("usage: %s [loadfile]\n" "This will destroy the current running OS instance and fire up the loaded image.\n" "You have been warned.\n", argv[0]); + return -1; + } + + if (stat(argv[1], &st) == -1) { + printf("Failed to open %s.\n", argv[1]); + return -1; + } + + /* + * Get physbase. + */ + size_t size; + sysctlbyname("kern.version", NULL, &size, NULL, 0); + char *osversion = malloc(size); + if (sysctlbyname("kern.version", osversion, &size, NULL, 0) == -1) { + printf("fail to kern.version sysctl\n"); + exit(-1); + } +#if SPURIOUS_DEBUG_OUTPUT + printf("%s\n", osversion); +#endif + + if (strcasestr(osversion, "s5l8930x")) { + PHYS_OFF = S5L8930_PHYS_OFF; + phys_addr_remap = 0x5fe00000; + } else if (strcasestr(osversion, "s5l8920x") || strcasestr(osversion, "s5l8922x")) { + PHYS_OFF = S5L8930_PHYS_OFF; + phys_addr_remap = 0x4fe00000; + } else if (strcasestr(osversion, "s5l8940x") || strcasestr(osversion, "s5l8942x") || strcasestr(osversion, "s5l8947x")) { + /* + * All others have the high ram base. + */ + PHYS_OFF = S5L8940_PHYS_OFF; + phys_addr_remap = 0x9fe00000; + } else if (strcasestr(osversion, "s5l8945x")) { + PHYS_OFF = S5L8940_PHYS_OFF; + phys_addr_remap = 0xbfe00000; + } else if (strcasestr(osversion, "s5l8950x") || strcasestr(osversion, "s5l8955x")) { + PHYS_OFF = S5L8940_PHYS_OFF; + phys_addr_remap = 0xbfe00000; + } else { + printf("Bravely assuming you're on an 8940-class device (unrecognized). You are on your own.\n"); + /* + * All others have the high ram base. + */ + PHYS_OFF = S5L8940_PHYS_OFF; + phys_addr_remap = 0x9fe00000; + } + + /* + * Pedanticness, though doesn't matter, after we quit the entire OS is gone lol + */ + free(osversion); + +#if SPURIOUS_DEBUG_OUTPUT + printf("physOff 0x%08x remap 0x%08x\n", PHYS_OFF, phys_addr_remap); +#endif + + /* + * get kernel base. + */ + kernel_base = get_kernel_base(); + if (kernel_base == (vm_address_t)-1) { + printf("failed to get kernel_baseel base...\n"); + return -1; + } + + printf("Kernel base is 0x%08x.\n", kernel_base); + + /* + * we can now find the kernel pmap. + */ + kern_return_t r = task_for_pid(mach_task_self(), 0, &kernel_task); + if (r != 0) { + printf("task_for_pid failed.\n"); + return -1; + } + + /* + * kill + */ + vm_address_t addr = kernel_base + 0x1000, e = 0, sz = 0; + uint8_t *p = malloc(DMPSIZE + 0x1000); + pointer_t buf; + + if (!p) { + printf("failed to malloc memory for kernel dump...\n"); + return -1; + } + while (addr < (kernel_base + DMPSIZE)) { + vm_read(kernel_task, addr, chunksize, &buf, &sz); + if (!buf || sz == 0) + continue; + uint8_t *z = (uint8_t *) buf; + addr += chunksize; + bcopy(z, p + e, chunksize); + e += chunksize; + } + + /* + * kernel dumped, now find pmap. + */ + uint32_t kernel_pmap = kernel_base + 0x1000 + find_pmap_location(kernel_base, (uint8_t *) p, DMPSIZE); + printf("kernel pmap is at 0x%08x.\n", kernel_pmap); + + /* + * Read for kernel_pmap, dereference it for pmap_store. + */ + vm_read(kernel_task, kernel_pmap, 2048, &buf, &sz); + vm_read(kernel_task, *(vm_address_t *) (buf), 2048, &buf, &sz); + + /* + * We now have the struct. Let's copy it out to get the TTE base (we don't really need to do this + * as it should just remain constant. TTEs should be after ToKD.) + */ + pmap_partial_t *part = (pmap_partial_t *) buf; + uint32_t tte_virt = part->tte_virt; + uint32_t tte_phys = part->tte_phys; + + printf("kernel pmap details: tte_virt: 0x%08x tte_phys: 0x%08x\n", tte_virt, tte_phys); + if (PHYS_OFF != (tte_phys & ~0xFFFFFFF)) { + printf("physOff 0x%08x should be 0x%08x\n", PHYS_OFF, tte_phys & ~0xFFFFFFF); + return -1; + } + + /* + * generate TTEs. + */ + generate_ttb_entries(); + + /* + * Now, we can start reading at the TTE base and start writing in the descriptors. + */ + uint32_t tte_off = SHADOWMAP_BEGIN_OFF; + vm_read(kernel_task, tte_virt + tte_off, 2048, &buf, &sz); + bcopy((char *) ttb_template_ptr + tte_off, (void *) buf, SHADOWMAP_SIZE); + vm_write(kernel_task, tte_virt + tte_off, buf, sz); + + printf("======================================================================================\n"); + printf("!!!! Kernel TTE entries written. System stability is no longer guaranteed.\n"); + printf("!!!! Security has also been reduced by an exponential factor. You have been warned.\n"); + printf("======================================================================================\n"); + + if (signal(SIGINT, SIG_IGN) != SIG_IGN) + signal(SIGINT, SIG_IGN); + + /* + * remap_address = 0x7ff00000 + */ + FILE *f = fopen(argv[1], "rb"); + if (!f) { + printf("Failed to open iBEC. Rebooting momentarily...\n"); + sleep(3); + reboot(0); + } + + fseek(f, 0, SEEK_END); + int length = ftell(f); + fseek(f, 0, SEEK_SET); + void *vp = malloc(length); + fread(vp, length, 1, f); + fclose(f); + printf("Read bootloader into buffer %p, length %d\n", vp, length); + + bcopy((void *) vp, (void *) 0x7fe00000, length); + + /* + * Verify ARM header. + */ + if (*(uint32_t *) 0x7fe00000 != 0xea00000e) { + printf("This doesn't seem like an ARM image, perhaps it failed to copy? Continuing though.\n"); + } + + printf("Image information: %s\n", (char *) 0x7fe00000 + 0x200); + printf("Image information: %s\n", (char *) 0x7fe00000 + 0x240); + printf("Image information: %s\n", (char *) 0x7fe00000 + 0x280); + + free(vp); + + /* + * iBEC copied, we need to copy over the shellcode now. + */ + uint32_t sysent_common = 0x1000 + find_syscall0(kernel_base + 0x1000, (uint8_t *) p, DMPSIZE) + SHADOWMAP_BEGIN; + printf("sysent_common_base: 0x%08x\n", sysent_common); + + /* + * fuck evasi0n7 + */ + if (*(uint32_t *) (sysent_common) == 0) { + printf("iOS 7 detected, adjusting base to 0x%08x = 0x%08x\n", sysent_common, *(uint32_t *) (sysent_common)); + sysent_common += 4; + if (*(uint32_t *) (sysent_common) == 0) { + printf("Something is severely wrong (blaming iOS 7 anyhow). Rebooting momentarily.\n"); + sleep(3); + reboot(0); + } + } + + /* + * Set offsets. + */ + larm_init_tramp = 0x1000 + find_larm_init_tramp(kernel_base + 0x1000, (uint8_t *) p, DMPSIZE) + SHADOWMAP_BEGIN; + + kern_base = kernel_base; + kern_tramp_phys = phys_addr_remap; + +#if 0 + + printf("larm_init_tramp is at 0x%08x\n", larm_init_tramp); + bcopy(shellcode_begin, (void *) 0x7f000c00, shellcode_end - shellcode_begin); + *(uint32_t *) sysent_common = 0x7f000c01; + + printf("Running shellcode now.\n"); + syscall(0); + +#else + + static uint32_t arm[2] = { 0xe51ff004, 0 }; + arm[1] = phys_addr_remap; + // Requires D-cache writeback. + printf("Tramp %x COMMMAP\n", larm_init_tramp); + printf("%lx, %lx\n", *(uintptr_t *) (larm_init_tramp), *(uintptr_t *) (larm_init_tramp + 4)); + printf("%x\n", *(uint32_t *) (0x7f000000 + 0x1000)); + bcopy((void *) arm, (void *) larm_init_tramp, sizeof(arm)); + printf("%lx, %lx\n", *(uintptr_t *) (larm_init_tramp), *(uintptr_t *) (larm_init_tramp + 4)); + printf("%x\n", *(uint32_t *) (0x7f000000 + 0x1000)); + +#endif + + printf("Syncing disks.\n"); + int diskSync; + for (diskSync = 0; diskSync < 10; diskSync++) + sync(); + sleep(1); + + while (1) { + printf("Magic happening now. (attempted!)\n"); + mach_port_t fb = IOPMFindPowerManagement(MACH_PORT_NULL); + if (fb != MACH_PORT_NULL) { + kern_return_t kr = IOPMSleepSystem(fb); + if (kr) { + err(1, "IOPMSleepSystem returned %x\n", kr); + } + } else { + err(1, "failed to get PM root port\n"); + } + sleep(3); + } + + return 0; +} + +/* how evil can you get???? */ +#if INLINE_IT_ALL +/* hey, you gotta compile with -no-integrated-as */ +#ifdef __arm__ +__asm__("\n" + " .code 16\n" + " .thumb_func\n" + " .align 2\n" + " .data\n" + " .globl _shellcode_begin\n" + " .globl _shellcode_end\n" + " .globl _larm_init_tramp\n" + " .globl _flush_dcache\n" + " .globl _invalidate_icache\n" + " .globl _kern_tramp_phys\n" + " .globl _kern_base\n" + "_shellcode_begin:\n" + " mrs r12, cpsr\n" + " cpsid if\n" + " ldr r0, 0f\n" + " ldr r1, _kern_tramp_phys\n" + " ldr r2, _larm_init_tramp\n" + " str r0, [r2]\n" + " str r1, [r2, #4]\n" + " ldr r0, _kern_base\n" + " @ Clear cache to PoC\n" + " ldr r2, _larm_init_tramp\n" + " bic r2, r2, #((1 << 6) - 1)\n" + " mcr p15, 0, r2, c7, c14, 1\n" + " mov r1, #256\n" + ".Lloop:\n" + " add r2, r2, #(1 << 6)\n" + " mcr p15, 0, r2, c7, c14, 1\n" + " subs r1, r1, #1\n" + " bne .Lloop\n" + " msr cpsr_c, r12\n" + " movs r0, #0\n" + " bx lr\n" + " .align 2\n" + "0: .long 0xe51ff004\n" + "_kern_tramp_phys: .long 0x9bf00000\n" + "_kern_base: .long 0xdeadbeef\n" + "_larm_init_tramp: .long 0xdeadbeef\n" + "_flush_dcache: .long 0xdeadbeef\n" + "_invalidate_icache: .long 0xdeadbeef\n" + "_shellcode_end:\n" + " mov r8, r8\n"); +#else +#error - this is only for ARM.. +#endif +#endif diff --git a/boot/ios-launcher/kloader/source.json b/boot/ios-launcher/kloader/source.json new file mode 100644 index 00000000..5037fb78 --- /dev/null +++ b/boot/ios-launcher/kloader/source.json @@ -0,0 +1,11 @@ +{ + "schema": 1, + "repository": "https://github.com/axi0mX/ios-kexec-utils", + "commit": "a1bbca8a8f2847d9c53070e76e0b5d093bda5993", + "author": "winocm", + "license": "GPL-2.0-or-later", + "files": { + "kloader.c": "c64f3252118d65b0ab52f10c8eaadc1dd1d7a989352a6e497075f15fb174296a", + "tfp0.plist": "517f29407243b3f9d7252519282e47701a76b810c58000f1d60eb16106ed4d5d" + } +} diff --git a/boot/ios-launcher/kloader/tfp0.plist b/boot/ios-launcher/kloader/tfp0.plist new file mode 100644 index 00000000..77ee3697 --- /dev/null +++ b/boot/ios-launcher/kloader/tfp0.plist @@ -0,0 +1,12 @@ + + + + + get-task-allow + + run-unsigned-code + + task_for_pid-allow + + + diff --git a/boot/ios-launcher/shim-build b/boot/ios-launcher/shim-build new file mode 100755 index 00000000..5d185d74 --- /dev/null +++ b/boot/ios-launcher/shim-build @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-2.0-only +set -euo pipefail +"$ORCHARD_CLANG" --target=arm-none-eabi -march=armv7-a -mthumb -mfloat-abi=soft \ + -ffreestanding -fno-builtin -fno-stack-protector -fno-unwind-tables \ + -fno-asynchronous-unwind-tables -Oz -Wall -Wextra -Werror \ + -I "$ORCHARD_OUTPUT" -c "$ORCHARD_SOURCE/ibec-local.c" -o "$ORCHARD_OUTPUT/local.o" +"$ORCHARD_LINKER" --build-id=none -T "$ORCHARD_OUTPUT/local.ld" \ + "$ORCHARD_OUTPUT/local.o" -o "$ORCHARD_OUTPUT/local.elf" +"$ORCHARD_OBJCOPY" -O binary "$ORCHARD_OUTPUT/local.elf" "$ORCHARD_OUTPUT/local.bin" diff --git a/chips/s5l8942x.toml b/chips/s5l8942x.toml index 50ba0c2d..b6910463 100644 --- a/chips/s5l8942x.toml +++ b/chips/s5l8942x.toml @@ -14,3 +14,8 @@ stages = ["iBSS", "iBEC"] platform = "P105 on a Cascadia-based build" stages = "Project Cascadia P105 boot chain" reference_version = "Project Cascadia builds its A5 boot chain from 8.4.1 / 12H321" +cascadia_profile = "devices/p105/cascadia.toml; external-initramfs handoff awaits hardware qualification" +ios_local_file = "P105 iOS 9.3.5 owner-started a source-built helper with a pinned 12H321 iBEC; current-base build awaits device validation" + +# Public Cascadia RAM recovery correlates a guarded host USB identity with the live FDT and boot ID. +# Linux SoC ECID access and on-device public recovery identity remain unqualified. diff --git a/devices/p105/README.md b/devices/p105/README.md index a39986c9..f66904bd 100644 --- a/devices/p105/README.md +++ b/devices/p105/README.md @@ -26,3 +26,14 @@ the IPSW members `058-24036-023.dmg`, `kernelcache.release.p105`, `Firmware/dfu/ The touch calibration is per unit and private: `lib/firmware/mtcal.bin`, the unit's syscfg `MtCl`, captured from its jailbroken iOS. It is never part of a public recipe or release. + +## ARMv7 recovery assembly + +The platform's `cascadia` payload producer builds the staging loader from +source with `cascadia.toml`. Select this board explicitly when resolving a +release's inputs: `python -m orchard.inputs --device p105 --output inputs.json`. +The profile identifies the memory window and handoff registers for this board. +The generated loader checks its configuration before copying the recovery +initramfs into the reserved high-memory range. The DTB names that initramfs. +This extension needs a hardware boot of the final producer revision; the +earlier installed P105 boot used an initramfs compiled into its kernel. diff --git a/devices/p105/boot.json b/devices/p105/boot.json index b2322755..56c44353 100644 --- a/devices/p105/boot.json +++ b/devices/p105/boot.json @@ -49,7 +49,48 @@ "cache_flush": "0x9ff2134c", "done_flag": "0x9ff48bb0", "usb_continue": "0x9ff201a4", - "source": "rzxvx/Project-Cascadia@091d441 devices/a5/scripts/patch-ibec-autogo.py" + "source": "rzxvx/Project-Cascadia@091d441 devices/a5/scripts/patch-ibec-autogo.py", + "image_base": "0x9ff00000", + "payload_address": "0x80000000", + "prepared_ibec": { + "bytes": 282624, + "sha256": "e987b101800bedf03c086aa55854ff594fcce1b0f55ea1424f3ccf180a40abce" + }, + "maximum_payload_bytes": 66060288 + }, + "ios-local-file": { + "identity_source": "ios-device", + "ios_model": "P105AP", + "ios_registry_compatible": ["P105AP", "iPad2,5", "AppleARM"], + "attempt_boot_path": "/boot/omarchy-p105-attempt.bin", + "ibec_attempt_boot_path": "/boot/boot/omarchy-p105-attempt.bin", + "attempt_ibec_path": "/usr/local/lib/orchard-p105/ibec-attempt.raw", + "build": "12H321", + "source": "Shayne Legault P105 local-file bring-up; derived from the owner-supplied patched iBEC; boot/ios-launcher/README.md", + "prepared_ibec_sha256": "e987b101800bedf03c086aa55854ff594fcce1b0f55ea1424f3ccf180a40abce", + "prepared_ibec_bytes": 282624, + "base": 2683305984, + "hook": 3190, + "finish_init": 2683429353, + "shim_start": 278072, + "shim_end": 278528, + "mount_bootfs": 2683308605, + "load_file": 2683454109, + "load_address": 2147483648, + "maximum_image_bytes": 66060288, + "boot_device": "nand0a", + "ibec_boot_path": "/boot/boot/omarchy-p105.bin", + "boot_path": "/boot/omarchy-p105.bin", + "ibec_path": "/usr/local/lib/orchard-p105/ibec-local.raw", + "kloader_path": "/usr/local/lib/orchard-p105/kloader", + "helper_path": "/usr/local/libexec/orchard-p105-start", + "lease_path": "/var/run/orchard-p105.start", + "wake_owner": "org.orchardlinux.p105.start", + "wake_delay_seconds": 60, + "app_path": "/Applications/Omarchy-A.app", + "bundle_id": "org.orchardlinux.p105.start", + "minimum_ios": "9.0", + "ios_build": "13G36" } } } diff --git a/devices/p105/cascadia.toml b/devices/p105/cascadia.toml new file mode 100644 index 00000000..95f23a4a --- /dev/null +++ b/devices/p105/cascadia.toml @@ -0,0 +1,12 @@ +# P105 handoff addresses from the Cascadia-derived staging loader used for the +# owner's successful iBEC boot. The external-initramfs placement is new and +# requires a fresh hardware test with this producer. +schema = 1 +load_address = 0x80000000 +kernel_offset = 0x8000 +watchdog_address = 0x0f103020 +l2_address = 0x3e000000 +ram_start = 0x80000000 +ram_end = 0xa0000000 +initrd_address = 0x98000000 +initrd_limit = 0x9e000000 diff --git a/devices/p105/device.toml b/devices/p105/device.toml index f87d1027..b5455b1c 100644 --- a/devices/p105/device.toml +++ b/devices/p105/device.toml @@ -13,9 +13,12 @@ chip = 0x8942 board = 0x0a [boot] -methods = ["jailbreak-kdfu", "recovery-autogo"] +# Public recovery uses a host-bound USB identity handoff; hardware qualification is pending. +# Source-built Cascadia payload profile: cascadia.toml. External-initramfs handoff awaits testing. +methods = ["jailbreak-kdfu", "recovery-autogo", "ios-local-file"] entries = ["pico-checkm8", "kdfu"] -# The CLI consumes an owner-private boot manifest. Public launch assets await release integration. +# ios-local-file prepares an on-device launcher from the exact boot.json input. +# Installation eligibility still follows the supported recovery/storage methods. [kernel] platform = "apple-armv7" # kernel/series/apple-armv7, kernel/configs/apple-armv7 diff --git a/docs/adapter-contract.md b/docs/adapter-contract.md index bfc3288f..14260669 100644 --- a/docs/adapter-contract.md +++ b/docs/adapter-contract.md @@ -20,13 +20,30 @@ Arguments and return types are defined in `contracts.py`, not repeated in a seco ## Records - `SelectedDevice`: required `mode`, `location`, non-null validated `ecid` and explicit boolean `resumed`; optional observed chip/board/product/serial details. `resumed` is true for running recovery and false for DFU/Pongo. Common orchestration requires this explicit state. A USB location may change across enumeration and is not persistent device identity. -- `Connection`: SSH arguments, validated `BootState` and a current-endpoint check performed before every RPC. Private state is keyed by profile/ECID/boot, never by USB port. -- `BootAssets`: local paths for the selected operation. Running recovery needs no upload; other states require recovery, and DFU additionally requires Pongo. Installation root is not interpreted by the adapter's boot method. +- `Connection`: SSH arguments, validated `BootState` and a current-endpoint check performed before every RPC. A checked ARM32 recovery also carries its `RecoveryHandoff`. Private state is keyed by profile/ECID/boot. +- `RecoveryHandoff`: the attempt's 128-bit nonce, template SHA-256, chip/board IDs and explicit origin. Serial bootstrap and authenticated SSH status must report those fields; the USB endpoint retains the matching `HND` nonce and origin throughout the session. +- `BootAssets`: local paths for the selected operation. Running recovery needs no upload; other states require recovery, and checkm8-pongo additionally requires Pongo. The recovery-autogo path reads a separately prepared private iBEC input and accepts a checked ARM32 payload. Installation root is not interpreted by the adapter's boot method. - `BootRequest`: minimum boot input, including the recovery SHA receipt. Both manifest-resolved and explicit-file boot use it; no source-specific fallback adapter API exists. - `MixinReceipt`: schema 1, `private-mixin`, exact compressed byte count and SHA-256. TypedDict records describe the boundary. They do not replace validation of untrusted USB data or manifests, and do not imply that every dictionary constructed by an arbitrary caller is safe. +For a recovery-autogo template carrying a host identity policy, the transport +queries the selected Apple boot stage and binds that identity to a fresh attempt. +It retains the template, bound bundle and exact loader-prefix hashes in a +private receipt. The transport sends the full bundle, followed by its checked +small prefix to start the prepared iBEC hook. A durable phase, minimum one-second +settle and exact Recovery identity check separate the two uploads. Resuming a +host-uploaded USB recovery requires its completed receipt with the same profile, +ECID and Linux boot ID. + +An on-device iOS start advertises `ORG:i`. Its expected template comes from the +private launcher output explicitly selected through `ORCHARD_IOS_LAUNCHER`. +That provision receipt binds the public input hash, installed launch template +and boot recipe. Serial and SSH checks compare the runtime provenance with the +selected receipt before granting phase RPC access. ARM64 ADT identity and +caller-owned ARM32 payloads without this policy keep their existing paths. + ## Enforcement The registry checks required member presence, profile identity and presentation metadata without invoking discovery. Python runtime Protocol checks do not verify function signatures; static checking does that. The real J71b module is assigned directly to `Adapter` in `tests/typecheck_adapter.py`, so signature drift is rejected without creating a wrapper implementation. diff --git a/docs/boot-contract.md b/docs/boot-contract.md index aa7c72fc..b6c55a4d 100644 --- a/docs/boot-contract.md +++ b/docs/boot-contract.md @@ -199,3 +199,80 @@ the same m1n1 code, splash included. - Every change must be tested on both ways into m1n1: Pongo, and iBoot (`launcher-kit`, `./launch boot --bare`, then the client over the proxy). Device time goes through the user. Which SoCs have an m1n1 storage backend is in [bring-up/bootloader/experiments.md](bring-up/bootloader/experiments.md#per-device-menu). + +## ARMv7 recovery identity origins + +Cascadia recovery templates carry a descriptor-selected identity policy in the +standard `/chosen/bootargs` string. The parameters pass observed boot identity +to userland. `orchard.recovery-policy=VALUE` and +`orchard.recovery-handoff=VALUE` encode bounded JSON objects as canonical, +unpadded base64url. Each token is bounded; the entire ARM command line remains +at most 1,023 ASCII bytes. Duplicate JSON keys, repeated parameters, unknown +fields, invalid types and incomplete records fail before recovery USB binds. + +The policy names the canonical `profile`, accepted live FDT `compatibles`, and +integer `chip` and `board` identifiers. Schema 1 permits `source = "host-usb"`. +Schema 2 carries an explicit `sources` allowlist containing `host-usb`, +`ios-device`, or both. A board receives the on-device origin only when its +descriptor declares `ios-local-file` and the matching boot recipe names +`identity_source = "ios-device"`; an incomplete declaration is rejected. The +public runtime contains this same policy from the selected hardware data. + +The handoff remains schema 1. Its `source` names the observed origin and must +appear in the staged policy's allowlist. It contains a nonzero random 128-bit +`handoff_id`, exact `template_sha256`, `profile`, `ecid`, `chip` and `board`. + +A host RAM-boot template contains the policy alone. The guarded transport +verifies its original digest and freshly queries the selected iRecovery endpoint +for its ECID, CPID, BDID and product. It binds one nonzero random 128-bit +`handoff_id` to that observation with `source = "host-usb"`. The transport +rebuilds the checked CB32 configuration and FDT while retaining the kernel and +initramfs bytes, then rechecks the selected endpoint immediately before sending +the bound bundle. After that upload completes, it records a durable settle +phase, waits at least one monotonic second for iBEC to rearm its Recovery +endpoint, and rechecks the exact ECID, chip, board, product and mode. A second +Recovery upload carries the checked loader prefix through the complete bound +configuration, ending at offset `0x7f44`. This transfer starts the complete bundle +already in RAM. Resume requires the current trigger length; earlier `0x6001` +receipts remain as evidence and are refused for resume. The transport rejects +Recovery upload lengths on a 512-byte packet boundary because the supported +irecovery command cannot prove the required trailing packet. Its private attempt +receipt records the template, bound bundle and loader-prefix hashes, loader +prefix byte count, completed settle check and nonce. + +For an on-device start, preparation reserves a bounded whitespace slot in a +deterministic launch template. Its private provision receipt retains the public +input hash and exact installed template hash. The root iOS helper checks the +installed assets and live iOS machine/build guards, then reads the IODeviceTree +identity using the recipe's exact property formats. It creates a fresh +`ios-device` handoff and fills the slot in an attempt copy. The corresponding +iBEC copy carries the checked CRC of that attempt. Both copies pass their bounds +and integrity checks before wake or kloader starts; the pinned installed assets +stay intact. + +The runtime requires the live compatible, policy token and staged policy to +agree. It publishes the actual `identity_source`, nonce and template digest with +the kernel's fresh boot ID. The ECID provenance remains the stated boot origin. +USB serial includes `HND:` followed by the nonce and a compact origin: `ORG:h` +means `host-usb`, while `ORG:i` means `ios-device`. P105's serial is 117 ASCII +characters with these fields, within the runtime's 126-character limit. The host +pins the origin and nonce before accepting this boot, then checks every +provenance field again through serial SSH bootstrap and authenticated status. + +A later host command resumes a host-uploaded recovery through its private +completed attempt receipt, including the same profile, ECID, nonce, boot ID and +template digest. An on-device recovery instead needs an explicitly selected +release or private provision receipt that pins its exact installed template. +Offline startup works independently of a host upload record. An unknown or +changed handoff stops before a storage operation. + +The existing ARM64 path requires its live ADT MTD identity source. Absence of a +boot identity policy preserves that path; an invalid policy or missing required +handoff stops the ARMv7 path. + +The platform recovery policy also selects USB function dependencies and early +storage activation. ARMv7 loads its declared ECM/ACM modules, keeps serial +available through network setup, and leaves SFTL unloaded until a manual +request after serial and authenticated SSH are ready. The module's finite +read policy bounds that later request. Hardware feature states change only +after the exact submitted stack passes its physical checks. diff --git a/docs/compatibility.md b/docs/compatibility.md index 8d54078d..3a72ff97 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -40,7 +40,7 @@ Generated by `tools/catalog/generate matrix` from `devices/`, `chips/` and `meth | A5 | `k95` | iPad 2 (CDMA) | iPad2,3 | known | – | – | – | – | 12H321 | | A5 | `n94` | iPhone 4s | iPhone4,1 | known | – | – | – | – | 12H321 | | A5 | `k93a` | iPad 2 (Mid 2012) | iPad2,4 | known | – | – | – | – | 12H321 | -| A5 | `p105` | iPad mini 1 (Wi-Fi) | iPad2,5 | experimental | – | jailbreak-kdfu, recovery-autogo | yes | yes | 12H321 | +| A5 | `p105` | iPad mini 1 (Wi-Fi) | iPad2,5 | experimental | – | jailbreak-kdfu, recovery-autogo, ios-local-file | yes | yes | 12H321 | | A5 | `p106` | iPad mini (GSM) | iPad2,6 | known | – | – | – | – | 12H321 | | A5 | `p107` | iPad mini (Global) | iPad2,7 | known | – | – | – | – | 12H321 | | A5X | `j1` | iPad 3 (Wi-Fi) | iPad3,1 | experimental | – | jailbreak-kdfu | yes | yes | 13G36 | diff --git a/docs/distribution-contract.md b/docs/distribution-contract.md index 082f604f..9ac4f435 100644 --- a/docs/distribution-contract.md +++ b/docs/distribution-contract.md @@ -21,11 +21,11 @@ without orchard takes it as an argument. | Command | Does | Files | |---|---|---| -| `orchard.inputs --output FILE [--platform P …] [--kernel-dir P=DIR …] [--m1n1 FILE] [--pongo FILE] [--tools DIR] [--kernel-tree DIR]` (`ORCHARD_KERNEL_TREE` too) | resolves what the build takes finished | `FILE`: per platform its kernel bundle and boards (board → DTB), the shared m1n1, Pongo and static tools, orchard's revision, and under `produced` how each was got (built: its commands; reused from the cache; downloaded from a nightly: tag, asset, sha256); paths into orchard's cache | -| `orchard.image.platform P --output DIR --kernel-dir BUNDLE --busybox FILE [--receipt FILE]` | the platform layer of installed systems | `DIR/platform-P.tar.gz`, its receipt | +| `orchard.inputs --output FILE [--platform P …] [--device BOARD] [--platform-loader P=FILE] [--platform-tools P=DIR] [--kernel-dir P=DIR …] [--m1n1 FILE] [--pongo FILE] [--tools DIR] [--kernel-tree DIR]` (`ORCHARD_KERNEL_TREE` too) | resolves what the build takes finished | `FILE`: per platform its kernel bundle and boards (board → DTB), the shared m1n1, Pongo and static tools, orchard's revision, and under `produced` how each was got (built: its commands; reused from the cache; downloaded from a nightly: tag, asset, sha256); paths into orchard's cache | +| `orchard.image.platform P --output DIR --kernel-dir BUNDLE --busybox FILE [--receipt FILE] [--root-architecture arm64|arm] [--device BOARD]` | the platform layer of installed systems | `DIR/platform-P.tar.gz`, its receipt | | `orchard.image.platforms_index DIR (--release TAG [--platform P …] \| --inputs FILE \| --kernels DIR --tools DIR --commit SHA)` | what a build takes from orchard: every arm64 platform layer, Pongo, m1n1, `install-tools.tar.gz`, and `platforms.toml` listing them; from a published orchard build (`--release`, which `orchard.image.publish` then links instead of copying), or built here | `DIR/*` | | `orchard.image.root_image ROOT.tar.* --output DIR` | a root as the block image the installer writes; needs Podman, or root on a native build | `DIR/root.blocks.zst`, `DIR/root.map.json`, `DIR/root-image.json` (the manifest's `[root]`) | -| `orchard.image.recovery --dtb FILE … --loader FILE [--logo FILE] --kernel FILE --modules-dir DIR --busybox --dropbear --dropbearkey --wpa-supplicant --wpa-cli --iw FILE --base FILE --base-receipt FILE --root-receipt FILE --output FILE [--verbose]` | a platform's RAM recovery | the image and `IMAGE.json` | +| `orchard.image.recovery --dtb FILE … --loader FILE [--logo FILE] --kernel FILE --modules-dir DIR --busybox --dropbear --dropbearkey --wpa-supplicant --wpa-cli --iw FILE --base FILE --base-receipt FILE --root-receipt FILE --output FILE [--verbose] [--platform P] [--device BOARD]` | a platform's RAM recovery | the image and `IMAGE.json` | | `orchard.installer release DIR [--installer-url URL] [--pongo FILE] [--m1n1 FILE]` | the installer of a release | `DIR/install.sh`, `DIR/orchard-install.pyz` | | `orchard.manifests FILE … [--verify-local]` | checks release manifests | exit status | | `orchard.image.publish --distro NAME --tag TAG --release-dir DIR --output DIR` | the hosted release | `DIR` | @@ -34,3 +34,39 @@ without orchard takes it as an argument. Changing one of these is a contract change: say so in the changelog, and update both distributions in the same push set. + +## Architecture and board selection + +The default input document still selects every ARM64 platform and preserves +its `shared` m1n1/Pongo/tool record. An explicit ARMv7 build selects a board +with `orchard.inputs --device p105`. Each selected platform record includes +`architecture` (`arm64` or `arm`), `image`, `loader` (`kind` and `file`) and +its own `tools` paths. `boards` contains the selected board when `--device` +is given. A document has one root architecture; mixing ARM32 and ARM64 +platforms is refused before producing components. The top-level +`architecture` repeats that target for the distribution's root builder. + +`--platform-loader PLATFORM=FILE` and `--platform-tools PLATFORM=DIR` supply +finished components for offline builds. ARMv7 uses these per-platform inputs +and has an empty `shared` record. A Cascadia payload uses one board's handoff +profile; a platform with several boards requires explicit board selection. + +`orchard.image.platform --root-architecture arm --device p105` checks the +root ISA and writes a layer for that board. Its kernel image comes from the +platform descriptor. Optional `[root] storage_modules` in that descriptor +names the early-root modules; an empty list requires built-in storage. + +`orchard.image.recovery --platform apple-armv7 --device p105` stages an +ARM EABI5 hard-float runtime and invokes the source-built Cascadia loader. +The ELF dependency reader follows ELF32 libraries as well as ELF64, and +recovery refuses libraries or executables from another ISA. Cascadia puts +the external recovery initramfs in a reserved RAM range and passes its +addresses through the DTB. Its image receipt has +`initramfs_encoding = "cascadia-dtb"` and `architecture = "arm"`; the component +records describe contiguous `loader`, `kernel`, `dtb`, and `initramfs` ranges +with padding records where needed. This new handoff awaits physical testing. + +A distribution that accepts a board also declares `selects-device = true` +under `[release]` in `orchard.toml`, alongside `selects-platform = true`. +Orchard then forwards its selected `--device` to that distribution's build +entry. Existing distribution entries retain their previous arguments. diff --git a/docs/platforms-and-builds.md b/docs/platforms-and-builds.md index d302aa1c..321d1553 100644 --- a/docs/platforms-and-builds.md +++ b/docs/platforms-and-builds.md @@ -493,11 +493,21 @@ Project Cascadia's chain, as shaynelegault ran it on a P105: 2. **Patched iBSS** from the user's own IPSW. For kDFU it must be unencrypted: after iOS has booted, the GID key is unavailable. 3. **iBEC with an auto-go hook** at fixed offsets of the iOS 8.4.1 (12H321) - iBEC: when a Recovery-mode upload is complete, iBEC jumps into it. -4. **Staging bundle** uploaded to `0x80000000`: the `linux_boot` trampoline - (paints status, turns MMU and caches off), padding to `0x8000`, then - `zImage` with the DTB appended, entered at `0x80008000`. The root is built - into the kernel: iBEC passes no initrd pointer. + iBEC. The host uploads the complete bound bundle, followed by its exact + checked loader prefix. It leaves at least one second for the Recovery + endpoint to rearm and rechecks the exact device between those uploads. The + second upload resends the exact loader prefix through the complete bound + configuration at offset `0x7f00`, ending at `0x7f44`. Its length stays below + the `0x8000` auto-go threshold and ends with a short USB packet. iBEC then + enters the image stored at `0x80000000`; the kernel, DTB and initramfs remain + from the full upload. Resume requires a completed receipt with this exact + trigger length. Earlier `0x6001` trigger receipts remain as evidence and + are refused for resume; a fresh authorized boot creates a current receipt. +4. **Staging bundle** at `0x80000000`: the `linux_boot` trampoline validates + the bundle, then turns MMU and caches off before relocating the initramfs. + Padding reaches `0x8000`, followed by + `zImage` with the DTB appended and entry at `0x80008000`. Current Orchard + recovery also carries its external initramfs in the checked CB32 layout. 5. **Linux** with a USB gadget; the host takes `10.55.0.1`, then SSH and NFS. In orchard this is an iBoot-chain variant whose payload is the trampoline diff --git a/methods.toml b/methods.toml index 2859f1fd..b04fb83f 100644 --- a/methods.toml +++ b/methods.toml @@ -31,3 +31,11 @@ description = "A patched iBSS, optionally iBEC, entered through an external cust boot_json = true stages = ["iBSS"] loader = "m1n1" + +["ios-local-file"] +# Starts from the running jailbroken iOS app, so it needs no DFU entry tool. +entries = [] +description = "An on-device iOS app schedules wake and enters a pinned iBEC that loads an ARM32 file from System" +boot_json = true +stages = ["iBEC"] +loader = "cascadia" diff --git a/platforms/apple-armv7.toml b/platforms/apple-armv7.toml index 61510ff2..eb21cb70 100644 --- a/platforms/apple-armv7.toml +++ b/platforms/apple-armv7.toml @@ -15,3 +15,17 @@ config = "kernel/configs/apple-armv7/config" defconfig = "orchard_applearmv7_defconfig" # the same config in OrchardLinux/linux branch = "armv7" # its branch in OrchardLinux/linux loader = "cascadia" # the payload format that boots this kernel + +[root] +# H2FMI is built in. The installed NAND backend supplies the early root +# setup; the arm64 platforms' NVMe modules do not apply to this platform. +storage_modules = [] + +[recovery] +# The guarded host transport binds a fresh USB identity to each RAM boot. +identity_source = "host-usb" +usb_network = "ecm" +usb_modules = ["libcomposite", "usb_f_ecm", "usb_f_acm"] +manual_modules = ["apple_sftl"] +# Mapping recovery begins only after serial and authenticated SSH are ready. +storage_activation = "manual" diff --git a/runtime/recovery/device.py b/runtime/recovery/device.py index bb217220..5b0b7414 100755 --- a/runtime/recovery/device.py +++ b/runtime/recovery/device.py @@ -47,6 +47,9 @@ def bootstrap(request): if type(request.get('ecid')) is not int or any(request.get(key) != record[key] for key in ('profile', 'ecid', 'boot_id')): raise ValueError('SSH bootstrap identity differs from this recovery boot') + if record.get('identity_source') in ('host-usb', 'ios-device') and any(type(request.get(key)) is not type(record[key]) or request.get(key) != record[key] for key in + ('identity_source', 'handoff_id', 'template_sha256', 'chip', 'board')): + raise ValueError('SSH bootstrap handoff differs from this recovery attempt') public = request.get('public_key', '') if not isinstance(public, str) or not re.fullmatch(KEY, public): raise ValueError('Expected one Ed25519 public key') diff --git a/runtime/recovery/identity.py b/runtime/recovery/identity.py index 7b622a3d..a32befed 100644 --- a/runtime/recovery/identity.py +++ b/runtime/recovery/identity.py @@ -1,4 +1,4 @@ -"""Publish the live ADT ECID and kernel boot ID before exposing recovery USB.""" +"""Publish live ADT identity or a descriptor-bound boot identity handoff.""" import json import os from pathlib import Path @@ -8,15 +8,95 @@ from adt import validate +try: + from recovery_identity import (HANDOFF_PARAMETER, POLICY_PARAMETER, allows_source, object_json, + parameters, validate_policy) +except ModuleNotFoundError as error: + if error.name != 'recovery_identity': + raise + from orchard.recovery_identity import (HANDOFF_PARAMETER, POLICY_PARAMETER, allows_source, object_json, + parameters, validate_policy) + RUN = Path('/run/orchard') BOOT_ID = Path('/proc/sys/kernel/random/boot_id') MTD = Path('/proc/mtd') DEV = Path('/dev') COMPATIBLE = Path('/proc/device-tree/compatible') +POLICY = Path('/etc/orchard/recovery-identity.json') +CMDLINE = Path('/proc/cmdline') +MAX_IDENTITY_BYTES = 2048 + + +def _bounded(path, maximum, *, protected=False): + try: + before = path.lstat() + if (not stat.S_ISREG(before.st_mode) or before.st_size > maximum or + (protected and (before.st_uid != 0 or before.st_mode & 0o022))): + raise ValueError('Identity input is not a protected regular file') + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, 'rb') as stream: + after = os.fstat(stream.fileno()) + if (not stat.S_ISREG(after.st_mode) or + (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino) or + (protected and (after.st_uid != 0 or after.st_mode & 0o022))): + raise ValueError('Identity input changed while opening') + raw = stream.read(maximum + 1) + except OSError as error: + raise ValueError('Identity input is unavailable: ' + str(path)) from error + if len(raw) > maximum: + raise ValueError('Identity input exceeds its bound') + return raw + + +def _policy(): + if not POLICY.exists() and not POLICY.is_symlink(): + return None + return validate_policy(object_json(_bounded(POLICY, MAX_IDENTITY_BYTES, protected=True))) + + +def _compatible(policy): + raw = _bounded(COMPATIBLE, 4096) + if not raw.endswith(b'\0') or b'\0\0' in raw: + raise ValueError('Live compatible is incomplete') + try: + first = raw.split(b'\0')[0].decode('ascii') + except UnicodeError as error: + raise ValueError('Live compatible is invalid') from error + if first not in policy['compatibles']: + raise ValueError('Live compatible differs from the recovery identity policy') + return first + + +def _handoff_identity(policy): + _compatible(policy) + selected = parameters(_bounded(CMDLINE, MAX_IDENTITY_BYTES)) + if set(selected) != {POLICY_PARAMETER, HANDOFF_PARAMETER}: + raise ValueError('Recovery command line requires its policy and host USB handoff') + if selected[POLICY_PARAMETER] != policy: + raise ValueError('Recovery command-line policy differs from the staged descriptor') + handoff = selected[HANDOFF_PARAMETER] + if not allows_source(policy, handoff['source']): + raise ValueError('Recovery handoff source is absent from the staged policy') + if any(type(handoff[name]) is not type(policy[name]) or handoff[name] != policy[name] + for name in ('profile', 'chip', 'board')): + raise ValueError('Host USB handoff differs from the recovery identity policy') + handoff_id, template = handoff['handoff_id'], handoff['template_sha256'] + boot = uuid.UUID(BOOT_ID.read_text().strip()) + if not boot.int: + raise ValueError('Invalid live boot ID') + result = {'profile': policy['profile'], 'ecid': handoff['ecid'], 'boot_id': str(boot), + 'identity_source': handoff['source'], 'handoff_id': handoff_id, + 'template_sha256': template, 'chip': handoff['chip'], 'board': handoff['board']} + serial(result) + return result def profile(): - """The board's profile from its first devicetree compatible: \"apple,j71b\" -> \"j71b\".""" + """Use the staged descriptor for handoffs, or the existing ADT board spelling.""" + policy = _policy() + if policy is not None: + _compatible(policy) + return policy['profile'] board = COMPATIBLE.read_bytes().split(b'\0')[0].decode() if not re.fullmatch(r'apple,[a-z0-9]+', board): raise ValueError('Not an Apple board: ' + board) @@ -30,8 +110,17 @@ def cached(): info = path.lstat() if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077: raise ValueError('Device identity record is not protected') - result = json.loads(path.read_text()) - if (result.get('profile') != profile() or type(result.get('ecid')) is not int or + policy = _policy() + if policy is not None: + result = object_json(_bounded(path, MAX_IDENTITY_BYTES, protected=True)) + expected = _handoff_identity(policy) + if (set(result) != set(expected) or + any(type(result[name]) is not type(value) or result[name] != value + for name, value in expected.items())): + raise ValueError('Device identity record differs from this boot handoff') + else: + result = json.loads(path.read_text()) + if policy is None and (result.get('profile') != profile() or type(result.get('ecid')) is not int or not 0 < result['ecid'] < 2**64 or result.get('boot_id') != str(uuid.UUID(BOOT_ID.read_text().strip()))): raise ValueError('Device identity record differs from this boot') @@ -41,21 +130,25 @@ def cached(): def initialize(): if cached() is not None: raise ValueError('Device identity was already published') - rows = re.findall(r'^mtd([0-9]+): ([0-9a-fA-F]+) [0-9a-fA-F]+ "adt"$', MTD.read_text(), re.M) - if len(rows) != 1: - raise ValueError('Expected one live ADT MTD entry') - number, size = rows[0][0], int(rows[0][1], 16) - if not 8 <= size <= 16 * 1024**2: - raise ValueError('Live ADT length outside bounds') - fd = os.open(DEV / ('mtd' + number), os.O_RDONLY | os.O_NOFOLLOW) - with os.fdopen(fd, 'rb') as stream: - if not stat.S_ISCHR(os.fstat(stream.fileno()).st_mode): - raise ValueError('Live ADT must be an MTD character device') - blob = stream.read(size) - if len(blob) != size: - raise ValueError('Live ADT read was truncated') - result = {'profile': profile(), 'ecid': validate(blob), - 'boot_id': str(uuid.UUID(BOOT_ID.read_text().strip()))} + policy = _policy() + if policy is not None: + result = _handoff_identity(policy) + else: + rows = re.findall(r'^mtd([0-9]+): ([0-9a-fA-F]+) [0-9a-fA-F]+ "adt"$', MTD.read_text(), re.M) + if len(rows) != 1: + raise ValueError('Expected one live ADT MTD entry') + number, size = rows[0][0], int(rows[0][1], 16) + if not 8 <= size <= 16 * 1024**2: + raise ValueError('Live ADT length outside bounds') + fd = os.open(DEV / ('mtd' + number), os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, 'rb') as stream: + if not stat.S_ISCHR(os.fstat(stream.fileno()).st_mode): + raise ValueError('Live ADT must be an MTD character device') + blob = stream.read(size) + if len(blob) != size: + raise ValueError('Live ADT read was truncated') + result = {'profile': profile(), 'ecid': validate(blob), + 'boot_id': str(uuid.UUID(BOOT_ID.read_text().strip()))} RUN.mkdir(mode=0o700, parents=True, exist_ok=True) if RUN.is_symlink(): raise ValueError('Linked runtime state refused') @@ -68,6 +161,22 @@ def initialize(): return result +def serial(record): + result = f'ECID:{record["ecid"]:016x} BOOT:{record["boot_id"]} BOARD:{record["profile"]}' + source = record.get('identity_source') + if source is None and ('handoff_id' in record or 'template_sha256' in record): + raise ValueError('Recovery serial has no handoff origin') + if source is not None: + origins = {'host-usb': 'h', 'ios-device': 'i'} + nonce = record.get('handoff_id') + if (not isinstance(source, str) or source not in origins or not isinstance(nonce, str) or + not re.fullmatch('[0-9a-f]{32}', nonce) or not int(nonce, 16)): + raise ValueError('Recovery serial has an invalid handoff origin') + result += ' HND:' + record['handoff_id'] + ' ORG:' + origins[source] + if len(result) > 126: + raise ValueError('Recovery identity exceeds the USB serial string limit') + return result + + if __name__ == '__main__': - record = initialize() - print(f'ECID:{record["ecid"]:016x} BOOT:{record["boot_id"]} BOARD:{record["profile"]}') + print(serial(initialize())) diff --git a/runtime/recovery/policy.py b/runtime/recovery/policy.py new file mode 100644 index 00000000..7a753692 --- /dev/null +++ b/runtime/recovery/policy.py @@ -0,0 +1,55 @@ +"""Read the descriptor-selected early recovery policy, with legacy defaults.""" +from pathlib import Path +import re +import stat +import os +import sys + +from recovery_identity import object_json, validate_policy + +CONFIG = Path('/etc/orchard/recovery.json') +IDENTITY = Path('/etc/orchard/recovery-identity.json') + + +def settings(): + if not CONFIG.exists() and not CONFIG.is_symlink(): + return {'usb_network': 'ncm', 'usb_modules': [], 'storage_activation': 'automatic'} + info = CONFIG.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or info.st_size > 2048: + raise ValueError('Recovery policy is not protected') + value = object_json(CONFIG.read_bytes()) + fields = {'schema', 'profile', 'usb_network', 'usb_modules', 'manual_modules', 'storage_activation'} + if (set(value) != fields or type(value['schema']) is not int or value['schema'] != 1 or + value['usb_network'] not in ('ncm', 'ecm') or value['storage_activation'] not in ('manual', 'automatic')): + raise ValueError('Invalid recovery runtime policy') + identity = validate_policy(object_json(IDENTITY.read_bytes())) + if value['profile'] != identity['profile']: + raise ValueError('Recovery policy profiles differ') + for key in ('usb_modules', 'manual_modules'): + names = value[key] + if (not isinstance(names, list) or any(not isinstance(name, str) or not re.fullmatch('[a-z0-9_-]+', name) for name in names) or + len(set(names)) != len(names)): + raise ValueError('Invalid recovery module policy') + normalized = lambda names: {name.replace('-', '_') for name in names} + if normalized(value['usb_modules']) & normalized(value['manual_modules']): + raise ValueError('A manual module cannot be loaded during USB setup') + return value + + +def loadable_modules(value, directory=None): + names = value['usb_modules'] + if not names: + return [] + directory = Path(directory) if directory is not None else Path('/lib/modules') / os.uname().release + builtins = {Path(line).stem.replace('-', '_') for line in + (directory / 'modules.builtin').read_text().splitlines() if line} + return [name for name in names if name.replace('-', '_') not in builtins] + + +if __name__ == '__main__': + value = settings() + selected = {'network': value['usb_network'], 'modules': ' '.join(loadable_modules(value)), + 'storage': value['storage_activation']} + if len(sys.argv) != 2 or sys.argv[1] not in selected: + raise SystemExit('Expected network, modules or storage') + print(selected[sys.argv[1]]) diff --git a/src/orchard/apple/cascadia.py b/src/orchard/apple/cascadia.py new file mode 100644 index 00000000..660bd36b --- /dev/null +++ b/src/orchard/apple/cascadia.py @@ -0,0 +1,394 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""Two-part ARM32 recovery upload through a prepared iBEC autogo chain. + +Entry into a pwned iBSS is an explicit external step. This transport sends +ordinary RAM uploads, records each uncertain outcome before starting it, and +waits for the same ECID's identified Linux recovery. It never sends flash, +saveenv, reset or retry commands. +""" +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import struct +import subprocess +import time +import zlib + +from orchard.integrity import digest +from orchard.contracts import RecoveryHandoff +from orchard import cascadia_payload as payload_image +from orchard.state import BootState, DeviceState, private_directory +from . import iboot32, usb + +CONFIG_AT = 0x7f00 +CONFIG = struct.Struct('<4s16I') +AUTOGO_TRIGGER_LIMIT = 0x8000 +LOADER_CODE_LIMIT = payload_image.LOADER_CODE_LIMIT +LOADER_TRIGGER_BYTES = CONFIG_AT + CONFIG.size +RECOVERY_PACKET_BYTES = 512 +RECOVERY_REARM_SECONDS = 1.0 + + +def tool(): + executable = os.environ.get('ORCHARD_IRECOVERY') or shutil.which('irecovery') + if not executable: + raise ValueError('Install libirecovery on the host before using recovery-autogo') + return str(executable) + + +def query(device, executable): + """Read iRecovery identity for the exact selected ECID; no state change.""" + result = subprocess.run([executable, '-i', hex(device['ecid']), '-q'], + check=False, capture_output=True, text=True, timeout=15) + if result.returncode: + raise ValueError('Selected recovery query failed') + fields = {} + for line in result.stdout.splitlines(): + key, sep, value = line.partition(':') + if sep and key in ('ECID', 'CPID', 'BDID', 'PRODUCT', 'MODE', 'PWND'): + if key in fields: + raise ValueError('Recovery query repeats identity fields') + fields[key] = value.strip() + try: + ecid = int(fields.get('ECID', ''), 0) + chip, board = (int(fields.get(key, ''), 16) for key in ('CPID', 'BDID')) + except ValueError as error: + raise ValueError('Recovery query lacks complete chip identity') from error + if (ecid, chip, board) != (device['ecid'], device['chip'], device['board']): + raise ValueError('Recovery identity differs from the selected device') + mode = fields.get('MODE', '').lower() + if mode not in ('dfu', 'recovery'): + raise ValueError('Expected DFU or iBEC Recovery') + return dict(fields, mode=mode) + + +def bundle(path, expected, selected): + """Validate the complete CB32 bundle before device access.""" + path = Path(path) + if path.is_symlink() or not path.is_file() or not re.fullmatch('[0-9a-f]{64}', expected or ''): + raise ValueError('Expected a regular recovery bundle with a SHA-256 receipt') + if not 0x8000 < path.stat().st_size <= selected['maximum_payload_bytes']: + raise ValueError('Recovery payload exceeds the selected iBEC upload bound') + with path.open('rb') as stream: + data = stream.read(selected['maximum_payload_bytes'] + 1) + if len(data) > selected['maximum_payload_bytes']: + raise ValueError('Recovery payload changed beyond its upload bound') + if hashlib.sha256(data).hexdigest() != expected: + raise ValueError('Recovery payload differs from its manifest') + validate_bundle(data, selected) + checked_recovery_upload_size(len(data)) + return data + + +def validate_bundle(data, selected): + """Check the final bytes after an attempt identity changes the DTB size.""" + if not 0x8000 < len(data) <= selected['maximum_payload_bytes']: + raise ValueError('Recovery payload exceeds the selected iBEC upload bound') + if len(data) < 0x8000 + 0x30 or data[:4] != bytes.fromhex('c0000cf1'): + raise ValueError('Expected an ARM32 recovery payload') + values = CONFIG.unpack_from(data, CONFIG_AT) + (magic, version, size, address, kernel, dtb, dtb_size, initrd, initrd_size, + initrd_address, total, watchdog, l2, ram_first, ram_end, initrd_end, crc) = values + if (magic, version, size) != (b'CB32', 1, CONFIG.size) or crc != zlib.crc32(data[CONFIG_AT:CONFIG_AT + CONFIG.size - 4]): + raise ValueError('Recovery loader configuration is incomplete or corrupt') + if (address != selected['payload_address'] or kernel != 0x8000 or total != len(data) + or not kernel + 0x30 <= dtb < dtb + dtb_size <= initrd < initrd + initrd_size == total + or not ram_first <= address < address + total <= initrd_address < initrd_address + initrd_size <= initrd_end <= ram_end + or watchdog % 4 or l2 % 4 or initrd_address % 4096 + or struct.unpack_from('= AUTOGO_TRIGGER_LIMIT or any(data[LOADER_CODE_LIMIT:CONFIG_AT]) or + len(trigger) != size or + trigger != data[:size]): + raise ValueError('Recovery loader trigger differs from the checked bundle prefix') + checked_recovery_upload_size(len(trigger)) + return trigger + + +def checked_recovery_upload_size(size): + """Reject lengths whose final Recovery ZLP cannot be proved by irecovery.""" + if type(size) is not int or size <= 0 or size % RECOVERY_PACKET_BYTES == 0: + raise ValueError('Recovery upload length has an unobservable trailing USB packet') + return size + + +def recovery_handoff(profile, endpoint): + """Recover the exact completed host handoff before resuming a USB session.""" + identifier = endpoint.get('handoff_id') + if identifier is None: + return None + if not isinstance(identifier, str) or not re.fullmatch('[0-9a-f]{32}', identifier): + raise ValueError('Invalid recovery handoff identity') + source = endpoint.get('identity_source') or 'host-usb' + if source == 'ios-device': + from .ios_launcher import provision_handoff + return provision_handoff(profile, endpoint) + if source != 'host-usb': + raise ValueError('Unsupported recovery identity origin') + scope = BootState(DeviceState(profile, endpoint['ecid']), endpoint['boot_id']) + folder = private_directory(scope.device.root / 'boot-attempts') + path = folder / (identifier + '.json') + if (path.is_symlink() or not path.is_file() or path.stat().st_size > 65536 or + path.stat().st_uid != os.getuid() or path.stat().st_mode & 0o077): + raise ValueError('The host-bound recovery has no private completed attempt receipt') + try: + record = json.loads(path.read_text()) + handoff = RecoveryHandoff(record['handoff_id'], record['template_sha256'], record['chip'], record['board']) + except (OSError, ValueError, TypeError, KeyError) as error: + raise ValueError('Invalid completed recovery handoff receipt') from error + if (not scope.matches(record) or record.get('profile') != profile or + type(record.get('schema')) is not int or record.get('schema') != 1 or + record.get('method') != 'recovery-autogo' or + record.get('phase') != 'complete' or record.get('payload_format') != 'cascadia-cb32' or + record.get('loader_bytes') != LOADER_TRIGGER_BYTES or + not isinstance(record.get('loader_sha256'), str) or + not re.fullmatch('[0-9a-f]{64}', record['loader_sha256']) or + type(record.get('inter_upload_settle_seconds')) is not float or + record['inter_upload_settle_seconds'] != RECOVERY_REARM_SECONDS or + record.get('loader_revalidated') is not True or + record.get('identity_source') != 'host-usb' or handoff.handoff_id != identifier or + endpoint.get('profile') != profile): + raise ValueError('Recovery differs from its completed host handoff receipt') + return handoff + + +def save(path, record): + temporary = path.with_suffix('.pending') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + with os.fdopen(fd, 'w') as stream: + json.dump(record, stream, sort_keys=True) + stream.write('\n') + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def selected(device, executable, *, mode, product): + observed = usb.selected(device['location'], device['ecid'], chip=device['chip'], + board=device['board'], mode=mode) + current = query(device, executable) + if current['mode'] != mode or current.get('PRODUCT') != product: + raise ValueError('Selected Apple boot stage changed') + return observed, current + + +def settle_recovery(device, executable, *, product): + """Wait for iBEC's endpoint to rearm, then recheck the exact boot stage.""" + deadline = time.monotonic() + RECOVERY_REARM_SECONDS + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(remaining) + return selected(device, executable, mode='recovery', product=product) + + +def wait_recovery(device, executable, *, product, seconds=30): + deadline = time.monotonic() + seconds + while True: + try: + observations = [row for row in usb.devices() if row['mode'] in ('dfu', 'recovery', 'pongo', 'linux')] + if len(observations) > 1: + raise RuntimeError('More than one target appeared during the boot transition') + if len(observations) == 1: + current = observations[0] + if current.get('ecid') != device['ecid']: + raise RuntimeError('Another device appeared during the boot transition') + if current['mode'] == 'recovery': + chosen = dict(current, chip=device['chip'], board=device['board']) + selected(chosen, executable, mode='recovery', product=product) + device.update(location=current['location'], mode='recovery') + return + except (OSError, subprocess.SubprocessError, ValueError): + pass # Only observations are polled; uploads are never repeated. + if time.monotonic() >= deadline: + raise RuntimeError('iBEC did not enumerate; inspect the saved attempt before another startup') + time.sleep(0.25) + + +def upload(device, path, executable, *, product, expected, mode='recovery'): + if mode == 'recovery': + checked_recovery_upload_size(Path(path).stat().st_size) + selected(device, executable, mode=mode, product=product) + if digest(path) != expected: + raise ValueError('Private boot snapshot changed before upload') + result = subprocess.run([executable, '-i', hex(device['ecid']), '-f', str(path)], + capture_output=True, timeout=180, check=False) + if (result.returncode or + not re.search(rb'(? 1: + raise RuntimeError('Ambiguous USB identity after the payload upload') + if observations and observations[0].get('ecid') not in (None, device['ecid']): + raise RuntimeError('Another device appeared after the payload upload') + if not observations or observations[0]['mode'] != 'linux': + raise ValueError('Waiting for Linux recovery') + endpoint = usb.recovery(product, profile) + if endpoint['ecid'] != device['ecid']: + raise RuntimeError('Another device appeared after the payload upload') + if endpoint.get('profile') != profile: + raise RuntimeError('Another board appeared after the payload upload') + try: + BootState(DeviceState(profile, endpoint['ecid']), endpoint['boot_id']) + except (KeyError, ValueError) as error: + raise RuntimeError('Linux recovery has no valid device boot identity') from error + if handoff is not None and endpoint.get('handoff_id') != handoff.handoff_id: + raise RuntimeError('Linux recovery differs from the uploaded host handoff') + if handoff is not None and (endpoint.get('identity_source') or 'host-usb') != handoff.identity_source: + raise RuntimeError('Linux recovery differs from the uploaded identity origin') + if handoff is None and endpoint.get('handoff_id') is not None: + raise RuntimeError('Linux recovery advertised an unexpected host handoff') + record.update(phase='complete', boot_id=endpoint['boot_id']) + save(attempt, record) + device.update(endpoint, resumed=True) + return + except (OSError, subprocess.SubprocessError, ValueError): + if time.monotonic() >= deadline: + raise RuntimeError('ARM32 recovery did not enumerate; inspect the saved boot attempt') + time.sleep(0.25) + finally: + # Attempt-private copies are no longer needed after this command + # exits. The outcome receipt retains their exact hashes. + for path in (image_copy, trigger_copy, stage_copy): + path.unlink(missing_ok=True) + + +def boot_ram(output, receipt, *, yes=False): + """Use the generic adapter to boot one checked local ARM32 RAM payload.""" + from orchard import devices, hardware + from orchard.boot import confirm_boot + from orchard.progress import Progress + from orchard.ram import verify_ram + from orchard.state import operation_lock + + output = Path(output) + with operation_lock(): + observed = usb.sole_target() + if observed['mode'] not in ('dfu', 'recovery'): + raise ValueError('Enter a pwned iBSS or iBEC Recovery before an ARM32 RAM boot') + matches = [] + for profile in receipt['boards']: + described = hardware.device(profile) + identity = described['match']['dfu'] + if (observed.get('chip'), observed.get('board')) == (identity['chip'], identity['board']): + matches.append(profile) + if len(matches) != 1: + raise ValueError('This ARM32 payload does not name exactly one matching board') + adapter = devices.load(matches[0]) + device = adapter.discover() + if (device['ecid'], device['location'], device['mode']) != (observed.get('ecid'), observed['location'], observed['mode']): + raise ValueError('Device selection changed before startup confirmation') + print(f'{adapter.NAME}, mode {device["mode"]}.') + if not yes: + confirm_boot() + current = verify_ram(output) + if current != receipt: + raise ValueError('RAM build receipt changed after confirmation') + request = {'boot_method': 'recovery-autogo', + 'assets': {'recovery': {'sha256': receipt['files']['linux.bin']}}} + adapter.boot(device, {'recovery': output / 'linux.bin'}, request, Progress()) + print('The selected ARM32 Linux recovery is ready on USB.') diff --git a/src/orchard/apple/device.py b/src/orchard/apple/device.py index b2a98c13..d28f36fe 100644 --- a/src/orchard/apple/device.py +++ b/src/orchard/apple/device.py @@ -40,8 +40,12 @@ def __init__(self, descriptor: Dict[str, Any]): self.INSTALL = not self.INSTALL_BLOCKERS self.STATUS = support.get('status', 'experimental') self.FEATURES = dict(descriptor.get('features', {})) - self.REBOOT_MESSAGE = ('A fresh recovery boot is required.\n' - f'Put the {self.NAME} in DFU mode, then run: orchard install') + if 'recovery-autogo' in descriptor.get('boot', {}).get('methods', []): + self.REBOOT_MESSAGE = ('A fresh ARM32 recovery boot is required.\n' + f'Enter a pwned iBSS or iBEC Recovery on the {self.NAME}, then run: orchard install') + else: + self.REBOOT_MESSAGE = ('A fresh recovery boot is required.\n' + f'Put the {self.NAME} in DFU mode, then run: orchard install') self.firmware_inputs = partial(firmware.ensure, self.PROFILE) self.firmware_cached = partial(firmware.cached, self.PROFILE) self.prepare_mixin = partial(firmware.mixin, self.PROFILE) @@ -55,8 +59,10 @@ def recovery_endpoint(self) -> Dict[str, Any]: return usb.recovery(self.PRODUCT, self.PROFILE) def connect(self, device: SelectedDevice) -> AbstractContextManager[Connection]: + from .cascadia import recovery_handoff + handoff = recovery_handoff(self.PROFILE, {**device, 'profile': self.PROFILE}) return ssh.session(DeviceState(self.PROFILE, device['ecid']), self.recovery_endpoint, - expected_boot=device.get('boot_id')) + expected_boot=device.get('boot_id'), handoff=handoff) def capture_adt(self, device: Optional[SelectedDevice] = None) -> bytes: # Bring-up and diagnostics only; installs do not need the ADT. @@ -76,6 +82,8 @@ def discover(self) -> SelectedDevice: if device['mode'] == 'linux': endpoint = self.recovery_endpoint() BootState(DeviceState(self.PROFILE, endpoint['ecid']), endpoint['boot_id']) + from .cascadia import recovery_handoff + recovery_handoff(self.PROFILE, endpoint) return cast(SelectedDevice, {**endpoint, 'resumed': True}) if not self.matches_dfu(device) or not device['ecid']: raise ValueError(f'The connected device is not a {self.NAME}') @@ -87,6 +95,16 @@ def boot_inputs(self) -> List[Any]: def boot(self, device: SelectedDevice, assets: BootAssets, manifest: BootRequest, progress: Progress) -> None: + declared = self._descriptor.get('boot', {}).get('methods', []) + method = manifest.get('boot_method') + if method is None: + method = next((name for name in declared if name in hardware.IMPLEMENTED_METHODS), None) + if method not in declared or method not in hardware.IMPLEMENTED_METHODS: + raise ValueError('The requested boot method is unavailable for this board') + if method == 'recovery-autogo': + from . import cascadia + return cascadia.boot(device, assets, manifest, progress, profile=self.PROFILE, + chip=self.CHIP, board=self.BOARD, product=self.PRODUCT) return apple_boot.boot(device, assets, manifest, progress, profile=self.PROFILE, chip=self.CHIP, board=self.BOARD, product=self.PRODUCT, entry=entries.select(self._descriptor)) @@ -97,6 +115,8 @@ def boot_payload(self, device: SelectedDevice, payload: Path, pongo_image: Path, def start_loader(self, device: SelectedDevice, loader: Dict[str, Path], progress: Progress, quiet: bool = False) -> str: + if not any(name == 'checkm8-pongo' for name in self._descriptor.get('boot', {}).get('methods', [])): + raise ValueError('This platform starts an ARM32 staging bundle; a standalone m1n1 storage reader is unavailable') return apple_boot.start_loader(device, loader, progress, profile=self.PROFILE, chip=self.CHIP, board=self.BOARD, product=self.PRODUCT, quiet=quiet, entry=entries.select(self._descriptor)) diff --git a/src/orchard/apple/iboot32.py b/src/orchard/apple/iboot32.py new file mode 100644 index 00000000..7452313a --- /dev/null +++ b/src/orchard/apple/iboot32.py @@ -0,0 +1,169 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""Private IMG3 preparation for a descriptor-pinned ARM32 recovery stage. + +The plaintext packer follows Project Cascadia's img3pack.py. The owner supplies +its already patched raw image; both that image and Apple's original IMG3 +container must match the device boot recipe before any output is created. +""" +import hashlib +import json +import os +from pathlib import Path +import re +import struct + +from orchard import hardware +from orchard.cache import cache_root +from orchard.integrity import digest + +HEADER = struct.Struct('<4sIII4s') +TAG = struct.Struct('<4sII') + + +def tags(blob): + if len(blob) < HEADER.size: + raise ValueError('Truncated IMG3 header') + magic, full, unpacked, signature, ident = HEADER.unpack_from(blob) + if magic != b'3gmI' or full != len(blob) or unpacked != full - HEADER.size or signature > unpacked: + raise ValueError('IMG3 header does not describe the complete file') + entries, at = [], HEADER.size + while at < len(blob): + if at + TAG.size > len(blob): + raise ValueError('Truncated IMG3 tag') + name, length, size = TAG.unpack_from(blob, at) + if length < TAG.size or size > length - TAG.size or at + length > len(blob): + raise ValueError('IMG3 tag exceeds its container') + entries.append((name, blob[at + TAG.size:at + TAG.size + size], blob[at:at + length])) + at += length + if sum(name == b'ATAD' for name, _, _ in entries) != 1: + raise ValueError('IMG3 must contain exactly one DATA tag') + return ident, entries + + +def pack_plain(template, raw): + """Replace DATA, remove KBAG and clear sigCheckArea, preserving other tags.""" + ident, entries = tags(template) + data = bytearray() + for name, _, original in entries: + if name == b'GABK': + continue + if name == b'ATAD': + padded = raw + bytes(-len(raw) % 16) + data.extend(TAG.pack(name, TAG.size + len(padded), len(raw)) + padded) + else: + data.extend(original) + result = HEADER.pack(b'3gmI', HEADER.size + len(data), len(data), 0, ident) + data + _, checked = tags(result) + if any(name == b'GABK' for name, _, _ in checked): + raise ValueError('Plaintext IMG3 retains an encryption tag') + if next(payload for name, payload, _ in checked if name == b'ATAD') != raw: + raise ValueError('Plaintext IMG3 payload differs') + return result + + +def thumb_branch(source, target): + delta = target - (source + 4) + if delta % 2 or not -(1 << 24) <= delta < 1 << 24: + raise ValueError('Thumb branch exceeds its aligned range') + value = delta & ((1 << 25) - 1) + sign, i1, i2 = value >> 24, value >> 23 & 1, value >> 22 & 1 + return struct.pack('> 12 & 0x3ff, + 0x9000 | ((~(i1 ^ sign)) & 1) << 13 | ((~(i2 ^ sign)) & 1) << 11 | value >> 1 & 0x7ff) + + +def recipe(profile): + document = hardware.boot_recipe(profile) + selected = document.get('methods', {}).get('recovery-autogo') + if not isinstance(selected, dict): + raise ValueError('This board has no recovery-autogo recipe') + build = selected.get('build') + candidates = [row for row in document.get('firmware', []) if row.get('ipsw', {}).get('build') == build] + if len(candidates) != 1 or 'iBEC' not in candidates[0].get('inputs', {}): + raise ValueError('Recovery method lacks one exact firmware input') + selected = dict(selected, device=document['device'], template_sha256=candidates[0]['inputs']['iBEC']['sha256']) + prepared = selected.get('prepared_ibec', {}) + if (not isinstance(prepared, dict) or type(prepared.get('bytes')) is not int or prepared['bytes'] <= 0 + or not re.fullmatch('[0-9a-f]{64}', prepared.get('sha256', ''))): + raise ValueError('Recovery method needs a pinned prepared plaintext iBEC') + for field in ('image_base', 'autogo_hook', 'autogo_trampoline', 'payload_address'): + if not isinstance(selected.get(field), str) or not re.fullmatch('0x[0-9a-f]+', selected[field]): + raise ValueError('Recovery recipe needs a checked address: ' + field) + selected[field] = int(selected[field], 16) + if type(selected.get('maximum_payload_bytes')) is not int or not 0x8000 < selected['maximum_payload_bytes'] <= 64 * (1 << 20): + raise ValueError('Recovery method needs a bounded payload size') + return selected + + +def verify_raw(raw, selected): + expected = selected['prepared_ibec'] + if len(raw) != expected['bytes'] or hashlib.sha256(raw).hexdigest() != expected['sha256']: + raise ValueError('Prepared plaintext iBEC differs from the recipe') + base, hook, target = (selected[key] for key in ('image_base', 'autogo_hook', 'autogo_trampoline')) + if not 0 <= hook <= len(raw) - 4 or not 0 <= target < len(raw): + raise ValueError('Autogo hook exceeds the prepared image') + if raw[hook:hook + 4] != thumb_branch(base + hook, base + target): + raise ValueError('Prepared iBEC lacks the checked autogo hook') + if len(raw) < 0x24 or struct.unpack_from('I', data, at)[0] + at += 4 + if token == 1: + name, at = cascadia._cstring(data, at, end) + at = cascadia.aligned(at, 4) + stack.append(name) + elif token == 2: + stack.pop() + elif token == 3: + length, name_at = struct.unpack_from('>II', data, at) + at += 8 + name, _ = cascadia._cstring(names, name_at, len(names)) + if tuple(stack) == wanted_path and name == wanted_name: + matches.append((at, length)) + at = cascadia.aligned(at + length, 4) + elif token == 9: + break + if len(matches) != 1: + raise ValueError('Expected one fixed-slot FDT property') + return matches[0] + + +def prepare_template(payload, profile): + """Retain a public input hash and create one deterministic launch template.""" + if payload[cascadia.CONFIG_OFFSET:cascadia.CONFIG_OFFSET + 4] != b'CB32': + return payload, None # Owner-supplied pre-CB32 standalone boot images. + policy = cascadia.host_identity_policy(payload) + if policy is None: + return payload, None + if not recovery_identity.allows_source(policy, 'ios-device'): + raise ValueError('This public recovery does not allow an iOS device identity') + if (policy['profile'], policy['chip'], policy['board']) != ( + profile['profile'], profile['chip'], profile['board']): + raise ValueError('Recovery identity policy differs from the iOS boot recipe') + values = cascadia.configuration(payload) + dtb = payload[values['dtb_offset']:values['dtb_offset'] + values['dtb_size']] + root, _, _ = cascadia.read_fdt(dtb) + chosen = next(child for child in root['children'] if child['name'] == 'chosen') + original = chosen['properties']['bootargs'][:-1].decode('ascii') + largest = recovery_identity.encode_parameter(recovery_identity.HANDOFF_PARAMETER, + {'schema': 1, 'source': 'ios-device', 'profile': profile['profile'], + 'ecid': (1 << 64) - 1, 'chip': profile['chip'], 'board': profile['board'], + 'handoff_id': 'f' * 32, 'template_sha256': 'f' * 64}) + slot_bytes = len(largest) + bootargs = original + ' ' + ' ' * slot_bytes + if len(bootargs.encode('ascii')) > recovery_identity.MAX_CMDLINE_BYTES: + raise ValueError('The iOS identity slot exceeds the ARM command-line bound') + loader = bytearray(payload[:values['kernel_offset']]) + pristine = dict(values) + for key in ('dtb_offset', 'dtb_size', 'initrd_offset', 'initrd_size', 'image_size', 'crc32'): + pristine[key] = 0 + loader[cascadia.CONFIG_OFFSET:cascadia.CONFIG_OFFSET + cascadia.CONFIG.size] = cascadia.CONFIG.pack( + *(pristine[key] for key in cascadia.CONFIG_FIELDS)) + described = {'schema': 1, **{key: values[key] for key in cascadia.PROFILE_FIELDS - {'schema'}}} + output, _ = cascadia.assemble(bytes(loader), payload[values['kernel_offset']:values['dtb_offset']], + dtb, payload[values['initrd_offset']:], described, bootargs) + framed = cascadia.configuration(output) + tree = output[framed['dtb_offset']:framed['dtb_offset'] + framed['dtb_size']] + value_at, value_bytes = property_span(tree, ('', 'chosen'), 'bootargs') + slot_at = framed['dtb_offset'] + value_at + len(original) + 1 + if value_bytes != len(bootargs) + 1 or output[slot_at:slot_at + slot_bytes] != b' ' * slot_bytes: + raise ValueError('The prepared identity slot differs from the checked FDT') + return output, {'schema': 1, 'identity_source': 'ios-device', + 'public_input_sha256': hashlib.sha256(payload).hexdigest(), + 'template_sha256': hashlib.sha256(output).hexdigest(), + 'template_bytes': len(output), 'slot_offset': slot_at, 'slot_bytes': slot_bytes, + 'profile': profile['profile'], 'chip': profile['chip'], 'board': profile['board']} + + +def elf_word_offset(data, name, base, length): + """Find the generated mutable CRC word inside the checked shim ELF.""" + if len(data) < 52 or data[:7] != b'\x7fELF\x01\x01\x01' or struct.unpack_from(' len(data): + raise ValueError('Invalid shim ELF section table') + sections = [struct.unpack_from('<10I', data, at + index * size) for index in range(count)] + found = [] + for section in sections: + if section[1] != 2: + continue + offset, size, link, entry = section[4], section[5], section[6], section[9] + if entry != 16 or link >= count or size % entry or offset + size > len(data): + raise ValueError('Invalid shim ELF symbols') + strings = sections[link] + if strings[1] != 3 or strings[4] + strings[5] > len(data): + raise ValueError('Invalid shim ELF symbol strings') + names = data[strings[4]:strings[4] + strings[5]] + for cursor in range(offset, offset + size, entry): + named, address, bytes_, info, _, index = struct.unpack_from('= len(names) or names.find(b'\0', named) < 0: + raise ValueError('Invalid shim ELF symbol name') + if names[named:names.find(b'\0', named)] == name.encode('ascii'): + if bytes_ != 4 or info & 15 != 1 or not 0 < index < count or address % 4: + raise ValueError('The shim CRC symbol is not one word') + found.append(address - base) + if len(found) != 1 or not 0 <= found[0] <= length - 4: + raise ValueError('The shim CRC word exceeds its checked padding') + return found[0] diff --git a/src/orchard/apple/ios_kloader.py b/src/orchard/apple/ios_kloader.py new file mode 100644 index 00000000..202b56c1 --- /dev/null +++ b/src/orchard/apple/ios_kloader.py @@ -0,0 +1,11 @@ +"""Identity of the source-built ARMv7 handoff utility. + +Its standalone producer is ``boot/ios-launcher/kloader/build.py``. These +constants bind launcher preparation to that producer's pinned source. +""" + +SOURCE_COMMIT = "a1bbca8a8f2847d9c53070e76e0b5d093bda5993" +SOURCE_SHA256 = "c64f3252118d65b0ab52f10c8eaadc1dd1d7a989352a6e497075f15fb174296a" +HARDENING_PATCH_SHA256 = "efd2b63a85a448701a33c80f2e42323f8e4560d685c5342553f36225595d4a87" +DERIVED_SOURCE_SHA256 = "ddcea7cd58afb437cbe7ac65d09e14f6f7ba98b04814d5e088bf4404c23360fa" +SLEEP_WAIT_SECONDS = 90 diff --git a/src/orchard/apple/ios_launcher.py b/src/orchard/apple/ios_launcher.py new file mode 100644 index 00000000..f90c223e --- /dev/null +++ b/src/orchard/apple/ios_launcher.py @@ -0,0 +1,606 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""Prepare an on-device iOS launcher from a board's exact boot recipe. + +This producer only reads host files and writes a new private output directory. +Device connection, installation and startup stay outside this command. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import plistlib +import re +import struct +import subprocess +from types import SimpleNamespace +import zlib + +from orchard import hardware + +def run(*args): + result = subprocess.run(args,capture_output=True,text=True,timeout=60) + if result.returncode: + raise RuntimeError(result.stdout+result.stderr) + return result.stdout + + +def library(folder,name,install,symbols=(),classes=()): + path = folder/(name+'.tbd') + text = ('--- !tapi-tbd\ntbd-version: 4\ntargets: [ armv7-ios ]\n' + f'install-name: {json.dumps(install)}\nexports:\n - targets: [ armv7-ios ]\n') + if symbols: + text += ' symbols: '+json.dumps(list(symbols))+'\n' + if classes: + text += ' objc-classes: '+json.dumps(list(classes))+'\n' + path.write_text(text+'...\n') + return str(path) + + +def check_entry(path): + """Require a 32-bit ARM executable whose LC_MAIN preserves entry mode.""" + raw = Path(path).read_bytes() + if len(raw) < 28: + raise ValueError('Incomplete Mach-O header') + magic,cpu,subtype,kind,count,size,_ = struct.unpack_from('<7I',raw) + if magic != 0xfeedface or cpu != 12 or subtype & 0xffffff != 9 or kind != 2: + raise ValueError('Expected a thin ARMv7 Mach-O executable') + end = 28 + size + if end > len(raw) or count > size // 8: + raise ValueError('Mach-O load commands exceed the file') + cursor = 28 + text = main = symbols = code = None + for _ in range(count): + if cursor + 8 > end: + raise ValueError('Incomplete Mach-O load command') + command,length = struct.unpack_from(' end: + raise ValueError('Invalid Mach-O load command length') + if command == 1 and length >= 56: + name = raw[cursor+8:cursor+24].rstrip(b'\0') + vmaddr,vmsize,offset,filesize,_,protection,sections = struct.unpack_from('<7I',raw,cursor+24) + if 56 + sections * 68 > length: + raise ValueError('Mach-O sections exceed their segment') + if name == b'__TEXT': + if text is not None or offset != 0 or not protection & 4 or filesize > len(raw): + raise ValueError('Unexpected executable text segment') + text = (vmaddr,vmsize,filesize) + for index in range(sections): + section = cursor + 56 + index * 68 + if raw[section:section+16].rstrip(b'\0') == b'__text': + if code is not None: + raise ValueError('Duplicate executable code section') + code = struct.unpack_from(' len(raw) or stroff + strsize > len(raw): + raise ValueError('Mach-O symbol table exceeds the file') + names = raw[stroff:stroff+strsize] + found = [] + for index in range(nsyms): + name,kind,section,description,value = struct.unpack_from('= len(names) or names.find(b'\0',name) < 0: + raise ValueError('Invalid Mach-O symbol name') + if names[name:names.find(b'\0',name)] == b'_main' and kind & 0x0e == 0x0e: + found.append((description,value)) + if len(found) != 1: + raise ValueError('Expected one defined _main symbol') + description,value = found[0] + thumb = bool(description & 8) + target = text[0] + main + if target != value | int(thumb): + raise ValueError('LC_MAIN address or instruction mode differs from _main') + address = target & ~1 + if not code[0] <= address < code[0] + code[1] or (main & ~1) >= text[2]: + raise ValueError('LC_MAIN does not enter the executable code section') + if not thumb and address % 4: + raise ValueError('ARM entry is not word aligned') + return {'mode':'thumb' if thumb else 'arm','file_offset':main, + 'virtual_address':value,'main_symbol_matches':True} + + + + +def boot_profile(device, *, root=None): + described = hardware.device(device, root) + recipe = hardware.boot_recipe(device, root) + if 'ios-local-file' not in described.get('boot', {}).get('methods', ()): + raise ValueError('The board has no iOS local-file boot method') + profile = dict(recipe.get('methods', {}).get('ios-local-file', {})) + profile['machine'] = recipe['device'] + profile['profile'] = described['device']['profile'] + profile['chip'] = described['match']['dfu']['chip'] + profile['board'] = described['match']['dfu']['board'] + validate_profile(profile) + return profile + + +def recipe_digest(device): + return hashlib.sha256(json.dumps(hardware.boot_recipe(device), sort_keys=True, + separators=(',', ':')).encode('ascii')).hexdigest() + + +def validate_profile(profile): + digest = profile.get('prepared_ibec_sha256', '') + if not isinstance(digest, str) or not re.fullmatch(r'[0-9a-f]{64}', digest): + raise ValueError('The prepared iBEC needs an exact SHA-256') + integer_names = ('prepared_ibec_bytes', 'base', 'hook', 'finish_init', 'shim_start', + 'shim_end', 'mount_bootfs', 'load_file', 'load_address', + 'maximum_image_bytes', 'wake_delay_seconds') + for key in integer_names: + value = profile.get(key) + if type(value) is not int or not 0 < value <= 0xffffffff: + raise ValueError('Invalid boot recipe integer: ' + key) + size, base = profile['prepared_ibec_bytes'], profile['base'] + if not (profile['hook'] + 4 <= size and profile['shim_start'] < profile['shim_end'] <= size + and base + size <= 0x100000000 and profile['hook'] % 2 == 0 + and profile['shim_start'] % 2 == 0 and profile['load_address'] % 4 == 0 + and (profile['hook'] + 4 <= profile['shim_start'] or profile['hook'] >= profile['shim_end']) + and 32768 < profile['maximum_image_bytes'] <= 64 * (1 << 20) + and profile['load_address'] + profile['maximum_image_bytes'] <= base + and 30 <= profile['wake_delay_seconds'] <= 300): + raise ValueError('Boot recipe address or timing bounds differ') + if any(not base <= profile[key] - 1 < base + size or not profile[key] & 1 + or base + profile['shim_start'] <= profile[key] - 1 < base + profile['shim_end'] + for key in ('finish_init', 'mount_bootfs', 'load_file')): + raise ValueError('Native iBEC entry must be Thumb code inside the pinned image') + for key in ('boot_path', 'ibec_path', 'kloader_path', 'helper_path', 'lease_path', + 'app_path', 'ibec_boot_path'): + value = profile.get(key) + if (not isinstance(value, str) or not value.startswith('/') or '\\' in value + or str(PurePosixPath(value)) != value or '..' in PurePosixPath(value).parts + or any(ord(c) < 32 or ord(c) > 126 for c in value)): + raise ValueError('Invalid absolute installation path: ' + key) + if len({profile[key] for key in ('boot_path', 'ibec_path', 'kloader_path', 'helper_path', + 'lease_path', 'app_path')}) != 6: + raise ValueError('Installation paths must be distinct') + for key in ('machine', 'ios_build', 'wake_owner', 'bundle_id', 'boot_device', 'minimum_ios'): + value = profile.get(key) + if not isinstance(value, str) or not re.fullmatch(r'[A-Za-z0-9,._-]{1,80}', value): + raise ValueError('Invalid boot recipe string: ' + key) + if profile.get('identity_source') not in (None, 'ios-device'): + raise ValueError('Unsupported iOS identity source') + if profile.get('identity_source') == 'ios-device': + for key in ('profile', 'ios_model'): + if not isinstance(profile.get(key), str) or not re.fullmatch(r'[A-Za-z0-9,._-]{1,80}', profile[key]): + raise ValueError('Invalid iOS identity recipe: ' + key) + if (type(profile.get('chip')) is not int or not 0 < profile['chip'] < 2**32 or + type(profile.get('board')) is not int or not 0 <= profile['board'] < 2**32): + raise ValueError('Invalid iOS identity chip/board') + compatible = profile.get('ios_registry_compatible') + if (not isinstance(compatible, list) or not compatible or + any(not isinstance(value, str) or not re.fullmatch(r'[A-Za-z0-9,._-]{1,80}', value) for value in compatible) or + len(set(compatible)) != len(compatible) or + sum(len(value) + 1 for value in compatible) > 128): + raise ValueError('Invalid iOS registry compatible list') + for key in ('attempt_boot_path', 'ibec_attempt_boot_path', 'attempt_ibec_path'): + value = profile.get(key) + if (not isinstance(value, str) or not value.startswith('/') or str(PurePosixPath(value)) != value or + '..' in PurePosixPath(value).parts or '\\' in value or any(ord(c) < 32 or ord(c) > 126 for c in value)): + raise ValueError('Invalid fixed iOS attempt path') + if profile['ibec_attempt_boot_path'] != '/boot' + profile['attempt_boot_path']: + raise ValueError('iBEC and iOS attempt paths differ') + if len({profile[key] for key in ('boot_path', 'ibec_path', 'kloader_path', 'helper_path', 'lease_path', + 'app_path', 'attempt_boot_path', 'attempt_ibec_path')}) != 8: + raise ValueError('Attempt paths must be distinct from the installed inputs') + + +def thumb_bl(source, target): + delta = target - (source + 4) + if delta % 2 or not -(1 << 24) <= delta < 1 << 24: + raise ValueError('Thumb branch exceeds its signed aligned range') + value = delta & ((1 << 25) - 1) + sign, i1, i2 = value >> 24, (value >> 23) & 1, (value >> 22) & 1 + return struct.pack('> 12) & 0x3ff, + 0xd000 | ((~(i1 ^ sign)) & 1) << 13 | ((~(i2 ^ sign)) & 1) << 11 + | (value >> 1) & 0x7ff) + + +def patch_ibec(source, stub, profile): + validate_profile(profile) + if (len(source) != profile['prepared_ibec_bytes'] + or hashlib.sha256(source).hexdigest() != profile['prepared_ibec_sha256']): + raise ValueError('Prepared iBEC differs from the exact supported image') + hook, first, end, base = (profile[key] for key in ('hook', 'shim_start', 'shim_end', 'base')) + if source[hook:hook + 4] != thumb_bl(base + hook, profile['finish_init'] & ~1): + raise ValueError('Native finish-init call differs') + if not 0 < len(stub) <= end - first or any(source[first:end]): + raise ValueError('Shim exceeds checked zero text padding') + changed = bytearray(source) + changed[first:first + len(stub)] = stub + changed[hook:hook + 4] = thumb_bl(base + hook, base + first) + return bytes(changed) + + +def launcher_config(profile, title, identity=None, shim=None): + validate_profile(profile) + if not isinstance(title, str) or not 1 <= len(title) <= 40 or any(ord(c) < 32 for c in title): + raise ValueError('Launcher title must contain 1-40 printable characters') + strings = {'TARGET_MACHINE': 'machine', 'TARGET_IOS_BUILD': 'ios_build', 'ROOT_BOOT_PATH': 'boot_path', 'IBEC_PATH': 'ibec_path', + 'KLOADER_PATH': 'kloader_path', 'HELPER_PATH': 'helper_path', 'LEASE_PATH': 'lease_path', + 'WAKE_OWNER': 'wake_owner'} + result = ''.join('#define ' + key + ' ' + json.dumps(profile[value]) + '\n' + for key, value in strings.items()) + result += f"#define WAKE_DELAY_SECONDS {profile['wake_delay_seconds']}\n" + result += '#define LAUNCHER_TITLE @' + json.dumps(title) + '\n' + result += '#define LAUNCHER_START_TITLE @' + json.dumps('Start ' + title) + '\n' + message = f"Starting {title}...\nThe screen may stay black for up to {profile['wake_delay_seconds']} seconds." + result += '#define LAUNCHER_STARTING_TITLE @' + json.dumps(message) + '\n' + if identity is not None: + raw = b'\0'.join(value.encode('ascii') for value in profile['ios_registry_compatible']) + b'\0' + literal = '"' + ''.join(chr(value) if 32 <= value < 127 and chr(value) not in ('"', '\\') + else f'\\{value:03o}' for value in raw) + '"' + for name, value in {'TARGET_PROFILE': profile['profile'], 'TARGET_MODEL': profile['ios_model'], + 'ATTEMPT_BOOT_PATH': profile['attempt_boot_path'], + 'ATTEMPT_IBEC_PATH': profile['attempt_ibec_path']}.items(): + result += '#define ' + name + ' ' + json.dumps(value) + '\n' + result += '#define TARGET_REGISTRY_COMPATIBLE ' + literal + '\n' + for name, value in {'TARGET_CHIP': profile['chip'], 'TARGET_BOARD': profile['board'], + 'TARGET_REGISTRY_COMPATIBLE_BYTES': len(raw), + 'IOS_IDENTITY_SLOT_OFFSET': identity['slot_offset'], + 'IOS_IDENTITY_SLOT_BYTES': identity['slot_bytes'], + 'IOS_IBEC_CRC_OFFSET': shim['mutable_crc_offset'], + 'IOS_TEMPLATE_CRC32': shim['boot_image_crc32']}.items(): + result += f'#define {name} {int(value, 16) if isinstance(value, str) else value}u\n' + return result + + +def build_shim(ibec, image, output, profile, *, clang='clang-19', linker='ld.lld-19', objcopy='llvm-objcopy-19', host_toolchain=False, identity=None): + validate_profile(profile) + raw, payload = Path(ibec).read_bytes(), Path(image).read_bytes() + # Refuse the firmware before invoking a compiler or creating an output. + if (len(raw) != profile['prepared_ibec_bytes'] + or hashlib.sha256(raw).hexdigest() != profile['prepared_ibec_sha256']): + raise ValueError('Prepared iBEC differs from the exact supported image') + if not 32768 < len(payload) < profile['maximum_image_bytes'] or payload[:4] != bytes.fromhex('c0000cf1'): + raise ValueError('Expected an ARM32 staging bundle with its entry instruction at byte zero') + output = Path(output) + output.mkdir(mode=0o700, parents=True, exist_ok=False) + source = Path(hardware.ROOT) / 'boot/ios-launcher' + crc = zlib.crc32(payload) + config = ''.join(f'#define {name} 0x{profile[key]:08x}u\n' for name, key in ( + ('IBEC_FINISH_INIT', 'finish_init'), ('IBEC_MOUNT_BOOT', 'mount_bootfs'), + ('IBEC_LOAD_FILE', 'load_file'), ('LOCAL_BOOT_ADDRESS', 'load_address'))) + config += '#define LOCAL_BOOT_DEVICE ' + json.dumps(profile['boot_device']) + '\n' + config += '#define LOCAL_BOOT_PATH ' + json.dumps(profile['ibec_attempt_boot_path'] if identity is not None else profile['ibec_boot_path']) + '\n' + if identity is not None: + config += '#define LOCAL_BOOT_DYNAMIC_IDENTITY 1\n' + config += f'#define LOCAL_BOOT_BYTES {len(payload)}u\n#define LOCAL_BOOT_CRC32 0x{crc:08x}u\n' + (output / 'local-boot-config.h').write_text(config) + address, end = profile['base'] + profile['shim_start'], profile['base'] + profile['shim_end'] + script = (f'ENTRY(orchard_local_boot)\nSECTIONS {{ . = 0x{address:x}; ' + '.text : { *(.text.entry) *(.text*) *(.rodata*) } ' + '/DISCARD/ : { *(.comment) *(.ARM.attributes) *(.ARM.exidx*) *(.ARM.extab*) } ' + f'ASSERT(. <= 0x{end:x}, "local boot shim exceeds checked padding") }}\n') + (output / 'local.ld').write_text(script) + commands = [ + [clang, '--target=arm-none-eabi', '-march=armv7-a', '-mthumb', '-mfloat-abi=soft', + '-ffreestanding', '-fno-builtin', '-fno-stack-protector', '-fno-unwind-tables', + '-fno-asynchronous-unwind-tables', '-Oz', '-Wall', '-Wextra', '-Werror', + '-I', str(output), '-c', str(source / 'ibec-local.c'), '-o', str(output / 'local.o')], + [linker, '--build-id=none', '-T', str(output / 'local.ld'), str(output / 'local.o'), + '-o', str(output / 'local.elf')], + [objcopy, '-O', 'binary', str(output / 'local.elf'), str(output / 'local.bin')]] + if host_toolchain: + for command in commands: + subprocess.run(command, check=True, timeout=60) + else: + from orchard import buildenv + image = 'localhost/orchard-hardware-kernel-toolchain:bookworm' + if not buildenv.native() and subprocess.run(['podman', 'image', 'exists', image]).returncode: + buildenv.build(image, hardware.ROOT / 'kernel/Containerfile', hardware.ROOT / 'kernel') + # The cross compiler runs on the builder host's ISA. Only the small + # generated configuration and checked source enter this container. + buildenv.run(image, ['bash', '/source/shim-build'], + [(source, '/source', True), (output, '/output', False)], + env={'ORCHARD_CLANG': clang, 'ORCHARD_LINKER': linker, + 'ORCHARD_OBJCOPY': objcopy}) + stub = (output / 'local.bin').read_bytes() + changed = patch_ibec(raw, stub, profile) + (output / 'ibec-local.raw').write_bytes(changed) + report = {'schema': 1, 'device_operations': 0, 'source_ibec_sha256': hashlib.sha256(raw).hexdigest(), + 'output_sha256': hashlib.sha256(changed).hexdigest(), 'shim_bytes': len(stub), + 'boot_image_sha256': hashlib.sha256(payload).hexdigest(), 'boot_image_bytes': len(payload), + 'boot_image_crc32': f'{crc:08x}', 'source_files': {'ibec-local.c': hashlib.sha256((source / 'ibec-local.c').read_bytes()).hexdigest(), + 'producer': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}, + 'on_device_boot_qualified': False} + if identity is not None: + from .ios_identity import elf_word_offset + relative = elf_word_offset((output / 'local.elf').read_bytes(), 'orchard_local_boot_crc32', address, len(stub)) + offset = profile['shim_start'] + relative + if struct.unpack_from(' ios_kloader.SLEEP_WAIT_SECONDS - 15: + raise ValueError('The configured wake is later than this kloader build can wait') + output = Path(output) + if output.is_symlink(): + raise ValueError('Linked output directory refused') + output = output.resolve() + if output.exists() or output.is_symlink(): + raise ValueError('Choose a new output directory') + # Validate assets before leaving a partial build directory behind. + raw, bundle = Path(ibec).read_bytes(), Path(boot_image).read_bytes() + if (len(raw) != profile['prepared_ibec_bytes'] + or hashlib.sha256(raw).hexdigest() != profile['prepared_ibec_sha256']): + raise ValueError('Prepared iBEC differs from the exact supported image') + if not 32768 < len(bundle) < profile['maximum_image_bytes'] or bundle[:4] != bytes.fromhex('c0000cf1'): + raise ValueError('Expected the ARM32 staging bundle') + from .ios_identity import prepare_template + template, identity = prepare_template(bundle, profile) + if len(template) >= profile['maximum_image_bytes']: + raise ValueError('The fixed-slot launch template exceeds its upload bound') + output.mkdir(mode=0o700, parents=True, exist_ok=False) + selected_image = Path(boot_image) + if identity is not None: + selected_image = output / 'launch-template.bin' + selected_image.write_bytes(template) + selected_image.chmod(0o600) + shim = output / 'shim' + shim_result = build_shim(ibec, selected_image, shim, profile, clang=clang, linker=linker, objcopy=objcopy, host_toolchain=host_toolchain, identity=identity) + native = output / 'native' + native_result = build_launcher(selected_image, shim / 'ibec-local.raw', kloader, native, profile, title, + identity=identity, shim=shim_result) + files = [] + stage = output / 'files' + def stage_file(source, destination, mode): + source = Path(source) + if source.is_symlink() or not source.is_file(): + raise ValueError('Every installation asset must be a regular file') + target = stage / destination.lstrip('/') + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + data = source.read_bytes() + with target.open('xb') as stream: + stream.write(data) + target.chmod(0o600) + files.append({'source': str(target.relative_to(output)), 'destination': destination, + 'uid': 0, 'gid': 0, 'mode': mode, 'bytes': len(data), + 'sha256': hashlib.sha256(data).hexdigest()}) + stage_file(selected_image, profile['boot_path'], '0644') + stage_file(shim / 'ibec-local.raw', profile['ibec_path'], '0644') + stage_file(kloader, profile['kloader_path'], '0755') + stage_file(native / 'orchard-ios-start', profile['helper_path'], '4755') + for item in sorted((native / 'Orchard.app').rglob('*')): + if item.is_file() or item.is_symlink(): + relative = item.relative_to(native / 'Orchard.app').as_posix() + stage_file(item, profile['app_path'] + '/' + relative, + '0755' if relative == 'Orchard' else '0644') + report = {'schema': 1, 'kind': 'ios-local-file-launcher', 'device': device, + 'machine': profile['machine'], 'device_operations': 0, 'files': files, + 'shim': shim_result, 'native': native_result, 'kloader': kloader_result, + 'on_device_startup_qualified': False} + if identity is not None: + report['identity'] = identity + report['boot_recipe_sha256'] = recipe_digest(device) + (output / 'installation.json').write_text(json.dumps(report, indent=2) + '\n') + (output / 'installation.json').chmod(0o600) + return report + + +def provision_handoff(device, endpoint, receipt=None): + """Pin an offline start to an explicitly selected private provision output.""" + from orchard.contracts import RecoveryHandoff + from orchard import cascadia_payload as cascadia + from orchard import recovery_identity + from orchard.state import BootState, DeviceState + + supplied = receipt or os.environ.get('ORCHARD_IOS_LAUNCHER') + if not supplied: + raise ValueError('Select the private iOS launcher output with ORCHARD_IOS_LAUNCHER before attaching') + path = Path(supplied) + if path.is_dir(): + path = path / 'installation.json' + folder = path.parent.resolve() + if (path.is_symlink() or not path.is_file() or path.stat().st_size > 1 << 20 or + path.stat().st_uid != os.getuid() or path.stat().st_mode & 0o077 or + path.parent.is_symlink() or folder.stat().st_uid != os.getuid() or folder.stat().st_mode & 0o077): + raise ValueError('Expected a private regular iOS provision receipt') + try: + record = json.loads(path.read_text()) + identity = record['identity'] + profile = boot_profile(device) + if (type(record['schema']) is not int or record['schema'] != 1 or record['kind'] != 'ios-local-file-launcher' or + record['device'] != device or record['machine'] != profile['machine'] or + record['boot_recipe_sha256'] != recipe_digest(device) or + type(identity['schema']) is not int or identity['schema'] != 1 or identity['identity_source'] != 'ios-device' or + (identity['profile'], identity['chip'], identity['board']) != + (device, profile['chip'], profile['board']) or + not re.fullmatch('[0-9a-f]{64}', identity['public_input_sha256'])): + raise ValueError('iOS provision identity differs from the selected recipe') + checked = {} + for asset in record['files']: + source = asset['source'] + if (not isinstance(source, str) or str(PurePosixPath(source)) != source or + PurePosixPath(source).is_absolute() or '..' in PurePosixPath(source).parts): + raise ValueError('iOS provision asset escapes its private output') + candidate = folder / source + if (candidate.is_symlink() or not candidate.is_file() or + any(parent.is_symlink() for parent in candidate.parents if parent != folder and folder in parent.parents) or + candidate.stat().st_uid != os.getuid() or candidate.stat().st_mode & 0o077 or + not 0 < candidate.stat().st_size <= profile['maximum_image_bytes'] or + candidate.stat().st_size != asset['bytes']): + raise ValueError('iOS provision asset is not a private checked file') + data = candidate.read_bytes() + if hashlib.sha256(data).hexdigest() != asset['sha256'] or asset['destination'] in checked: + raise ValueError('iOS provision asset differs from its receipt') + checked[asset['destination']] = (asset, data) + if not {profile[key] for key in ('boot_path', 'ibec_path', 'kloader_path', 'helper_path')} <= checked.keys(): + raise ValueError('The iOS provision inventory lacks a required boot asset') + asset, template = checked[profile['boot_path']] + if (asset['sha256'] != identity['template_sha256'] or len(template) != identity['template_bytes'] or + type(identity['slot_offset']) is not int or type(identity['slot_bytes']) is not int or + not 0 < identity['slot_bytes'] <= 1023 or + not 0 <= identity['slot_offset'] <= len(template) - identity['slot_bytes'] or + template[identity['slot_offset']:identity['slot_offset'] + identity['slot_bytes']] != b' ' * identity['slot_bytes']): + raise ValueError('iOS provision template differs from its fixed identity slot') + policy = cascadia.host_identity_policy(template) + if (policy is None or not recovery_identity.allows_source(policy, 'ios-device') or + (policy['profile'], policy['chip'], policy['board']) != (device, profile['chip'], profile['board'])): + raise ValueError('The selected iOS template lacks its public identity policy') + if endpoint.get('identity_source') != 'ios-device' or endpoint.get('profile') != device: + raise ValueError('The recovery has no explicit iOS device origin') + BootState(DeviceState(device, endpoint['ecid']), endpoint['boot_id']) + return RecoveryHandoff(endpoint['handoff_id'], identity['template_sha256'], + profile['chip'], profile['board'], 'ios-device') + except (OSError, KeyError, TypeError, ValueError) as error: + raise ValueError('The selected iOS provision receipt does not match this recovery: ' + str(error)) from error + + +def add_arguments(parser): + parser.add_argument('--device', required=True, help='board with an ios-local-file recipe') + for name in ('ibec', 'boot-image', 'kloader', 'kloader-receipt', 'output'): + parser.add_argument('--' + name, type=Path, required=True) + parser.add_argument('--title', default='Orchard') + parser.add_argument('--clang', default='clang-19') + parser.add_argument('--linker', default='ld.lld-19') + parser.add_argument('--objcopy', default='llvm-objcopy-19') + parser.add_argument('--host-toolchain', action='store_true', help='use local LLVM executables for the freestanding shim') + + +def from_arguments(args): + return prepare(args.device, args.ibec, args.boot_image, args.kloader, args.kloader_receipt, + args.output, title=args.title, clang=args.clang, linker=args.linker, objcopy=args.objcopy, + host_toolchain=args.host_toolchain) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + add_arguments(parser) + args = parser.parse_args() + print(json.dumps(from_arguments(args), indent=2)) + + +if __name__ == '__main__': + main() diff --git a/src/orchard/apple/linux_usb.py b/src/orchard/apple/linux_usb.py index 36c8ea0c..d8f2b123 100644 --- a/src/orchard/apple/linux_usb.py +++ b/src/orchard/apple/linux_usb.py @@ -3,8 +3,10 @@ import re import uuid +from .usb_identity import handoff_id, identity_source + # 0x4142: Orchard Recovery; 0x4145: an installed system (orchard's initrd). -MODES = {0x1227: 'dfu', 0x4141: 'pongo', 0x4142: 'linux', 0x4144: 'ram', 0x4145: 'system'} +MODES = {0x1280: 'recovery', 0x1281: 'recovery', 0x1282: 'recovery', 0x1283: 'recovery', 0x1227: 'dfu', 0x4141: 'pongo', 0x4142: 'linux', 0x4144: 'ram', 0x4145: 'system'} def devices(root=Path('/sys/bus/usb/devices')): @@ -30,9 +32,11 @@ def devices(root=Path('/sys/bus/usb/devices')): found.append(dict(location=node.name, mode=MODES.get(pid, 'other'), ecid=int(ecid.group(1), 16) if ecid else None, boot_id=str(uuid.UUID(boot.group(1))) if boot else None, + handoff_id=handoff_id(serial), + identity_source=identity_source(serial), chip=int(chip.group(1), 16) if chip else None, board=int(board.group(1), 16) if board else None, - pwned='YOLO:checkra1n' in serial, profile=profile.group(1) if profile else None, product=product, + pwned='YOLO:checkra1n' in serial or bool(re.search(r'\bPWND:\[(?:checkm8|ipwnder|iPwnder32)\]', serial)), profile=profile.group(1) if profile else None, product=product, serial_port=next(iter(ports), None))) return found diff --git a/src/orchard/apple/usb.py b/src/orchard/apple/usb.py index 76791e13..154540d2 100644 --- a/src/orchard/apple/usb.py +++ b/src/orchard/apple/usb.py @@ -6,12 +6,13 @@ import uuid from .linux_usb import devices as linux_devices, m1n1_ports as linux_m1n1_ports +from .usb_identity import handoff_id, identity_source from orchard.state import EndpointUnavailable # 0x4144: a RAM payload's USB session (orchard/usb-session). # 0x4142: Orchard Recovery; 0x4145: an installed system (orchard's initrd); # 0x4146: orchard's initrd with the install add-on (install_direct.py). -MODES = {0x1227: 'dfu', 0x4141: 'pongo', 0x4142: 'linux', 0x4144: 'ram', 0x4145: 'system', 0x4146: 'install'} +MODES = {0x1280: 'recovery', 0x1281: 'recovery', 0x1282: 'recovery', 0x1283: 'recovery', 0x1227: 'dfu', 0x4141: 'pongo', 0x4142: 'linux', 0x4144: 'ram', 0x4145: 'system', 0x4146: 'install'} class DeviceError(ValueError): @@ -54,10 +55,13 @@ def devices(): found.append({'location': location, 'mode': MODES.get(node.get('idProduct'), 'other'), 'ecid': int(fields[0].group(1), 16) if fields[0] else None, 'boot_id': str(uuid.UUID(boot.group(1))) if boot else None, + 'handoff_id': handoff_id(serial), + 'identity_source': identity_source(serial), 'chip': int(fields[1].group(1), 16) if fields[1] else None, 'board': int(fields[2].group(1), 16) if fields[2] else None, 'profile': profile.group(1) if profile else None, 'product': node.get('USB Product Name'), + 'pwned': bool(re.search(r'\bPWND:\[(?:checkm8|ipwnder|iPwnder32)\]', serial)), 'serial_port': ports[0] if ports else None}) return found @@ -94,13 +98,13 @@ def m1n1_ports(): def sole_target(): found = devices() - targets = [item for item in found if item['mode'] in ('dfu', 'pongo', 'linux')] + targets = [item for item in found if item['mode'] in ('dfu', 'recovery', 'pongo', 'linux')] if not targets: if any(item['mode'] == 'system' for item in found): raise EndpointUnavailable('The device is running its installed system; put it in DFU mode first') raise EndpointUnavailable('Selected USB target is not connected') if len(targets) != 1: - raise ValueError('Connect exactly one target in DFU, Pongo or Linux; device selection is not supported') + raise ValueError('Connect exactly one target in DFU, Recovery, Pongo or Linux; device selection is not supported') return targets[0] @@ -124,6 +128,6 @@ def selected(location, ecid, *, chip, board, mode='pongo'): hint = ('. Another palera1n/checkra1n may have exploited it; stop any running, ' 'force-restart the device and enter DFU again') raise DeviceError(f'Expected {mode} at the selected port; found {device["mode"]}{hint}') - if mode in ('dfu', 'pongo') and (device['ecid'], device['chip'], device['board']) != (ecid, chip, board): + if mode in ('dfu', 'recovery', 'pongo') and (device['ecid'], device['chip'], device['board']) != (ecid, chip, board): raise DeviceError('Wrong device identity at the selected port') return device diff --git a/src/orchard/apple/usb_identity.py b/src/orchard/apple/usb_identity.py new file mode 100644 index 00000000..fb3eb9cc --- /dev/null +++ b/src/orchard/apple/usb_identity.py @@ -0,0 +1,25 @@ +"""Validate the optional attempt identity carried by Orchard USB descriptors.""" +import re + + +def handoff_id(serial: str) -> str | None: + """Read one complete HND field; legacy descriptors omit the field.""" + fields = re.findall(r'\bHND\b', serial) + if not fields: + return None + value = re.search(r'(? str | None: + """Keep older host handoffs readable; iOS handoffs always name their origin.""" + nonce = handoff_id(serial) + fields = re.findall(r'\bORG\b', serial) + if not fields: + return 'host-usb' if nonce is not None else None + value = re.search(r'(?10I') + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def aligned(value, size=8): + return (value + size - 1) & -size + + +def profile(path): + return validate_profile(tomllib.loads(Path(path).read_text())) + + +def validate_profile(result): + if set(result) != PROFILE_FIELDS or result['schema'] != 1: + raise ValueError('Expected the schema-1 Cascadia handoff profile') + if any(type(value) is not int or not 0 <= value <= 0xffffffff for value in result.values()): + raise ValueError('Handoff addresses must be unsigned 32-bit integers') + first, end = result['ram_start'], result['ram_end'] + base, target, limit = result['load_address'], result['initrd_address'], result['initrd_limit'] + if (result['kernel_offset'] != 0x8000 or base != first or base % 0x8000000 or + not first <= base < base + 0x8000 < target < limit <= end or target % 4096): + raise ValueError('Handoff memory ranges overlap or have invalid alignment') + if any(result[key] % 4 for key in ('watchdog_address', 'l2_address')): + raise ValueError('Handoff register addresses must be word aligned') + return result + + +def configuration(raw): + if len(raw) < CONFIG_OFFSET + CONFIG.size: + raise ValueError('Loader lacks its configuration record') + result = dict(zip(CONFIG_FIELDS, CONFIG.unpack_from(raw, CONFIG_OFFSET))) + if (result['magic'], result['version'], result['header_size']) != (b'CB32', 1, CONFIG.size): + raise ValueError('Unknown loader configuration format') + return result + + +def verify_loader(binary, described, elf=None): + """Reject metadata before ARM code and a loader built for another profile.""" + if (not CONFIG_OFFSET + CONFIG.size <= len(binary) <= 0x8000 or + binary[:4] != bytes.fromhex('c0000cf1')): + raise ValueError('Expected the ARM CPSID instruction at loader byte zero') + config = configuration(binary) + for field in PROFILE_FIELDS - {'schema'}: + if config[field] != described[field]: + raise ValueError('Loader configuration differs from the handoff profile: ' + field) + if any(config[field] for field in ('dtb_offset', 'dtb_size', 'initrd_offset', 'initrd_size', + 'image_size', 'crc32')): + raise ValueError('Expected a pristine loader before payload assembly') + if elf is None: + return + if len(elf) < 52 or elf[:7] != b'\x7fELF\x01\x01\x01': + raise ValueError('Expected little-endian ELF32') + if struct.unpack_from(' len(elf): + raise ValueError('Invalid loader section table') + allocated = [] + for index in range(count): + _, kind, flags, address, source, length, *_ = struct.unpack_from('<10I', elf, offset + index * size) + if not flags & 2 or not length: + continue + relative = address - described['load_address'] + if (kind == 8 or relative < 0 or source + length > len(elf) or + relative + length > len(binary) or binary[relative:relative + length] != elf[source:source + length]): + raise ValueError('ELF allocated section differs from the raw loader') + if relative != CONFIG_OFFSET and relative + length > LOADER_CODE_LIMIT: + raise ValueError('ELF section overlaps the loader stack or configuration') + allocated.append((relative, flags, length)) + if not allocated or min(allocated)[0] != 0 or not min(allocated)[1] & 4: + raise ValueError('Loader starts with non-executable metadata') + if not any(start == CONFIG_OFFSET and length == CONFIG.size for start, _, length in allocated): + raise ValueError('ELF lacks the configuration section') + + +def _cstring(data, offset, end): + if not 0 <= offset < end: + raise ValueError('FDT string offset exceeds its block') + stop = data.find(b'\0', offset, end) + if stop < 0: + raise ValueError('Unterminated FDT string') + try: + return data[offset:stop].decode('ascii'), stop + 1 + except UnicodeDecodeError as error: + raise ValueError('FDT names must be ASCII') from error + + +def read_fdt(data): + """Return a checked tree and reservation list, preserving every property.""" + if len(data) < FDT_HEADER.size: + raise ValueError('Truncated FDT header') + magic, total, tree_at, strings_at, reserve_at, version, last, cpu, strings_size, tree_size = FDT_HEADER.unpack_from(data) + if magic != 0xd00dfeed or total != len(data) or version < 17 or last > 17: + raise ValueError('Unsupported FDT header') + if (tree_at % 4 or reserve_at % 8 or + tree_size <= 0 or any(start < 40 or size < 0 or start + size > total + for start, size in ((tree_at, tree_size), (strings_at, strings_size))) or + not 40 <= reserve_at <= total - 16): + raise ValueError('Invalid FDT block bounds') + intervals = [(tree_at, tree_at + tree_size), (strings_at, strings_at + strings_size)] + if max(intervals[0][0], intervals[1][0]) < min(intervals[0][1], intervals[1][1]): + raise ValueError('FDT blocks overlap') + reservations, at = [], reserve_at + while True: + if at + 16 > total or any(max(at, start) < min(at + 16, end) for start, end in intervals): + raise ValueError('FDT reservation map is truncated or overlaps another block') + address, length = struct.unpack_from('>QQ', data, at) + at += 16 + if address == length == 0: + break + if not length or address + length > 1 << 64: + raise ValueError('Invalid FDT reservation') + reservations.append((address, length)) + names = data[strings_at:strings_at + strings_size] + at, end, stack, root, finished = tree_at, tree_at + tree_size, [], None, False + while at < end: + if at + 4 > end: + raise ValueError('Truncated FDT token') + token = struct.unpack_from('>I', data, at)[0] + at += 4 + if token == 1: + name, at = _cstring(data, at, end) + at = aligned(at, 4) + if at > end or len(stack) >= 128 or '/' in name: + raise ValueError('Invalid FDT node') + node = {'name': name, 'properties': {}, 'children': []} + if stack: + if not name or any(child['name'] == name for child in stack[-1]['children']): + raise ValueError('Duplicate or empty FDT child node') + stack[-1]['children'].append(node) + elif root is not None or name: + raise ValueError('Expected exactly one unnamed FDT root') + else: + root = node + stack.append(node) + elif token == 2: + if not stack: + raise ValueError('Unbalanced FDT end-node') + stack.pop() + elif token == 3: + if not stack or at + 8 > end or stack[-1]['children']: + raise ValueError('Invalid FDT property position') + length, name_at = struct.unpack_from('>II', data, at) + at += 8 + name, _ = _cstring(names, name_at, len(names)) + if not name or name in stack[-1]['properties'] or at + length > end: + raise ValueError('Invalid or repeated FDT property') + stack[-1]['properties'][name] = data[at:at + length] + at = aligned(at + length, 4) + elif token == 4: + continue + elif token == 9: + if stack or root is None or at != end: + raise ValueError('FDT ends before its nodes or has trailing tokens') + finished = True + break + else: + raise ValueError('Unknown FDT token') + if not finished: + raise ValueError('FDT lacks its end token') + return root, reservations, cpu + + +def check_compatible(data, expected): + """Bind a board profile to the supplied DTB before payload assembly.""" + tree, _, _ = read_fdt(data) + value = tree['properties'].get('compatible', b'') + if not value or not value.endswith(b'\0'): + raise ValueError('Cascadia DTB has no complete compatible list') + try: + observed = set(value[:-1].decode('ascii').split('\0')) + except UnicodeDecodeError as error: + raise ValueError('Cascadia DTB compatible is invalid') from error + if not observed.intersection(expected): + raise ValueError('Cascadia DTB compatible differs from the selected board') + + +def write_fdt(root, reservations=(), cpu=0): + names, offsets, tree = bytearray(), {}, bytearray() + def integer(value): + tree.extend(struct.pack('>I', value)) + def node(item): + integer(1) + tree.extend(item['name'].encode('ascii') + b'\0') + tree.extend(b'\0' * (-len(tree) % 4)) + for name, value in item['properties'].items(): + if name not in offsets: + offsets[name] = len(names) + names.extend(name.encode('ascii') + b'\0') + integer(3) + tree.extend(struct.pack('>II', len(value), offsets[name])) + tree.extend(value) + tree.extend(b'\0' * (-len(tree) % 4)) + for child in item['children']: + node(child) + integer(2) + node(root) + integer(9) + # A valid tree may have no properties; retain a one-byte strings block. + if not names: + names.append(0) + reserve = b''.join(struct.pack('>QQ', *row) for row in reservations) + b'\0' * 16 + tree_at = 40 + len(reserve) + strings_at = tree_at + len(tree) + return (FDT_HEADER.pack(0xd00dfeed, strings_at + len(names), tree_at, strings_at, + 40, 17, 16, cpu, len(names), len(tree)) + reserve + tree + names) + + +def chosen(data, bootargs, start, size): + if not isinstance(bootargs, str) or any(char in bootargs for char in ('\0', '\n', '\r')): + raise ValueError('Kernel arguments must be one line') + encoded = bootargs.encode('utf-8') + if len(encoded) > identity_protocol.MAX_CMDLINE_BYTES: + raise ValueError('Kernel arguments exceed the ARM command-line bound') + root, reservations, cpu = read_fdt(data) + cell_bytes = root['properties'].get('#address-cells', struct.pack('>I', 2)) + if len(cell_bytes) != 4 or (cells := struct.unpack('>I', cell_bytes)[0]) not in (1, 2): + raise ValueError('Expected one or two root address cells') + selected = next((item for item in root['children'] if item['name'] == 'chosen'), None) + if selected is None: + selected = {'name': 'chosen', 'properties': {}, 'children': []} + root['children'].append(selected) + properties = selected['properties'] + width = cells * 4 + previous_start = properties.get('linux,initrd-start', b'') + previous_end = properties.get('linux,initrd-end', b'') + if len(previous_start) == len(previous_end) == width: + old_start, old_end = int.from_bytes(previous_start, 'big'), int.from_bytes(previous_end, 'big') + reservations = [row for row in reservations if row != (old_start, old_end - old_start)] + if any(max(start, address) < min(start + size, address + length) for address, length in reservations): + raise ValueError('Initramfs destination overlaps reserved memory') + properties['bootargs'] = encoded + b'\0' + properties['linux,initrd-start'] = start.to_bytes(width, 'big') + properties['linux,initrd-end'] = (start + size).to_bytes(width, 'big') + reservations.append((start, size)) + return write_fdt(root, reservations, cpu) + + +def _policy_compatible(data, policy): + check_compatible(data, policy['compatibles']) + tree, _, _ = read_fdt(data) + raw = tree['properties']['compatible'] + if not raw.endswith(b'\0') or b'\0\0' in raw: + raise ValueError('Recovery DTB compatible list is incomplete') + if raw.split(b'\0')[0].decode('ascii') not in policy['compatibles']: + raise ValueError('First DTB compatible differs from the recovery identity policy') + + +def decompression_bound(kernel, dtb_size, described): + """Conservatively bound the v6.12 ARM decompressor's relocated workspace. + + The zImage table describes its inflated image and BSS. The low upload + address overlaps the output, so reserve room for a relocated compressed + image as well as DTB growth, stack and malloc space above that output. + Unknown table formats are refused before a payload is written. + """ + if len(kernel) < 0x3c or len(kernel) % 8: + raise ValueError('zImage must end on an eight-byte boundary for its appended DTB') + if dtb_size > 0x100000: + raise ValueError('DTB exceeds the ARM decompressor workspace bound') + if struct.unpack_from(' len(kernel): + raise ValueError('zImage size table exceeds the kernel') + count, magic, size_at, bss_size, text_offset, malloc_size, end = struct.unpack_from('<7I', kernel, table_at) + if count != 6 or magic != 0x5a534c4b or end or size_at < 0x3c or size_at + 4 > len(kernel): + raise ValueError('Unsupported zImage size table') + inflated = struct.unpack_from(' 0x100000: + raise ValueError('Invalid zImage decompression parameters') + # head.S allows a DTB to grow by 50%, bounded at 1 MiB. Include the + # decompressor relocation/code/stack alignment with an extra 128 KiB. + dtb_workspace = min(max(aligned(dtb_size + dtb_size // 2), 0x8000), 0x100000) + bound = (described['ram_start'] + text_offset + inflated + bss_size + + aligned(len(kernel) + dtb_workspace + malloc_size + 0x20000, 4096)) + if bound > described['initrd_address']: + raise ValueError('Kernel decompression workspace overlaps the initramfs destination') + return bound + + +def assemble(loader, kernel, dtb, initramfs, described, bootargs, *, elf=None, identity_policy=None): + validate_profile(described) + verify_loader(loader, described, elf) + if (len(kernel) < 0x30 or struct.unpack_from(' described['initrd_limit'] - described['initrd_address']: + raise ValueError('Initramfs exceeds its reserved destination') + if identity_policy is not None: + policy = identity_protocol.validate_policy(identity_policy) + if identity_protocol.parameters(bootargs): + raise ValueError('Recovery template already contains identity parameters') + _policy_compatible(dtb, policy) + bootargs = bootargs + ' ' + identity_protocol.encode_parameter(identity_protocol.POLICY_PARAMETER, policy) + tree = chosen(dtb, bootargs, described['initrd_address'], len(initramfs)) + expanded_end = decompression_bound(kernel, len(tree), described) + kernel_at = described['kernel_offset'] + dtb_at = kernel_at + len(kernel) + initrd_at = aligned(dtb_at + len(tree)) + image_size = initrd_at + len(initramfs) + if described['load_address'] + image_size > described['initrd_address']: + raise ValueError('Uploaded payload overlaps the initramfs destination') + values = configuration(loader) + values.update(dtb_offset=dtb_at, dtb_size=len(tree), initrd_offset=initrd_at, + initrd_size=len(initramfs), image_size=image_size) + record = CONFIG.pack(*(values[field] for field in CONFIG_FIELDS)) + values['crc32'] = zlib.crc32(record[:-4]) + framed_loader = bytearray(loader) + framed_loader[CONFIG_OFFSET:CONFIG_OFFSET + CONFIG.size] = CONFIG.pack(*(values[field] for field in CONFIG_FIELDS)) + components, image = [], bytearray() + def append(name, payload): + if payload: + components.append({'name': name, 'offset': len(image), 'bytes': len(payload), 'sha256': digest(payload)}) + image.extend(payload) + append('loader', framed_loader) + append('loader-padding', b'\0' * (kernel_at - len(image))) + append('kernel', kernel) + append('kernel-padding', b'\0' * (dtb_at - len(image))) + append('dtb', tree) + append('dtb-padding', b'\0' * (initrd_at - len(image))) + append('initramfs', initramfs) + return bytes(image), {'schema': 1, 'architecture': 'arm', 'loader_format': 'cascadia', + 'image_sha256': digest(image), 'initramfs_encoding': 'cascadia-dtb', + 'components': components, 'load_address': described['load_address'], + 'kernel_entry': described['load_address'] + kernel_at, + 'dtb_address': described['load_address'] + dtb_at, + 'kernel_decompression_bound': expanded_end, + 'initramfs_address': described['initrd_address'], + 'input_loader_sha256': digest(loader), 'input_dtb_sha256': digest(dtb)} + + +def _payload_parts(data): + """Validate a CB32 bundle before inspecting or binding its identity.""" + values = configuration(data) + if (values['image_size'] != len(data) or values['kernel_offset'] != 0x8000 or + data[:4] != bytes.fromhex('c0000cf1') or + values['crc32'] != zlib.crc32(data[CONFIG_OFFSET:CONFIG_OFFSET + CONFIG.size - 4])): + raise ValueError('Invalid Cascadia payload configuration') + kernel_at, dtb_at, dtb_size = (values[key] for key in ('kernel_offset', 'dtb_offset', 'dtb_size')) + initrd_at, initrd_size = values['initrd_offset'], values['initrd_size'] + if (not kernel_at + 0x3c <= dtb_at < dtb_at + dtb_size <= initrd_at or + initrd_at != aligned(dtb_at + dtb_size) or initrd_at + initrd_size != len(data) or + any(data[dtb_at + dtb_size:initrd_at])): + raise ValueError('Invalid Cascadia payload component ranges') + described = {'schema': 1, **{key: values[key] for key in PROFILE_FIELDS - {'schema'}}} + validate_profile(described) + if values['load_address'] + len(data) > values['initrd_address']: + raise ValueError('Cascadia payload overlaps its initramfs destination') + tree, _, _ = read_fdt(data[dtb_at:dtb_at + dtb_size]) + selected = next((child for child in tree['children'] if child['name'] == 'chosen'), None) + raw = selected['properties'].get('bootargs', b'') if selected else b'' + if not raw.endswith(b'\0') or b'\0' in raw[:-1]: + raise ValueError('Cascadia payload lacks one complete bootargs value') + try: + bootargs = raw[:-1].decode('utf-8') + except UnicodeError as error: + raise ValueError('Cascadia bootargs are not UTF-8') from error + return values, described, bootargs + + +def host_identity_policy(data): + """Return the declared host-bound recovery policy of an unbound template.""" + values, _, bootargs = _payload_parts(data) + if 'orchard.recovery-' not in bootargs: + return None + parameters = identity_protocol.parameters(bootargs) + if identity_protocol.HANDOFF_PARAMETER in parameters: + raise ValueError('A public recovery template already contains an identity handoff') + policy = parameters.get(identity_protocol.POLICY_PARAMETER) + if policy is None: + raise ValueError('Recovery identity parameters lack a policy') + start = values['dtb_offset'] + _policy_compatible(data[start:start + values['dtb_size']], policy) + return policy + + +def bind_host_identity(data, *, ecid, chip, board, profile, handoff_id, template_sha256): + """Bind a guarded host USB observation to one complete, revalidated bundle.""" + return bind_recovery_identity(data, source='host-usb', ecid=ecid, chip=chip, board=board, + profile=profile, handoff_id=handoff_id, template_sha256=template_sha256) + + +def bind_recovery_identity(data, *, source, ecid, chip, board, profile, handoff_id, template_sha256): + """Bind one permitted origin without changing the kernel or initramfs.""" + policy = host_identity_policy(data) + if policy is None or not identity_protocol.allows_source(policy, source): + raise ValueError('Payload does not permit the selected recovery identity source') + if (profile, chip, board) != (policy['profile'], policy['chip'], policy['board']): + raise ValueError('Selected device differs from the recovery identity policy') + if template_sha256 != digest(data): + raise ValueError('Recovery template digest differs before identity binding') + handoff = identity_protocol.validate_handoff({'schema': 1, 'source': source, + 'profile': profile, 'ecid': ecid, 'chip': chip, 'board': board, + 'handoff_id': handoff_id, 'template_sha256': template_sha256}) + values, described, bootargs = _payload_parts(data) + loader = bytearray(data[:values['kernel_offset']]) + pristine = dict(values) + for key in ('dtb_offset', 'dtb_size', 'initrd_offset', 'initrd_size', 'image_size', 'crc32'): + pristine[key] = 0 + loader[CONFIG_OFFSET:CONFIG_OFFSET + CONFIG.size] = CONFIG.pack(*(pristine[key] for key in CONFIG_FIELDS)) + bootargs += ' ' + identity_protocol.encode_parameter(identity_protocol.HANDOFF_PARAMETER, handoff) + output, _ = assemble(bytes(loader), data[values['kernel_offset']:values['dtb_offset']], + data[values['dtb_offset']:values['dtb_offset'] + values['dtb_size']], + data[values['initrd_offset']:], described, bootargs) + _payload_parts(output) + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) + given = parser.add_mutually_exclusive_group(required=True) + given.add_argument('--bootargs') + given.add_argument('--manifest', type=Path) + for name in ('loader', 'kernel', 'initramfs', 'profile', 'output'): + parser.add_argument('--' + name, type=Path, required=True) + parser.add_argument('--loader-elf', type=Path) + parser.add_argument('--identity-policy', type=Path, help='descriptor-selected public recovery identity policy') + parser.add_argument('--dtb', action='append', type=Path, required=True) + parser.add_argument('--logo', type=Path) + args = parser.parse_args() + if len(args.dtb) != 1: + parser.error('A Cascadia payload takes exactly one device tree') + if args.logo: + parser.error('Cascadia payloads do not implement the m1n1 logo format') + receipt = args.output.with_suffix(args.output.suffix + '.json') + if args.output.exists() or receipt.exists(): + parser.error('Choose new image and receipt paths') + try: + bootargs = args.bootargs + if args.manifest: + bootargs = json.loads(args.manifest.read_text())['bootargs'] + prefix = 'chosen.bootargs=' + if not bootargs.startswith(prefix) or not bootargs.endswith('\n'): + raise ValueError('Invalid boot-argument manifest') + bootargs = bootargs[len(prefix):-1] + data, document = assemble(args.loader.read_bytes(), args.kernel.read_bytes(), args.dtb[0].read_bytes(), + args.initramfs.read_bytes(), profile(args.profile), bootargs, + elf=args.loader_elf.read_bytes() if args.loader_elf else None, + identity_policy=identity_protocol.validate_policy(identity_protocol.object_json(args.identity_policy.read_bytes())) + if args.identity_policy else None) + except (ValueError, KeyError) as error: + parser.error(str(error)) + document['profile_sha256'] = digest(args.profile.read_bytes()) + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('xb') as output: + output.write(data) + with receipt.open('x') as output: + json.dump(document, output, indent=2, sort_keys=True) + output.write('\n') + print(f'Built {args.output}; component receipt: {receipt}') + + +if __name__ == '__main__': + main() diff --git a/src/orchard/command.py b/src/orchard/command.py index 0a49f93e..1e29f025 100644 --- a/src/orchard/command.py +++ b/src/orchard/command.py @@ -103,6 +103,14 @@ def main(): for command in (run, guided, launcher, boot): command.add_argument('--via', choices=sorted(ENTRIES), help='how to enter the device from DFU ' "(default: the board's preference, else palera1n)") + ios = commands.add_parser('prepare-ios-launcher', help='prepare an on-device iOS launcher as private host files; no device access') + from orchard.apple import ios_launcher + ios_launcher.add_arguments(ios) + autogo = commands.add_parser('prepare-recovery-autogo', help='prepare a private pinned ARM32 recovery stage; no device access') + autogo.add_argument('--device', required=True) + autogo.add_argument('--template', type=Path, required=True) + autogo.add_argument('--ibec-raw', type=Path, required=True) + autogo.add_argument('--output', type=Path) args = parser.parse_args() if getattr(args, 'via', None): os.environ['ORCHARD_VIA'] = args.via @@ -115,6 +123,14 @@ def main(): if args.action == 'build' or (args.action == 'run' and not args.no_build): from orchard.cache import check_free check_free(build_cache(), args.directory) + if args.action == 'prepare-ios-launcher': + print(json.dumps(ios_launcher.from_arguments(args), indent=2)) + return + if args.action == 'prepare-recovery-autogo': + from orchard.apple import iboot32 + output = args.output if args.output is not None else iboot32.directory(args.device) + print(json.dumps(iboot32.prepare(args.device, args.template, args.ibec_raw, output), indent=2)) + return if args.action in ('build', 'run'): from orchard import distro root = distro.find(args.directory) @@ -138,8 +154,14 @@ def main(): '--platform or --device; update the distribution ' '(orchard.toml [release] selects-platform = true)') from orchard.build import resolve_kernel_tree + selected = {} + if distro.settings(root)['selects_device']: + from orchard.build import resolve_device + selected_device = resolve_device(root, args.device) + if selected_device is not None: + selected['device'] = selected_device index = distro.build(root, version=args.version, platforms=platforms, - kernel_tree=resolve_kernel_tree(root, args.kernel_tree)) + kernel_tree=resolve_kernel_tree(root, args.kernel_tree), **selected) commands = index.parent.parent / f'{index.parent.name}.commands.sh' if args.explain: print(commands.read_text(), end='') @@ -246,10 +268,16 @@ def main(): expected = receipt.get('image_sha256', receipt.get('sha256')) if args.image.is_symlink() or digest(args.image) != expected: raise ValueError('Explicit recovery image differs from receipt') - if device['mode'] == 'dfu' and not args.pongo_image: + from orchard import hardware + described = hardware.device(adapter.PROFILE) + method = next((name for name in described.get('boot', {}).get('methods', []) + if name in hardware.IMPLEMENTED_METHODS), None) + if method is None: + raise ValueError('The selected board has no implemented recovery boot method') + if device['mode'] == 'dfu' and method == 'checkm8-pongo' and not args.pongo_image: raise ValueError('DFU boot with explicit files requires --pongo-image') assets = {'recovery': args.image, 'pongo': args.pongo_image} - record = {'assets': {'recovery': {'sha256': expected}}} + record = {'boot_method': method, 'assets': {'recovery': {'sha256': expected}}} else: source = args.manifest or args.source if not source: diff --git a/src/orchard/contracts.py b/src/orchard/contracts.py index 76dc456f..fe6be22a 100644 --- a/src/orchard/contracts.py +++ b/src/orchard/contracts.py @@ -7,6 +7,7 @@ from contextlib import AbstractContextManager from dataclasses import dataclass from pathlib import Path +import re from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Protocol, Tuple, TypedDict, Union, runtime_checkable from .progress import Clock, Progress @@ -22,32 +23,40 @@ class _SelectionDetails(TypedDict, total=False): serial_port: Optional[str] pwned: bool boot_id: Optional[str] + handoff_id: Optional[str] + identity_source: Optional[str] class SelectedDevice(_SelectionDetails): """Validated discovery result; resumed is explicit, never inferred by the host.""" location: USBLocation - mode: Literal['dfu', 'pongo', 'linux'] + mode: Literal['dfu', 'recovery', 'pongo', 'linux'] ecid: int resumed: bool class BootAssets(TypedDict, total=False): - """Verified local artifacts; Pongo is required only when starting from DFU. + """Verified local artifacts; Pongo is used by the checkm8-pongo chain. A running recovery needs no upload; otherwise recovery must be supplied. + prepared_boot names private checked iBEC inputs for recovery-autogo. root is used by installation, not by the adapter's boot transport. """ pongo: Path recovery: Path root: Path + prepared_boot: Path class BootReceipt(TypedDict): sha256: str -class BootRequest(TypedDict): +class _BootRequestDetails(TypedDict, total=False): + boot_method: str + + +class BootRequest(_BootRequestDetails): """Minimum adapter boot input, shared by resolved and explicit-file requests.""" assets: Mapping[str, BootReceipt] @@ -59,11 +68,42 @@ class MixinReceipt(TypedDict): sha256: str +@dataclass(frozen=True) +class RecoveryHandoff: + """Expected provenance for one checked ARM32 recovery handoff.""" + handoff_id: str + template_sha256: str + chip: int + board: int + identity_source: str = 'host-usb' + + def __post_init__(self) -> None: + if (not isinstance(self.handoff_id, str) or + not re.fullmatch('[0-9a-f]{32}', self.handoff_id) or + not int(self.handoff_id, 16) or + not isinstance(self.template_sha256, str) or + not re.fullmatch('[0-9a-f]{64}', self.template_sha256) or + self.identity_source not in ('host-usb', 'ios-device') or + type(self.chip) is not int or not 0 < self.chip < 2**32 or + type(self.board) is not int or not 0 <= self.board < 2**32): + raise ValueError('Invalid host-bound recovery identity') + + def fields(self) -> Dict[str, Any]: + return {'identity_source': self.identity_source, 'handoff_id': self.handoff_id, + 'template_sha256': self.template_sha256, 'chip': self.chip, 'board': self.board} + + def matches(self, observation: Any) -> bool: + return (isinstance(observation, dict) and + type(observation.get('chip')) is int and type(observation.get('board')) is int and + all(observation.get(key) == value for key, value in self.fields().items())) + + @dataclass(frozen=True) class Connection: argv: List[str] scope: BootState check: Callable[[], None] + handoff: Optional[RecoveryHandoff] = None @runtime_checkable diff --git a/src/orchard/devices.py b/src/orchard/devices.py index 448c736a..fd5bf5c8 100644 --- a/src/orchard/devices.py +++ b/src/orchard/devices.py @@ -45,13 +45,13 @@ def choose() -> Tuple[Adapter, SelectedDevice]: def attached_board() -> Optional[str]: - """The board of the one device attached in DFU or Pongo, if described; None otherwise. + """The board of the one device attached in DFU, Recovery or Pongo, if described; None otherwise. Only lists USB devices: nothing is opened, reset or booted. """ from .apple import usb try: - found = [device for device in usb.devices() if device.get('mode') in ('dfu', 'pongo')] + found = [device for device in usb.devices() if device.get('mode') in ('dfu', 'recovery', 'pongo')] except Exception: # no libusb or USB access: no attached board to go by return None if len(found) != 1: diff --git a/src/orchard/distro.py b/src/orchard/distro.py index 72afe53d..33813f74 100644 --- a/src/orchard/distro.py +++ b/src/orchard/distro.py @@ -61,13 +61,16 @@ def settings(root): if kind != 'release': raise ValueError(f'{root} has no orchard.toml with [release]') value = {**value, 'name': name} - unknown = set(value) - {'name', 'build', 'selects-platform'} + unknown = set(value) - {'name', 'build', 'selects-platform', 'selects-device'} if unknown: raise ValueError(f'{path}: unknown settings {", ".join(sorted(unknown))}; ' - 'expected name, build, selects-platform') + 'expected name, build, selects-platform, selects-device') selects = value.get('selects-platform', False) if not isinstance(selects, bool): raise ValueError(f'{path}: selects-platform must be true or false') + selects_device = value.get('selects-device', False) + if not isinstance(selects_device, bool) or (selects_device and not selects): + raise ValueError(f'{path}: selects-device requires selects-platform and must be true or false') name, build = value.get('name'), value.get('build') if not isinstance(name, str) or not _NAME.fullmatch(name): raise ValueError(f'{path}: name must be the distro name of its index, e.g. "omarchy-a"') @@ -79,7 +82,7 @@ def settings(root): raise ValueError(f'{path}: the build entry {build} is not a file') if entry.suffix != '.py' and not os.access(entry, os.X_OK): raise ValueError(f'{path}: the build entry {build} is not executable') - return {'name': name, 'build': entry, 'selects_platform': selects} + return {'name': name, 'build': entry, 'selects_platform': selects, 'selects_device': selects_device} def default_version(now=None): @@ -88,7 +91,7 @@ def default_version(now=None): return now.strftime('dev-%Y%m%d-%H%M%S') -def build(root, *, version=None, platforms=None, kernel_tree=None): +def build(root, *, version=None, platforms=None, kernel_tree=None, device=None): """Run ROOT's build entry into out/releases/VERSION, check the release, record it; returns its index. PLATFORMS: a list of platform names or 'all', passed as --platform to an @@ -118,6 +121,10 @@ def build(root, *, version=None, platforms=None, kernel_tree=None): elif distro['selects_platform']: raise ValueError(f'{distro["name"]} selects its platforms: choose with --platform, --device, ' 'orchard.local.toml or the attached board') + if device is not None: + if not distro['selects_device']: + raise ValueError('Distribution build entry does not accept --device') + argv += ['--device', device] print('+', ' '.join(argv), flush=True) environment = {**os.environ, 'ORCHARD_PYTHON': sys.executable, 'ORCHARD_CHECKOUT': str(ROOT)} tree = '' @@ -185,7 +192,8 @@ def run(root, *, build_first=True, version=None, yes=False, progress=None, kerne if board is None: raise ValueError('No described device is attached: connect it, then run again') platforms = [ram.resolve_platform(root, ROOT, device=board)] - index = build(root, version=version, platforms=platforms, kernel_tree=kernel_tree) + index = build(root, version=version, platforms=platforms, kernel_tree=kernel_tree, + **({'device': board} if settings(root)['selects_device'] else {})) else: index = latest(root) with operation_lock(): diff --git a/src/orchard/hardware.py b/src/orchard/hardware.py index e4d5b010..3d3723f7 100644 --- a/src/orchard/hardware.py +++ b/src/orchard/hardware.py @@ -15,7 +15,7 @@ # The repository root: devices/, kernel/, boot/ and schemas/ sit beside src/. ROOT = Path(__file__).resolve().parents[2] # What this version of the code can do; data can name methods newer than this code. -IMPLEMENTED_METHODS = frozenset({'checkm8-pongo'}) +IMPLEMENTED_METHODS = frozenset({'checkm8-pongo', 'recovery-autogo'}) def published(): @@ -59,6 +59,16 @@ def device_path(profile, root=None): return path +def payload_profile(profile, kind, root=None): + """The board-owned profile for a source-built payload loader.""" + if kind not in ('cascadia',): + raise ValueError('Unsupported payload profile: ' + str(kind)) + path = device_path(profile, root).with_name(kind + '.toml') + if not path.is_file() or path.is_symlink(): + raise ValueError(f'{profile} has no {kind} handoff profile') + return path + + def profiles(root=None): """Every device this hardware data describes.""" devices = Path(root or data_root()) / 'devices' @@ -80,6 +90,19 @@ def device(profile, root=None): return tomllib.loads(device_path(profile, root).read_text()) +def boot_recipe(profile, root=None): + """Return a board's boot recipe through the hardware data boundary.""" + described = device(profile, root) + path = device_path(profile, root).parent / 'boot.json' + if path.is_symlink() or not path.is_file(): + raise ValueError('The board has no regular boot recipe') + recipe = json.loads(path.read_text()) + if (recipe.get('schema') != 1 or recipe.get('device') not in described['device']['apple_products'] + or not isinstance(recipe.get('methods'), dict)): + raise ValueError('Boot recipe identity differs from the board') + return recipe + + def install_blockers(profile, described=None): """Why this Orchard can't install on a board: [] when it can. @@ -102,8 +125,8 @@ def install_blockers(profile, described=None): def has_recovery(described, root=None): """Whether Orchard's recovery runs on a board: it follows from the board's kernel. - Recovery is built per platform with every board's device tree, on the arm64 root - distributions build, so 32-bit boards have none until a root of their own exists. + Recovery is built per platform with every board's device tree. The + platform producers support ARM64 and ARMv7 roots independently. """ if 'kernel' not in described: return False @@ -111,7 +134,18 @@ def has_recovery(described, root=None): for path in (chips / 'chips').glob('*.toml'): chip = tomllib.loads(path.read_text()) if chip['cpid'] == described['match']['dfu']['chip']: - return chip['architecture'] == 'arm64' + if chip['architecture'] == 'arm64': + return True + profile = described['device']['profile'] + if (chip['architecture'] != 'arm' or 'recovery-autogo' not in IMPLEMENTED_METHODS + or 'recovery-autogo' not in described.get('boot', {}).get('methods', ())): + return False + try: + payload_profile(profile, 'cascadia', root=chips) + recipe = boot_recipe(profile, root=chips) + return isinstance(recipe['methods'].get('recovery-autogo'), dict) + except (ValueError, OSError): + return False return False diff --git a/src/orchard/image/cascadia.py b/src/orchard/image/cascadia.py new file mode 100644 index 00000000..4a39f7ad --- /dev/null +++ b/src/orchard/image/cascadia.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Command entry point for the shared standard-library CB32 payload codec.""" +from pathlib import Path +import sys + +if not __package__: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from orchard.cascadia_payload import main + +if __name__ == '__main__': + main() diff --git a/src/orchard/image/elf.py b/src/orchard/image/elf.py index 48c6fc14..914421f3 100644 --- a/src/orchard/image/elf.py +++ b/src/orchard/image/elf.py @@ -1,35 +1,100 @@ -"""Read ELF64 little-endian DT_NEEDED names without executing target binaries.""" +"""Inspect little-endian ELF32/ELF64 dependencies without executing target code.""" import struct +def _parts(data): + if data[:4] != b'\x7fELF': + return None + if len(data) < 16 or data[5:7] != b'\x01\x01' or data[4] not in (1, 2): + raise ValueError('Unsupported ELF encoding') + width = data[4] + header_size, program_size = (52, 32) if width == 1 else (64, 56) + if len(data) < header_size: + raise ValueError('Incomplete ELF header') + machine = struct.unpack_from(' len(data)): + raise ValueError('ELF program headers exceed the file') + segments = [] + for index in range(count): + position = offset + size * index + if width == 1: + kind, file_offset, virtual, _, file_size, _, _, _ = struct.unpack_from('<8I', data, position) + else: + kind, _, file_offset, virtual, _, file_size, _, _ = struct.unpack_from(' len(data): + raise ValueError('ELF segment exceeds the file') + segments.append((kind, virtual, file_offset, file_size)) + return width, machine, segments + + +def architecture(data): + """Return Orchard's architecture for an ARM executable or shared object.""" + parsed = _parts(data) + if parsed is None: + raise ValueError('Expected an ELF executable') + width, machine, _ = parsed + if width == 2 and machine == 183: + return 'arm64' + if width == 1 and machine == 40: + flags = struct.unpack_from('> 24 != 5 or not flags & 0x400 or flags & 0x200: + raise ValueError('ARM recovery requires EABI5 hard-float executables') + return 'arm' + raise ValueError('Unsupported recovery ELF architecture') + + def needed(data): - if data[:6] != b'\x7fELF\x02\x01': + parsed = _parts(data) + if parsed is None: return set() - offset = struct.unpack_from(' length: + raise ValueError('ELF string table exceeds its load segment') + length = string_size + result = set() + for name in names: + if name >= length: + raise ValueError('ELF dependency offset exceeds the string table') + end = data.find(b'\0', start + name, start + length) + if end < 0: + raise ValueError('Unterminated ELF dependency name') + try: + text = data[start + name:end].decode('ascii') + except UnicodeDecodeError as error: + raise ValueError('Non-ASCII ELF dependency name') from error + if not text or '/' in text or text in ('.', '..'): + raise ValueError('Invalid ELF dependency name') + result.add(text) + return result raise ValueError('ELF string table is outside load segments') diff --git a/src/orchard/image/platform.py b/src/orchard/image/platform.py index 76a5e4fb..f3a1ec7c 100644 --- a/src/orchard/image/platform.py +++ b/src/orchard/image/platform.py @@ -17,6 +17,8 @@ import gzip import io import json +import re +import tomllib from pathlib import Path import stat import tarfile @@ -49,7 +51,7 @@ def boards(platform, hardware=None): ROOT_ARCH = 'arm64' # distributions build one arm64 root for every platform -def platforms(hardware=None): +def platforms(hardware=None, architecture=ROOT_ARCH): """Every platform with at least one described board whose kernel runs the shared arm64 root, sorted. A platform of another architecture (apple-armv7) has no platform layer until a root of its architecture exists.""" @@ -57,7 +59,7 @@ def platforms(hardware=None): from ..ram import boards_by_platform hardware = hardware or ROOT return sorted(platform for platform, boards in boards_by_platform(hardware).items() - if descriptor(hardware, sorted(boards)[0])['arch'] == ROOT_ARCH) + if architecture is None or descriptor(hardware, sorted(boards)[0])['arch'] == architecture) def kernel(platform, *, hardware=None, cache=None): @@ -79,7 +81,7 @@ def tools(hardware=None, cache=None, platform='apple-16k'): return prepare_tools(hardware, cache, device=sorted(boards(platform, hardware))[0]) / 'busybox' -def initrd(modules, release, busybox, *, hardware=None): +def initrd(modules, release, busybox, *, hardware=None, storage_modules=STORAGE_MODULES): """orchard's initrd for installed systems: BusyBox, the storage modules and their dependencies. MODULES is {path: (data, mode)} as module_files gives it. @@ -91,9 +93,9 @@ def initrd(modules, release, busybox, *, hardware=None): name, _, needs = line.partition(':') depends[name] = needs.split() by_name = {Path(name).name.split('.ko')[0].replace('_', '-'): name for name in depends} - loaded = [name for name in STORAGE_MODULES if name.replace('_', '-') in by_name] - if not loaded: - raise ValueError(f'Kernel bundle has no storage module: {", ".join(STORAGE_MODULES)}') + loaded = [name for name in storage_modules if name.replace('_', '-') in by_name] + if storage_modules and not loaded: + raise ValueError(f'Kernel bundle has no storage module: {", ".join(storage_modules)}') wanted, pending = [], [by_name[name.replace('_', '-')] for name in loaded] while pending: name = pending.pop() @@ -121,14 +123,31 @@ def initrd(modules, release, busybox, *, hardware=None): return output.read_bytes() +def storage_modules(hardware, platform): + """The platform's early-root modules; an empty list denotes built-in storage.""" + if not re.fullmatch(r'[a-z0-9][a-z0-9_-]*', platform): + raise ValueError('Invalid platform name') + settings = tomllib.loads((Path(hardware) / 'platforms' / (platform + '.toml')).read_text()) + modules = settings.get('root', {}).get('storage_modules', list(STORAGE_MODULES)) + if (not isinstance(modules, list) or + any(not isinstance(name, str) or not re.fullmatch(r'[a-zA-Z0-9_+-]+', name) for name in modules) or + len(modules) != len(set(modules))): + raise ValueError('Platform root.storage_modules must be distinct kernel module names') + return tuple(modules) + + def files(bundle, platform, board_dtbs, *, busybox, hardware=None): """(kernel release, {path: (data, mode)}) of PLATFORM's layer from a staged kernel BUNDLE.""" hardware = Path(hardware or ROOT) + from ..kernel import descriptor + described = descriptor(hardware, sorted(board_dtbs)[0]) bundle = Path(bundle) release, result = module_files(bundle / 'lib/modules') base = f'usr/lib/modules/{release}/' - result[base + 'vmlinuz'] = ((bundle / 'Image.gz').read_bytes(), 0o644) - result[base + 'initrd'] = (initrd(result, release, busybox, hardware=hardware), 0o644) + result[base + 'vmlinuz'] = ((bundle / described['image']).read_bytes(), 0o644) + modules_to_load = storage_modules(hardware, platform) + result[base + 'initrd'] = (initrd(result, release, busybox, hardware=hardware, + storage_modules=modules_to_load), 0o644) for dtb in sorted(set(board_dtbs.values())): path = bundle / 'dtbs' / (dtb + '.dtb') if path.is_symlink() or not path.is_file(): @@ -145,7 +164,8 @@ def files(bundle, platform, board_dtbs, *, busybox, hardware=None): raise ValueError(f'System defaults belong under /usr/lib or /usr/share/alsa/ucm2: {name}') result[name] = (path.read_bytes(), 0o755 if path.stat().st_mode & 0o111 else 0o644) description = {'schema': SCHEMA, 'platform': platform, 'kernel_release': release, - 'boards': dict(sorted(board_dtbs.items()))} + 'boards': dict(sorted(board_dtbs.items())), 'architecture': described['arch'], + 'image': described['image']} result['usr/lib/orchard/platform.json'] = ((json.dumps(description, indent=2) + '\n').encode(), 0o644) result['usr/lib/orchard/platform.SHA256SUMS'] = (checksums(result), 0o644) return release, result @@ -166,21 +186,36 @@ def write(layer, output): return expanded, len(layer) -def build(platform, output_directory, *, hardware=None, cache=None, bundle=None, busybox=None): +def build(platform, output_directory, *, hardware=None, cache=None, bundle=None, busybox=None, + root_architecture=None, device=None): """PLATFORM's layer as OUTPUT_DIRECTORY/platform-PLATFORM.tar.gz, plus its receipt. BUNDLE is a staged kernel bundle and BUSYBOX orchard's static BusyBox; without them they are built or reused from the cache. """ board_dtbs = boards(platform, hardware) + if device is not None: + if device not in board_dtbs: + raise ValueError(f'Device {device} does not belong to platform {platform}') + board_dtbs = {device: board_dtbs[device]} + if root_architecture is not None: + from ..kernel import descriptor + architecture = descriptor(hardware or ROOT, sorted(board_dtbs)[0])['arch'] + if architecture != root_architecture: + raise ValueError(f'Platform {platform} architecture {architecture} differs from root {root_architecture}') bundle = Path(bundle) if bundle is not None else kernel(platform, hardware=hardware, cache=cache) busybox = Path(busybox) if busybox is not None else tools(hardware, cache, platform) + if root_architecture is not None: + from .elf import architecture as elf_architecture + if elf_architecture(busybox.read_bytes()) != root_architecture: + raise ValueError('Platform initrd BusyBox differs from the root architecture') release, layer = files(bundle, platform, board_dtbs, busybox=busybox, hardware=hardware) output = Path(output_directory) / f'platform-{platform}.tar.gz' expanded, members = write(layer, output) return {'file': output.name, 'bytes': output.stat().st_size, 'sha256': sha(output), 'compression': 'gzip', 'expanded_bytes': expanded, 'members': members, - 'kernel_release': release, 'boards': sorted(board_dtbs)} + 'kernel_release': release, 'boards': sorted(board_dtbs), + 'architecture': json.loads(layer['usr/lib/orchard/platform.json'][0])['architecture']} def main(): @@ -190,9 +225,13 @@ def main(): parser.add_argument('--kernel-dir', type=Path, help='a staged kernel bundle (default: build or reuse it)') parser.add_argument('--busybox', type=Path, help="orchard's static BusyBox (default: build or reuse it)") parser.add_argument('--receipt', type=Path, help="also write the layer's receipt (JSON) here") + parser.add_argument('--device', help='restrict the platform layer to this board') + parser.add_argument('--root-architecture', choices=('arm', 'arm64'), + help='require the platform to match this root architecture') args = parser.parse_args() args.output.mkdir(parents=True, exist_ok=True) - receipt = build(args.platform, args.output, bundle=args.kernel_dir, busybox=args.busybox) + receipt = build(args.platform, args.output, bundle=args.kernel_dir, busybox=args.busybox, + root_architecture=args.root_architecture, device=args.device) if args.receipt: args.receipt.write_text(json.dumps(receipt, indent=2, sort_keys=True) + '\n') print(json.dumps(receipt, indent=2)) diff --git a/src/orchard/image/prepare_runtime.py b/src/orchard/image/prepare_runtime.py index 9317506a..7bc79ac1 100644 --- a/src/orchard/image/prepare_runtime.py +++ b/src/orchard/image/prepare_runtime.py @@ -10,7 +10,7 @@ from .runtime import files as runtime_files, recovery_files from .integrity import sha -from .elf import needed +from .elf import needed, architecture as elf_architecture from ..hardware import ROOT as PROJECT HERE = PROJECT / 'boot/initramfs/recovery' @@ -22,11 +22,18 @@ def copy(source, target, mode): target.chmod(mode) -def stage_runtime(base, receipt, root): +def stage_runtime(base, receipt, root, architecture='arm64'): spec = json.loads(receipt.read_text()) + root_architecture = spec.get('architecture', architecture) + if not isinstance(root_architecture, str): + raise ValueError('Python source root architecture is invalid') + root_architecture = {'aarch64': 'arm64', 'arm64': 'arm64', + 'armv7h': 'arm', 'armhf': 'arm', 'arm': 'arm'}.get(root_architecture) + if root_architecture != architecture: + raise ValueError('Python source root architecture differs from recovery') if base.stat().st_size != spec['bytes'] or sha(base) != spec['sha256']: raise ValueError('Python source root differs from receipt') - libraries = re.compile(r'usr/lib/(?:ld-linux-aarch64|lib(?:python[0-9.]+|c|m|z|zstd|bz2|lzma|ffi|ssl|crypto|readline|ncursesw|tinfo|uuid|expat|gcc_s|mpdec))[-.].*') + libraries = re.compile(r'usr/lib/(?:ld-linux-(?:aarch64|armhf)|lib(?:python[0-9.]+|c|m|z|zstd|bz2|lzma|ffi|ssl|crypto|readline|ncursesw|tinfo|uuid|expat|gcc_s|mpdec))[-.].*') with tarfile.open(base, 'r|gz') as archive: for member in archive: name = member.name.removeprefix('./') @@ -74,7 +81,10 @@ def stage_runtime(base, receipt, root): required = set() for path in (root / 'usr').rglob('*'): if path.is_file() and not path.is_symlink(): - required.update(needed(path.read_bytes())) + data = path.read_bytes() + if data.startswith(b'\x7fELF') and elf_architecture(data) != architecture: + raise ValueError('Recovery contains an executable for another architecture: ' + str(path)) + required.update(needed(data)) missing = set() for name in required: path = root / 'usr/lib' / name @@ -112,12 +122,38 @@ def stage_runtime(base, receipt, root): raise ValueError('Python runtime dependency closure did not converge') +def check_recovery_modules(root, release, recovery): + """Manual storage must stay modular; declared USB dependencies must exist.""" + normalized = lambda names: {name.replace('-', '_') for name in names} + if normalized(recovery['runtime']['usb_modules']) & normalized(recovery['runtime']['manual_modules']): + raise ValueError('A manual module cannot be loaded during USB setup') + directory = root / 'usr/lib/modules' / release + builtins = {Path(name).stem.replace('-', '_') for name in + (directory / 'modules.builtin').read_text().splitlines() if name} + available = {path.stem.replace('-', '_') for path in directory.rglob('*.ko')} + for name in recovery['runtime']['manual_modules']: + if name.replace('-', '_') in builtins: + raise ValueError('Manual recovery module is built in: ' + name) + for name in recovery['runtime']['usb_modules']: + if name.replace('-', '_') not in builtins | available: + raise ValueError('Recovery USB module is unavailable: ' + name) + + def main(): parser = argparse.ArgumentParser(description=__doc__) for name in ('busybox', 'dropbear', 'dropbearkey', 'wpa-supplicant', 'wpa-cli', 'iw', 'base', 'base-receipt', 'modules-dir', 'root-receipt', 'output'): parser.add_argument('--' + name, type=Path, required=True) + parser.add_argument('--architecture', choices=('arm', 'arm64'), default='arm64') + parser.add_argument('--device', help='stage the selected board recovery policy') args = parser.parse_args() + recovery = None + if args.device: + from ..recovery_policy import settings + from ..kernel import descriptor + if descriptor(PROJECT, args.device)['arch'] != args.architecture: + parser.error('Recovery board architecture differs from the runtime') + recovery = settings(args.device) root = args.output if root.exists(): parser.error('Choose a new staging directory') @@ -129,13 +165,18 @@ def main(): (args.wpa_supplicant, 'usr/sbin/wpa_supplicant'), (args.wpa_cli, 'usr/bin/wpa_cli'), (args.iw, 'usr/sbin/iw')): copy(source, root / name, 0o755) - stage_runtime(args.base, args.base_receipt, root) + for source in (args.busybox, args.dropbear, args.dropbearkey, args.wpa_supplicant, args.wpa_cli, args.iw): + if elf_architecture(source.read_bytes()) != args.architecture: + raise ValueError('Recovery tool architecture differs: ' + str(source)) + stage_runtime(args.base, args.base_receipt, root, args.architecture) kernel_release, public = runtime_files(args.modules_dir) for name, (data, mode) in {**public, **recovery_files()}.items(): path = root / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) path.chmod(mode) + if recovery is not None: + check_recovery_modules(root, kernel_release, recovery) # The release this recovery belongs to; `orchard-device status` reports it. # Root images carry no kernel: the release's platform layer of the same # kernel bundle does, so the recovery and an installed root match. @@ -151,6 +192,17 @@ def main(): (root / 'etc/group').write_text('root:x:0:\n') (root / 'etc/shadow').write_text('root:!:0:0:99999:7:::\n') (root / 'etc/shadow').chmod(0o600) + if recovery is not None: + for name, value in (('recovery-identity.json', recovery['identity']), ('recovery.json', recovery['runtime'])): + path = root / 'etc/orchard' / name + path.write_text(json.dumps(value, sort_keys=True) + '\n') + path.chmod(0o644) + manual = recovery['runtime']['manual_modules'] + if manual: + blacklist = root / 'etc/modprobe.d/orchard-recovery.conf' + blacklist.parent.mkdir(parents=True, exist_ok=True) + blacklist.write_text(''.join('blacklist ' + name + '\n' for name in manual)) + blacklist.chmod(0o644) print('Public recovery staged:', root) diff --git a/src/orchard/image/recovery.py b/src/orchard/image/recovery.py index 7c2ee440..a9c7ef68 100644 --- a/src/orchard/image/recovery.py +++ b/src/orchard/image/recovery.py @@ -19,10 +19,40 @@ def main(): parser.add_argument('--dtb', type=Path, action='append', required=True, help='the DTB of each device this recovery boots; repeat') parser.add_argument('--logo', type=Path, help="the distribution's m1n1 logo payload (see assemble.py)") + parser.add_argument('--platform', default='apple-16k', help='kernel platform and payload format') + parser.add_argument('--device', help='the board whose Cascadia handoff profile to use') diagnostics = parser.add_mutually_exclusive_group() diagnostics.add_argument('--verbose', action='store_true', help='enable visible Linux framebuffer console and kernel diagnostics') args = parser.parse_args() + from ..kernel import descriptor + from .platform import boards + described = descriptor(ROOT, args.platform) + profile = None + selected = args.device + if selected: + from ..hardware import device as described_device + if described_device(selected)['kernel']['platform'] != described['platform']: + parser.error('Recovery board does not belong to the selected platform') + if described['loader'] == 'cascadia': + if len(args.dtb) != 1 or args.logo is not None: + parser.error('Cascadia recovery takes one board DTB and no m1n1 logo') + available = boards(described['platform']) + selected = args.device + if selected is None: + candidates = [board for board, dtb in available.items() if args.dtb[0].stem == dtb] + if len(candidates) != 1: + parser.error('Select the Cascadia board with --device') + selected = candidates[0] + if selected not in available or args.dtb[0].stem != available[selected]: + parser.error('The Cascadia board and supplied DTB differ') + from ..hardware import payload_profile, device as described_device + from ..cascadia_payload import check_compatible + try: + profile = payload_profile(selected, 'cascadia', ROOT) + check_compatible(args.dtb[0].read_bytes(), described_device(selected, ROOT)['device']['dt_compatible']) + except ValueError as error: + parser.error(str(error)) manifest = 'boot-verbose.json' if args.verbose else 'boot.json' if args.output.exists(): parser.error('choose a new output path') @@ -39,11 +69,17 @@ def main(): '--wpa-cli', str(args.wpa_cli), '--iw', str(args.iw), '--base', str(args.base), '--base-receipt', str(args.base_receipt), '--modules-dir', str(args.modules_dir), '--root-receipt', str(args.root_receipt), + '--architecture', described['arch'], + *(['--device', selected] if selected else []), '--output', str(stage / 'root')], check=True) subprocess.run([sys.executable, '-m', 'orchard.image.initramfs', '--root', str(stage / 'root'), '--output', str(initramfs)], check=True) - subprocess.run([sys.executable, '-m', 'orchard.image.assemble', + assembly = 'orchard.image.cascadia' if profile is not None else 'orchard.image.assemble' + subprocess.run([sys.executable, '-m', assembly, + *(['--profile', str(profile)] if profile is not None else []), + *(['--identity-policy', str(stage / 'root/etc/orchard/recovery-identity.json')] + if (stage / 'root/etc/orchard/recovery-identity.json').is_file() else []), '--manifest', str(HERE / manifest), '--loader', str(args.loader), *(f'--dtb={dtb}' for dtb in args.dtb), *(['--logo', str(args.logo)] if args.logo else []), diff --git a/src/orchard/image/runtime.py b/src/orchard/image/runtime.py index 577717ae..595f3bec 100644 --- a/src/orchard/image/runtime.py +++ b/src/orchard/image/runtime.py @@ -63,7 +63,8 @@ def recovery_files(): prefix = 'usr/libexec/orchard/' source = PROJECT / 'runtime/recovery' result = {prefix + name: ((source / name).read_bytes(), 0o755) - for name in ('device.py', 'identity.py', 'storage-fence', 'start.py')} + for name in ('device.py', 'identity.py', 'policy.py', 'storage-fence', 'start.py')} + result[prefix + 'recovery_identity.py'] = ((PROJECT / 'src/orchard/recovery_identity.py').read_bytes(), 0o644) result[prefix + 'adt.py'] = ((PROJECT / 'src/orchard/apple/adt.py').read_bytes(), 0o644) result['usr/bin/orchard-device'] = (b'#!/bin/sh\nexec /usr/bin/python3 /usr/libexec/orchard/device.py "$@"\n', 0o755) return result diff --git a/src/orchard/inputs.py b/src/orchard/inputs.py index 3d84b442..1d9d7ba0 100644 --- a/src/orchard/inputs.py +++ b/src/orchard/inputs.py @@ -32,30 +32,57 @@ TOOLS = ('busybox', 'dropbear', 'dropbearkey', 'wpa_supplicant', 'wpa_cli', 'iw', 'orchard-write', 'resize2fs', 'tune2fs') -def resolve(platforms=None, explicit=None, *, hardware=None, cache=None, shared=None, kernel_tree=None): - """The inputs document for PLATFORMS (default: every arm64 platform). +def resolve(platforms=None, explicit=None, *, hardware=None, cache=None, shared=None, kernel_tree=None, + platform_loaders=None, platform_tools=None, device=None): + """Resolve one architecture's inputs; default to the existing arm64 platforms. - SHARED: {'m1n1', 'pongo', 'tools'} given explicitly; the rest is resolved. - KERNEL_TREE: a developer's kernel tree to build every selected platform's kernel from. + Per-platform loader/tool overrides keep offline builds independent of + orchard's cache. A root build must choose platforms of one architecture. """ - from .build import prepare_shared + from .build import prepare_shared, prepare_payload_loader, prepare_tools from .hardware import ROOT, revision from .image import platform + from .kernel import descriptor, layout hardware = Path(hardware or ROOT) explicit = explicit or {} - known = platform.platforms(hardware) - selected = list(platforms or known) - unknown = sorted(set(selected) - set(known)) + sorted(set(explicit) - set(selected)) + platform_loaders, platform_tools = platform_loaders or {}, platform_tools or {} + known = platform.platforms(hardware, architecture=None) + if device is not None: + selected_platform, _ = layout(hardware, device) + if platforms and list(platforms) != [selected_platform]: + raise ValueError('Selected device does not match the requested platform') + selected = [selected_platform] + else: + selected = list(platforms or platform.platforms(hardware)) + unknown = (set(selected) - set(known)) | ((set(explicit) | set(platform_loaders) | + set(platform_tools)) - set(selected)) if unknown: - raise ValueError(f'Unknown or unselected platform {", ".join(unknown)}; known: {", ".join(known)}') + raise ValueError(f'Unknown or unselected platform {", ".join(sorted(unknown))}; known: {", ".join(known)}') + descriptions = {name: descriptor(hardware, name) for name in selected} + architectures = {description['arch'] for description in descriptions.values()} + if len(architectures) != 1: + raise ValueError('A distribution root requires platforms of one architecture') + architecture = next(iter(architectures)) + board_sets = {name: platform.boards(name, hardware) for name in selected} + if device is not None: + board_sets[selected[0]] = {device: board_sets[selected[0]][device]} + for name, description in descriptions.items(): + if description['loader'] == 'cascadia' and len(board_sets[name]) != 1: + raise ValueError('Cascadia recovery requires --device to select one board profile') given = {key: Path(value) for key, value in (shared or {}).items() if value is not None} - found = {} if len(given) == 3 else prepare_shared(hardware, cache) - m1n1 = given.get('m1n1') or found['loader'] - pongo = given.get('pongo') or found['pongo'] - tools = given.get('tools') or Path(found['tools']) - for path in (m1n1, pongo, *(tools / name for name in TOOLS)): - if not Path(path).is_file(): - raise ValueError(f'Missing component {path}') + shared_document = {} + if architecture == 'arm64': + found = {} if len(given) == 3 else prepare_shared(hardware, cache) + m1n1 = given.get('m1n1') or found['loader'] + pongo = given.get('pongo') or found['pongo'] + tools = given.get('tools') or Path(found['tools']) + shared_document = {'m1n1': str(Path(m1n1).resolve()), 'pongo': str(Path(pongo).resolve()), + 'tools': {name: str((tools / name).resolve()) for name in TOOLS}} + for path in (m1n1, pongo, *(tools / name for name in TOOLS)): + if not Path(path).is_file(): + raise ValueError(f'Missing component {path}') + elif given: + raise ValueError('ARMv7 inputs use --platform-loader and --platform-tools overrides') try: orchard = revision() except ValueError: @@ -65,29 +92,46 @@ def bundle(name): return explicit[name] if kernel_tree is not None: from .build import prepare_kernel - return prepare_kernel(hardware, cache, device=sorted(platform.boards(name, hardware))[0], + return prepare_kernel(hardware, cache, device=sorted(board_sets[name])[0], kernel_tree=kernel_tree) return platform.kernel(name, hardware=hardware, cache=cache) - return { - 'schema': SCHEMA, - 'orchard': orchard, - 'platforms': {name: {'kernel': str(Path(bundle(name)).resolve()), - 'boards': platform.boards(name, hardware)} - for name in selected}, - 'shared': {'m1n1': str(Path(m1n1).resolve()), 'pongo': str(Path(pongo).resolve()), - 'tools': {name: str((tools / name).resolve()) for name in TOOLS}}, - } + records = {} + for name in selected: + description = descriptions[name] + board = sorted(board_sets[name])[0] + if architecture == 'arm64': + loader = platform_loaders.get(name) or m1n1 + target_tools = platform_tools.get(name) or tools + else: + loader = platform_loaders.get(name) or prepare_payload_loader(hardware, cache, device=board) + target_tools = platform_tools.get(name) or prepare_tools(hardware, cache, device=board) + target_tools = Path(target_tools) + for path in (loader, *(target_tools / tool for tool in TOOLS)): + if not Path(path).is_file(): + raise ValueError(f'Missing component {path}') + records[name] = {'kernel': str(Path(bundle(name)).resolve()), + 'boards': board_sets[name], + 'architecture': description['arch'], 'image': description['image'], + 'loader': {'kind': description['loader'], 'file': str(Path(loader).resolve())}, + 'tools': {tool: str((target_tools / tool).resolve()) for tool in TOOLS}} + return {'schema': SCHEMA, 'orchard': orchard, 'architecture': architecture, + 'platforms': records, 'shared': shared_document} def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) parser.add_argument('--output', type=Path, required=True, help='the JSON document to write') + parser.add_argument('--device', help='restrict inputs to this board and its loader profile') parser.add_argument('--platform', action='append', help='a platform to build for; repeat (default: every one)') parser.add_argument('--kernel-dir', action='append', default=[], metavar='PLATFORM=DIRECTORY', help="a platform's staged kernel bundle instead of orchard's") parser.add_argument('--m1n1', type=Path, help="orchard's m1n1 to use as it is") parser.add_argument('--pongo', type=Path, help='the Pongo to use as it is') parser.add_argument('--tools', type=Path, help='a directory with the static tools to use as they are') + parser.add_argument('--platform-loader', action='append', default=[], metavar='PLATFORM=FILE', + help='an explicit payload loader for one platform') + parser.add_argument('--platform-tools', action='append', default=[], metavar='PLATFORM=DIRECTORY', + help='an explicit static tool directory for one platform') parser.add_argument('--kernel-tree', type=Path, help='build the kernels from this kernel tree ' '(default: ORCHARD_KERNEL_TREE, which orchard build --kernel-tree sets)') args = parser.parse_args() @@ -97,6 +141,16 @@ def main(): if not separator: parser.error('--kernel-dir takes PLATFORM=DIRECTORY') explicit[name] = Path(path) + def overrides(values, option): + result = {} + for value in values: + name, separator, path = value.partition('=') + if not separator or not name or not path or name in result: + parser.error(option + ' takes distinct PLATFORM=PATH values') + result[name] = Path(path) + return result + platform_loaders = overrides(args.platform_loader, '--platform-loader') + platform_tools = overrides(args.platform_tools, '--platform-tools') try: import os tree = args.kernel_tree or (Path(os.environ['ORCHARD_KERNEL_TREE']) if os.environ.get('ORCHARD_KERNEL_TREE') @@ -105,7 +159,8 @@ def main(): explain.start() try: document = resolve(args.platform, explicit, shared={'m1n1': args.m1n1, 'pongo': args.pongo, - 'tools': args.tools}, kernel_tree=tree) + 'tools': args.tools}, kernel_tree=tree, + platform_loaders=platform_loaders, platform_tools=platform_tools, device=args.device) finally: steps = explain.stop() except ValueError as error: diff --git a/src/orchard/project.py b/src/orchard/project.py index d084147a..3bcf8343 100644 --- a/src/orchard/project.py +++ b/src/orchard/project.py @@ -10,6 +10,7 @@ [release] build = "tools/build.py" # relative to this file selects-platform = true # optional: the script takes --platform + selects-device = true # optional: preserve the chosen board as --device The file says what the project builds, never which board it is for; a developer's choices (platform, device, kernel tree) are arguments or diff --git a/src/orchard/ram.py b/src/orchard/ram.py index d0ba1eac..ad74add4 100644 --- a/src/orchard/ram.py +++ b/src/orchard/ram.py @@ -20,8 +20,13 @@ IMAGE = Path(__file__).resolve().parent / 'image' # standalone scripts, run by path -def check_busybox(path): +def check_busybox(path, architecture='arm64'): data = path.read_bytes() + if architecture == 'arm': + from .image.elf import architecture as elf_architecture, _parts + if elf_architecture(data) != architecture or any(kind in (2, 3) for kind, *_ in _parts(data)[2]): + raise ValueError('BusyBox must be static ARM EABI5 hard-float ELF32') + return if len(data) < 64 or data[:6] != b'\x7fELF\x02\x01' or data[18:20] != b'\xb7\x00': raise ValueError('BusyBox must be little-endian AArch64 ELF64') offset = struct.unpack_from(' 128 * 1024 * 1024: raise ValueError('Payload exceeds the Pongo uploader limit (128 MiB)') - shutil.copyfile(pongo, work / 'pongo.bin') + if pongo is not None: + shutil.copyfile(pongo, work / 'pongo.bin') receipt = {'kind': 'orchard.ram-build', 'platform': platform, 'boards': sorted(boards), 'kernel_release': release, 'hardware_tested': False, # What `orchard run` needs to add a board's firmware layer. - 'assembly': {'loader': str(loader), 'kernel': str(kernel / 'Image.gz'), + 'assembly': {'loader': str(loader), 'kernel': str(kernel / described['image']), 'dtbs': [str(path) for path in dtbs], 'bootargs': bootargs}, - 'files': {name: digest(work / name) for name in ('linux.bin', 'pongo.bin')}, + 'files': {name: digest(work / name) for name in + (('linux.bin', 'pongo.bin') if pongo is not None else ('linux.bin',))}, 'sources': {str(p.resolve()): digest(p) for p in inputs}} - for name in ('linux.bin', 'linux.bin.json', 'pongo.bin', 'initramfs.cpio.gz'): + if profile is not None: + receipt['loader_format'] = 'cascadia' + receipt['boot_method'] = 'recovery-autogo' + receipt['assembly']['profile'] = str(profile) + for name in ('linux.bin', 'linux.bin.json', 'initramfs.cpio.gz', + *(('pongo.bin',) if pongo is not None else ())): os.replace(work / name, output / name) (output / 'build.json').write_text(json.dumps(receipt, indent=2) + '\n') print(f'Built {output / "linux.bin"}. No device was accessed.') return output -def assemble(bootargs, loader, dtbs, kernel, initramfs, output): - command(sys.executable, IMAGE / 'assemble.py', '--bootargs', bootargs, '--loader', loader, +def assemble(bootargs, loader, dtbs, kernel, initramfs, output, *, profile=None): + command(sys.executable, IMAGE / ('cascadia.py' if profile is not None else 'assemble.py'), + *(['--profile', profile] if profile is not None else []), + '--bootargs', bootargs, '--loader', loader, *(argument for dtb in dtbs for argument in ('--dtb', dtb)), '--kernel', kernel, '--initramfs', initramfs, '--output', output) @@ -218,7 +254,12 @@ def _build_directory(directory, *, hardware, cache, kernel, busybox, loader, pon hardware = Path(hardware).expanduser().resolve() platform = resolve_platform(directory, hardware, platform=platform, device=device) boards = boards_by_platform(hardware)[platform] - # Components are per platform; any of its boards selects them. + from .kernel import descriptor + if descriptor(hardware, platform)['loader'] == 'cascadia': + selected = build.resolve_device(directory, device) + if selected is None or selected not in boards: + raise ValueError('Select the Cascadia board with --device BOARD') + boards = {selected: boards[selected]} representative = sorted(boards)[0] cache = Path(cache).expanduser().resolve() if cache is not None else build.cache_root() output = directory / 'out' / platform @@ -302,10 +343,12 @@ def with_firmware(output, board, *, hardware=None, cache=None): image = work / 'image' image.mkdir() assemble(assembly['bootargs'], Path(assembly['loader']), [Path(path) for path in assembly['dtbs']], - Path(assembly['kernel']), combined, image / 'linux.bin') - shutil.copyfile(output / 'pongo.bin', image / 'pongo.bin') + Path(assembly['kernel']), combined, image / 'linux.bin', + profile=Path(assembly['profile']) if 'profile' in assembly else None) + if 'pongo.bin' in receipt['files']: + shutil.copyfile(output / 'pongo.bin', image / 'pongo.bin') layered = {**receipt, 'boards': [board], 'firmware': True, - 'files': {name: digest(image / name) for name in ('linux.bin', 'pongo.bin')}} + 'files': {name: digest(image / name) for name in receipt['files']}} layered.pop('overlay', None) layered['sources'] = {} (image / 'build.json').write_text(json.dumps(layered, indent=2) + '\n') @@ -318,7 +361,8 @@ def verify_ram(output): if 'device' in receipt and 'boards' not in receipt: # built before payloads were per platform receipt['boards'], receipt['platform'] = [receipt['device']], None if (receipt.get('kind') not in (None, 'orchard.ram-build') or not receipt.get('boards') or - set(receipt['files']) != {'linux.bin', 'pongo.bin'}): + set(receipt['files']) != ({'linux.bin'} if receipt.get('loader_format') == 'cascadia' else + {'linux.bin', 'pongo.bin'})): raise ValueError('Not a RAM-boot build') for name, expected in receipt['files'].items(): path = output / name diff --git a/src/orchard/recovery_identity.py b/src/orchard/recovery_identity.py new file mode 100644 index 00000000..c42028d8 --- /dev/null +++ b/src/orchard/recovery_identity.py @@ -0,0 +1,144 @@ +"""Bounded boot identity parameters shared by payloads and RAM recovery.""" +import base64 +import binascii +import json +import re + +POLICY_PARAMETER = 'orchard.recovery-policy' +HANDOFF_PARAMETER = 'orchard.recovery-handoff' +MAX_ENCODED_BYTES = 2048 +MAX_CMDLINE_BYTES = 1023 +POLICY_FIELDS = frozenset(('schema', 'source', 'profile', 'compatibles', 'chip', 'board')) +POLICY_SOURCES_FIELDS = POLICY_FIELDS - {'source'} | {'sources'} +SOURCES = frozenset(('host-usb', 'ios-device')) +HANDOFF_FIELDS = frozenset(('schema', 'source', 'profile', 'ecid', 'chip', 'board', + 'handoff_id', 'template_sha256')) + + +def _pairs(items): + result = {} + for key, value in items: + if key in result: + raise ValueError('Identity JSON repeats a field') + result[key] = value + return result + + +def object_json(raw): + """Decode one bounded JSON object with no duplicate keys or NUL bytes.""" + if not isinstance(raw, bytes) or len(raw) > MAX_ENCODED_BYTES or b'\0' in raw: + raise ValueError('Identity JSON exceeds its bound or contains a NUL') + try: + result = json.loads(raw.decode('utf-8'), object_pairs_hook=_pairs) + except (UnicodeError, json.JSONDecodeError) as error: + raise ValueError('Invalid identity JSON') from error + if not isinstance(result, dict): + raise ValueError('Identity JSON must be an object') + return result + + +def _selected(result, fields, schema=1): + if (not isinstance(result, dict) or set(result) != fields or + type(result.get('schema')) is not int or result['schema'] != schema or + not isinstance(result.get('profile'), str) or + not re.fullmatch(r'[a-z0-9][a-z0-9._-]*', result['profile'])): + raise ValueError('Unsupported recovery identity fields') + if (type(result.get('chip')) is not int or not 0 < result['chip'] <= 0xffffffff or + type(result.get('board')) is not int or not 0 <= result['board'] <= 0xffffffff): + raise ValueError('Recovery identity has invalid chip identity') + + +def validate_policy(result): + if isinstance(result, dict) and result.get('schema') == 2: + _selected(result, POLICY_SOURCES_FIELDS, 2) + sources = result['sources'] + if (not isinstance(sources, list) or not 1 <= len(sources) <= len(SOURCES) or + any(not isinstance(value, str) or value not in SOURCES for value in sources) or + len(set(sources)) != len(sources)): + raise ValueError('Recovery identity policy has invalid sources') + else: + _selected(result, POLICY_FIELDS) + if result['source'] != 'host-usb': + raise ValueError('Schema-1 recovery identity policy requires host-usb') + compatibles = result.get('compatibles') + if (not isinstance(compatibles, list) or not 1 <= len(compatibles) <= 32 or + any(not isinstance(value, str) or not re.fullmatch(r'apple,[a-zA-Z0-9._+-]{1,120}', value) + for value in compatibles) or len(set(compatibles)) != len(compatibles)): + raise ValueError('Recovery identity policy has invalid compatibles') + return result + + +def allows_source(policy, source): + """An origin is accepted only when the staged descriptor permits it.""" + validate_policy(policy) + return source in (policy['sources'] if policy['schema'] == 2 else [policy['source']]) + + +def validate_handoff(result): + _selected(result, HANDOFF_FIELDS) + if not isinstance(result['source'], str) or result['source'] not in SOURCES: + raise ValueError('Recovery handoff has an unsupported source') + if type(result.get('ecid')) is not int or not 0 < result['ecid'] < 2**64: + raise ValueError('Host USB handoff has an invalid ECID') + handoff_id, template = result.get('handoff_id'), result.get('template_sha256') + if (not isinstance(handoff_id, str) or not re.fullmatch(r'[0-9a-f]{32}', handoff_id) or + int(handoff_id, 16) == 0 or not isinstance(template, str) or + not re.fullmatch(r'[0-9a-f]{64}', template)): + raise ValueError('Host USB handoff has invalid attempt provenance') + return result + + +def _validate(name, value): + if name == POLICY_PARAMETER: + return validate_policy(value) + if name == HANDOFF_PARAMETER: + return validate_handoff(value) + raise ValueError('Unknown recovery identity parameter') + + +def encode_parameter(name, value): + value = _validate(name, value) + raw = json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode('ascii') + encoded = base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') + if len(encoded) > MAX_ENCODED_BYTES: + raise ValueError('Recovery identity token exceeds its bound') + return name + '=' + encoded + + +def _decode(name, value): + if not 1 <= len(value) <= MAX_ENCODED_BYTES or not re.fullmatch(r'[A-Za-z0-9_-]+', value): + raise ValueError('Invalid recovery handoff token') + try: + raw = base64.b64decode(value + '=' * (-len(value) % 4), altchars=b'-_', validate=True) + except (ValueError, binascii.Error) as error: + raise ValueError('Invalid recovery handoff encoding') from error + if base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') != value: + raise ValueError('Recovery handoff encoding is not canonical') + return _validate(name, object_json(raw)) + + +def parameters(cmdline): + """Return present identity parameters; callers decide which ones are required.""" + if isinstance(cmdline, bytes): + try: + cmdline = cmdline.decode('ascii') + except UnicodeError as error: + raise ValueError('Recovery command line is not ASCII') from error + if not isinstance(cmdline, str) or not cmdline.isascii(): + raise ValueError('Recovery command line is not ASCII') + # /proc/cmdline adds one newline to the kernel's bootargs string. + if cmdline.endswith('\n'): + cmdline = cmdline[:-1] + if len(cmdline) > MAX_CMDLINE_BYTES or any(value in cmdline for value in ('\0', '\r', '\n')): + raise ValueError('Recovery command line exceeds its bound or contains a control character') + result = {} + for token in cmdline.split(): + name, separator, value = token.partition('=') + if name not in (POLICY_PARAMETER, HANDOFF_PARAMETER): + if name.startswith('orchard.recovery-'): + raise ValueError('Unknown recovery identity parameter') + continue + if name in result or not separator: + raise ValueError('Repeated or incomplete recovery identity parameter') + result[name] = _decode(name, value) + return result diff --git a/src/orchard/recovery_policy.py b/src/orchard/recovery_policy.py new file mode 100644 index 00000000..7d48fba2 --- /dev/null +++ b/src/orchard/recovery_policy.py @@ -0,0 +1,42 @@ +"""Descriptor-selected recovery identity and early runtime policy.""" +from pathlib import Path +import re +import tomllib + + +def settings(board, hardware=None): + from .hardware import ROOT, boot_recipe, device + from .kernel import descriptor + from .recovery_identity import validate_policy + hardware = Path(hardware or ROOT) + selected = device(board, hardware) + platform = descriptor(hardware, board)['platform'] + table = tomllib.loads((hardware / 'platforms' / (platform + '.toml')).read_text()).get('recovery') + if table is None: + return None + expected = {'identity_source', 'usb_network', 'usb_modules', 'manual_modules', 'storage_activation'} + if (not isinstance(table, dict) or set(table) != expected or table['identity_source'] != 'host-usb' or + table['usb_network'] not in ('ecm', 'ncm') or table['storage_activation'] not in ('manual', 'automatic')): + raise ValueError('Invalid platform recovery policy') + modules = table['usb_modules'] + for key in ('usb_modules', 'manual_modules'): + names = table[key] + if (not isinstance(names, list) or any(not isinstance(name, str) or not re.fullmatch('[a-z0-9_-]+', name) for name in names) or + len(set(names)) != len(names)): + raise ValueError('Recovery modules must be distinct module names') + normalized = lambda names: {name.replace('-', '_') for name in names} + if normalized(table['usb_modules']) & normalized(table['manual_modules']): + raise ValueError('A manual module cannot be loaded during USB setup') + identity = {'schema': 1, 'source': 'host-usb', 'profile': selected['device']['profile'], + 'compatibles': selected['device']['dt_compatible'], 'chip': selected['match']['dfu']['chip'], + 'board': selected['match']['dfu']['board']} + if 'ios-local-file' in selected.get('boot', {}).get('methods', ()): + local = boot_recipe(board, hardware).get('methods', {}).get('ios-local-file', {}) + if not isinstance(local, dict) or local.get('identity_source') != 'ios-device': + raise ValueError('The on-device boot recipe lacks an explicit ios-device identity source') + identity.pop('source') + identity.update(schema=2, sources=['host-usb', 'ios-device']) + identity = validate_policy(identity) + runtime = {'schema': 1, 'profile': identity['profile'], 'usb_network': table['usb_network'], + 'usb_modules': modules, 'manual_modules': table['manual_modules'], 'storage_activation': table['storage_activation']} + return {'identity': identity, 'runtime': runtime} diff --git a/src/orchard/transports/ssh.py b/src/orchard/transports/ssh.py index 9f3a76bd..e8412570 100644 --- a/src/orchard/transports/ssh.py +++ b/src/orchard/transports/ssh.py @@ -25,7 +25,7 @@ def host_key(text): return matches[0] -def bootstrap_reply(code, text, scope): +def bootstrap_reply(code, text, scope, handoff=None): records = [json.loads(line) for line in text.splitlines() if line.strip()] if not records or any(not isinstance(row, dict) or row.get('protocol') != PROTOCOL for row in records): raise ValueError('Incompatible recovery SSH bootstrap protocol') @@ -42,6 +42,8 @@ def bootstrap_reply(code, text, scope): raise ValueError('Invalid recovery SSH bootstrap result') if not scope.matches(result) or result.get('profile') != scope.device.profile: raise ValueError('Serial SSH bootstrap identity differs from the selected device/boot') + if handoff is not None and not handoff.matches(result): + raise ValueError('Serial SSH bootstrap differs from the host-bound handoff') host = str(ipaddress.IPv4Address(result['host'])) port = result['port'] if type(port) is not int or not 0 < port < 65536: @@ -86,13 +88,15 @@ def wait_authenticated(argv, check, seconds=30): time.sleep(min(0.25, remaining)) -def endpoint_check(scope, endpoint, select_endpoint): +def endpoint_check(scope, endpoint, select_endpoint, handoff=None): address = (endpoint['location'], endpoint['serial_port']) def check(): current = select_endpoint() if (not scope.matches(current) or - (current['location'], current['serial_port']) != address): + (current['location'], current['serial_port']) != address or + (handoff is not None and (current.get('handoff_id') != handoff.handoff_id or + (current.get('identity_source') or 'host-usb') != handoff.identity_source))): raise ValueError('Selected device/boot or USB endpoint changed') return check @@ -101,6 +105,8 @@ def verify_peer(connection): status = Client(connection, Progress()).call('status', timeout=8) if not connection.scope.matches(status) or status.get('profile') != connection.scope.device.profile: raise ValueError('Pinned SSH peer differs from the selected device/boot') + if connection.handoff is not None and not connection.handoff.matches(status): + raise ValueError('Pinned SSH peer differs from the host-bound handoff') connection.check() @@ -134,12 +140,18 @@ def one_connection(identity, hosts, host, port, *, alias=None): @contextmanager -def session(device: DeviceState, select_endpoint, *, expected_boot=None): +def session(device: DeviceState, select_endpoint, *, expected_boot=None, handoff=None): endpoint = select_endpoint() scope = BootState(device, endpoint['boot_id']) if not scope.matches(endpoint) or (expected_boot is not None and scope.boot_id != expected_boot): raise ValueError('Recovery identity differs from the selected device/boot') - check = endpoint_check(scope, endpoint, select_endpoint) + if handoff is not None and endpoint.get('handoff_id') != handoff.handoff_id: + raise ValueError('Recovery differs from the selected host-bound handoff') + if handoff is not None and (endpoint.get('identity_source') or 'host-usb') != handoff.identity_source: + raise ValueError('Recovery differs from the selected identity origin') + if handoff is None and endpoint.get('handoff_id') is not None: + raise ValueError('Recovery requires its completed host handoff receipt') + check = endpoint_check(scope, endpoint, select_endpoint, handoff) port = endpoint['serial_port'] work = private_directory(scope.root / 'sessions') with tempfile.TemporaryDirectory(prefix='ssh-', dir=work) as directory: @@ -153,10 +165,11 @@ def session(device: DeviceState, select_endpoint, *, expected_boot=None): identity.chmod(0o600) payload = base64.urlsafe_b64encode(json.dumps({ 'protocol': PROTOCOL, 'profile': device.profile, 'ecid': device.ecid, - 'boot_id': scope.boot_id, 'public_key': ' '.join(public[:2])}).encode()).decode() + 'boot_id': scope.boot_id, 'public_key': ' '.join(public[:2]), + **(handoff.fields() if handoff is not None else {})}).encode()).decode() with SerialPort(port, check) as serial: code, text = serial.run('/usr/bin/orchard-device bootstrap ' + payload, timeout=20) - host, ssh_port, pinned_key = bootstrap_reply(code, text, scope) + host, ssh_port, pinned_key = bootstrap_reply(code, text, scope, handoff) check() hosts = folder / 'known_hosts' name = host if ssh_port == 22 else f'[{host}]:{ssh_port}' @@ -164,7 +177,6 @@ def session(device: DeviceState, select_endpoint, *, expected_boot=None): hosts.chmod(0o600) wait_authenticated(ssh_argv(identity, hosts, host, ssh_port), check) with one_connection(identity, hosts, host, ssh_port) as argv: - connection = Connection(argv, scope, check) + connection = Connection(argv, scope, check, handoff) verify_peer(connection) yield connection - diff --git a/tests/fixtures/cascadia-runtime.c b/tests/fixtures/cascadia-runtime.c new file mode 100644 index 00000000..791d542f --- /dev/null +++ b/tests/fixtures/cascadia-runtime.c @@ -0,0 +1,131 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Exercise the producer's actual C entry against anonymous mapped RAM/MMIO. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +/* The assembler patches this record in writable upload RAM on the device. */ +#define const +#include "linux_boot.c" +#undef const + +static unsigned int checks; + +#define CHECK(test) do { \ + checks++; \ + if (!(test)) { fprintf(stderr, "line %d: %s\n", __LINE__, #test); exit(1); } \ +} while (0) + +static void *memory(uintptr_t address, size_t bytes) +{ + void *value = mmap((void *)address, bytes, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0); + CHECK(value == (void *)address); + return value; +} + +static void prepare(void) +{ + struct config value = { + { 'C', 'B', '3', '2' }, 1, sizeof(struct config), LOAD_ADDRESS, + KERNEL_OFFSET, 0x9000, 40, 0xa000, 33, INITRD_ADDRESS, + 0xa000 + 33, WATCHDOG_ADDRESS, L2_ADDRESS, + RAM_START, RAM_END, INITRD_LIMIT, 0, + }; + memcpy((void *)&cascadia_config, &value, sizeof(value)); + unsigned char *ram = (void *)(uintptr_t)LOAD_ADDRESS; + memset(ram, 0, 0x20000); + ram[0x9000] = 0xd0; + ram[0x9001] = 0x0d; + ram[0x9002] = 0xfe; + ram[0x9003] = 0xed; + ram[0x9007] = 40; + for (unsigned int i = 0; i < 33; i++) + ram[0xa000 + i] = (unsigned char)(i ^ 0x5a); + memset((void *)(uintptr_t)INITRD_ADDRESS, 0x77, 0x10000); + memset((void *)(uintptr_t)WATCHDOG_ADDRESS, 0xa5, 16); + cascadia_config.crc32 = checksum(); +} + +static void rejects(void) +{ + CHECK(cascadia_main() == 0); + for (unsigned int i = 0; i < 33; i++) + CHECK(((unsigned char *)(uintptr_t)INITRD_ADDRESS)[i] == 0x77); + for (unsigned int i = 0; i < 16; i++) + CHECK(((unsigned char *)(uintptr_t)WATCHDOG_ADDRESS)[i] == 0); +} + +int main(void) +{ + CHECK(sizeof(u32) == 4 && sizeof(struct config) == 68); + memory(LOAD_ADDRESS, 0x2000000); + memory(INITRD_ADDRESS, 0x2000000); + memory(WATCHDOG_ADDRESS & ~4095u, 4096); + prepare(); + /* Validation must work even when high RAM has no inherited mapping. */ + CHECK(mprotect((void *)(uintptr_t)INITRD_ADDRESS, 0x2000000, PROT_NONE) == 0); + CHECK(cascadia_main() == LOAD_ADDRESS + 0x9000); + CHECK(mprotect((void *)(uintptr_t)INITRD_ADDRESS, 0x2000000, + PROT_READ | PROT_WRITE) == 0); + for (unsigned int i = 0; i < 33; i++) + CHECK(((unsigned char *)(uintptr_t)INITRD_ADDRESS)[i] == 0x77); + cascadia_relocate_initrd(LOAD_ADDRESS + 0xa000, INITRD_ADDRESS, 33); + for (unsigned int i = 0; i < 33; i++) + CHECK(((unsigned char *)(uintptr_t)INITRD_ADDRESS)[i] == (i ^ 0x5a)); + for (unsigned int i = 0; i < 16; i++) + CHECK(((unsigned char *)(uintptr_t)WATCHDOG_ADDRESS)[i] == 0); + CHECK(((unsigned char *)(uintptr_t)INITRD_ADDRESS)[33] == 0x77); + + /* Exercise exact word/tail sizes and the real attempt-3 initramfs length. */ + const unsigned int sizes[] = { 0, 1, 2, 3, 4, 7, 8, 33, 4096, 4097, 19124158 }; + unsigned char *source = (void *)(uintptr_t)(LOAD_ADDRESS + 0xa000); + unsigned char *target = (void *)(uintptr_t)(INITRD_ADDRESS + 16); + unsigned char *guard = (void *)(uintptr_t)(INITRD_ADDRESS + 12); + for (unsigned int test = 0; test < sizeof(sizes) / sizeof(sizes[0]); test++) { + unsigned int size = sizes[test]; + for (unsigned int i = 0; i < size + 4; i++) + source[i] = (unsigned char)(i ^ (i >> 8) ^ 0x5a); + memset(target - 4, 0x77, size + 8); + cascadia_relocate_initrd(LOAD_ADDRESS + 0xa000, INITRD_ADDRESS + 16, size); + CHECK(memcmp(source, target, size) == 0); + for (unsigned int i = 0; i < 4; i++) { + CHECK(guard[i] == 0x77); + CHECK(target[size + i] == 0x77); + } + } + + /* Every field is either profile-fixed or has a checked dynamic bound. */ + volatile u32 *fields = (volatile u32 *)&cascadia_config; + for (unsigned int i = 0; i < sizeof(struct config) / sizeof(u32); i++) { + prepare(); + fields[i] = 0xffffffffu; + if (i != 16) + cascadia_config.crc32 = checksum(); + rejects(); + } + prepare(); + cascadia_config.dtb_offset = KERNEL_OFFSET - 4; + cascadia_config.crc32 = checksum(); + rejects(); + prepare(); + cascadia_config.initrd_offset = 0x9000; + cascadia_config.crc32 = checksum(); + rejects(); + prepare(); + cascadia_config.initrd_offset++; + cascadia_config.image_size++; + cascadia_config.crc32 = checksum(); + rejects(); + prepare(); + ((unsigned char *)(uintptr_t)(LOAD_ADDRESS + 0x9000))[3] ^= 1; + rejects(); + prepare(); + ((unsigned char *)(uintptr_t)(LOAD_ADDRESS + 0x9000))[7] ^= 1; + rejects(); + printf("%u runtime checks passed\n", checks); + return 0; +} diff --git a/tests/fixtures/ios-attempt-files.c b/tests/fixtures/ios-attempt-files.c new file mode 100644 index 00000000..4fa28adb --- /dev/null +++ b/tests/fixtures/ios-attempt-files.c @@ -0,0 +1,252 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Exercise the extracted production file preparation with local files only. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ios-identity-bind.h" +#include "ios-attempt-config.h" + +static char root_hash[65], ibec_hash[65]; +static uint32_t template_crc; +static const char *scenario; +static unsigned int reads, writes, file_syncs, directory_opens, directory_syncs; +static unsigned int directory_closes; +static unsigned int renames, random_calls; +static int close_fault; + +static int is(const char *name) { return !strcmp(scenario, name); } + +static int fixture_fstat(int fd, struct stat *info) +{ + int result = fstat(fd, info); + if (!result) + info->st_uid = is("wrong-owner") ? 501 : 0; + return result; +} + +static ssize_t fixture_pread(int fd, void *bytes, size_t length, off_t offset) +{ + reads++; + if (is("failed-read")) { errno = EIO; return -1; } + if (is("short-read-zero") && reads > 1) return 0; + if ((is("short-read-zero") || is("partial-read")) && length > 7) length = 7; + return pread(fd, bytes, length, offset); +} + +static ssize_t fixture_write(int fd, const void *bytes, size_t length) +{ + writes++; + if (is("failed-write") || (is("second-write-failed") && writes == 2) || + (is("short-write-error") && writes > 1)) { errno = EIO; return -1; } + if (is("zero-write") || (is("short-write-zero") && writes > 1)) return 0; + if ((is("short-write-zero") || is("short-write-error") || is("partial-write")) && length > 7) + length = 7; + return write(fd, bytes, length); +} + +static int fixture_open(const char *path, int flags, ...) +{ + mode_t mode = 0; + if (flags & O_CREAT) { + va_list arguments; + va_start(arguments, flags); + mode = (mode_t)va_arg(arguments, int); + va_end(arguments); + } + if (flags & O_DIRECTORY) { + directory_opens++; + if (is("failed-directory-open") || + (is("second-directory-open-failed") && directory_opens == 2)) { + errno = EIO; + return -1; + } + } + return flags & O_CREAT ? open(path, flags, mode) : open(path, flags); +} + +static int fixture_fsync(int fd) +{ + struct stat info; + assert(!fstat(fd, &info)); + if (S_ISDIR(info.st_mode)) { + directory_syncs++; + if (is("failed-directory-sync") || + (is("second-directory-sync-failed") && directory_syncs == 2)) { + errno = EIO; + return -1; + } + } else { + file_syncs++; + if (is("failed-sync")) { errno = EIO; return -1; } + } + return fsync(fd); +} + +static int fixture_close(int fd) +{ + struct stat info; + int directory = !fstat(fd, &info) && S_ISDIR(info.st_mode); + int result = close(fd); + if (directory) { + directory_closes++; + if (is("failed-directory-close") || + (is("second-directory-close-failed") && directory_closes == 2)) { + errno = EIO; + return -1; + } + } + if (is("failed-write-close") && writes && !close_fault) { + close_fault = 1; + errno = EIO; + return -1; + } + return result; +} + +static int fixture_rename(const char *source, const char *target) +{ + renames++; + if (is("failed-rename") || (is("second-rename-failed") && renames == 2)) { + errno = EIO; + return -1; + } + return rename(source, target); +} + +static void fixture_random(void *output, size_t length) +{ + assert(length == 16); + random_calls++; + for (size_t i = 0; i < length; i++) + ((unsigned char *)output)[i] = is("zero-nonce") ? 0 : + (unsigned char)(i + (is("fresh-nonce") ? 17 : 1)); +} + +#define open fixture_open +#define fstat fixture_fstat +#define pread fixture_pread +#define write fixture_write +#define fsync fixture_fsync +#define close fixture_close +#define rename fixture_rename +#define arc4random_buf fixture_random +#include "ios-attempt-source.inc" +#undef open +#undef fstat +#undef pread +#undef write +#undef fsync +#undef close +#undef rename +#undef arc4random_buf + +static void put_file(const char *path, const unsigned char *bytes, size_t length) +{ + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, 0600); + assert(fd >= 0); + size_t at = 0; + while (at < length) { + ssize_t count = write(fd, bytes + at, length - at); + assert(count > 0); + at += (size_t)count; + } + assert(!close(fd)); +} + +static unsigned char *get_file(const char *path, size_t length) +{ + unsigned char *bytes = malloc(length); + assert(bytes); + int fd = open(path, O_RDONLY | O_NOFOLLOW); + assert(fd >= 0); + size_t at = 0; + while (at < length) { + ssize_t count = read(fd, bytes + at, length - at); + assert(count > 0); + at += (size_t)count; + } + assert(!close(fd)); + return bytes; +} + +static int exists(const char *path) +{ + struct stat info; + return !lstat(path, &info); +} + +int main(int argc, char **argv) +{ + assert(argc == 2); + scenario = argv[1]; + unsigned char *boot = get_file(FIXTURE_BOOT_SOURCE, ROOT_BOOT_BYTES); + unsigned char *ibec = get_file(FIXTURE_IBEC_SOURCE, IBEC_BYTES); + assert(!orchard_ios_crc32(boot, ROOT_BOOT_BYTES, &template_crc)); + if (is("bad-template-crc")) boot[0] ^= 1; + if (is("nonspace-slot")) { + boot[IOS_IDENTITY_SLOT_OFFSET] = 'x'; + assert(!orchard_ios_crc32(boot, ROOT_BOOT_BYTES, &template_crc)); + for (unsigned int i = 0; i < 4; i++) + ibec[IOS_IBEC_CRC_OFFSET + i] = (unsigned char)(template_crc >> (i * 8)); + } + if (is("bad-ibec-crc")) ibec[IOS_IBEC_CRC_OFFSET] ^= 1; + sha_text(boot, ROOT_BOOT_BYTES, root_hash); + sha_text(ibec, IBEC_BYTES, ibec_hash); + if (is("bad-sha")) memset(root_hash, '0', 64); + unlink(ROOT_BOOT_PATH); + unlink(IBEC_PATH); + unlink(ATTEMPT_BOOT_PATH); + unlink(ATTEMPT_IBEC_PATH); + unlink(ATTEMPT_BOOT_PATH ".pending"); + unlink(ATTEMPT_IBEC_PATH ".pending"); + put_file(ROOT_BOOT_PATH, boot, ROOT_BOOT_BYTES); + put_file(IBEC_PATH, ibec, IBEC_BYTES); + const unsigned char victim[] = "untouched-owner-local-victim"; + if (is("linked-pending")) { + put_file(FIXTURE_VICTIM_PATH, victim, sizeof(victim)); + assert(!symlink(FIXTURE_VICTIM_PATH, ATTEMPT_BOOT_PATH ".pending")); + } + if (is("input-symlink")) { + unlink(ROOT_BOOT_PATH); + assert(!symlink(FIXTURE_BOOT_SOURCE, ROOT_BOOT_PATH)); + } + if (is("wrong-mode")) assert(!chmod(ROOT_BOOT_PATH, 0622)); + int result = prepare_attempt(UINT64_C(0x0102030405060708)); + assert(random_calls == 1); + if (!is("input-symlink")) { + unsigned char *after = get_file(ROOT_BOOT_PATH, ROOT_BOOT_BYTES); + assert(!memcmp(boot, after, ROOT_BOOT_BYTES)); + free(after); + } + unsigned char *after = get_file(IBEC_PATH, IBEC_BYTES); + assert(!memcmp(ibec, after, IBEC_BYTES)); + free(after); + if (is("linked-pending")) { + after = get_file(FIXTURE_VICTIM_PATH, sizeof(victim)); + assert(!memcmp(victim, after, sizeof(victim))); + free(after); + struct stat info; + assert(!lstat(ATTEMPT_BOOT_PATH ".pending", &info) && S_ISLNK(info.st_mode)); + } else assert(!exists(ATTEMPT_BOOT_PATH ".pending")); + assert(!exists(ATTEMPT_IBEC_PATH ".pending")); + printf("{\"case\":\"%s\",\"result\":%d,\"reads\":%u,\"writes\":%u," + "\"file_syncs\":%u,\"directory_opens\":%u,\"directory_syncs\":%u," + "\"directory_closes\":%u," + "\"renames\":%u,\"random_calls\":%u,\"boot_exists\":%s,\"ibec_exists\":%s}\n", + scenario, result, reads, writes, file_syncs, directory_opens, directory_syncs, + directory_closes, + renames, random_calls, + exists(ATTEMPT_BOOT_PATH) ? "true" : "false", exists(ATTEMPT_IBEC_PATH) ? "true" : "false"); + free(boot); + free(ibec); + return 0; +} diff --git a/tests/fixtures/ios-attempt-wake-order.c b/tests/fixtures/ios-attempt-wake-order.c new file mode 100644 index 00000000..7b1ec136 --- /dev/null +++ b/tests/fixtures/ios-attempt-wake-order.c @@ -0,0 +1,222 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Compile actual startup main/wake code with inert identity and power APIs. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static unsigned int checked_calls, live_calls, lease_calls, prepare_calls; +static unsigned int api_calls, schedule_calls, cancel_calls, copy_calls, spawn_calls; +static int live_error, prepare_error, lease_error, hash_error, inspect_mode, lease_open; +static CFDateRef saved_date; +static CFStringRef saved_owner, saved_type; + +static int checked(const char *path, size_t length, const char *hash) +{ + (void)path; (void)length; (void)hash; + assert(!live_calls && !lease_calls && !prepare_calls && !api_calls); + checked_calls++; + return hash_error == (int)checked_calls; +} + +static int orchard_ios_live_identity(uint64_t *ecid) +{ + assert(checked_calls == 3 && !live_calls && !lease_calls && !prepare_calls && !api_calls); + live_calls++; + if (live_error) return -1; + *ecid = UINT64_C(0x0102030405060708); + return 0; +} + +static int boot_lease(void) +{ + assert(live_calls == 1 && !lease_calls && !prepare_calls && !api_calls); + lease_calls++; + if (lease_error) return -1; + lease_open = 1; + return 99; +} + +static int prepare_attempt(uint64_t ecid) +{ + assert(ecid == UINT64_C(0x0102030405060708)); + assert(live_calls == 1 && lease_calls == 1 && lease_open && !prepare_calls && !api_calls); + prepare_calls++; + return prepare_error ? -1 : 0; +} + +static kern_return_t fake_schedule(CFDateRef date, CFStringRef owner, CFStringRef type) +{ + assert(live_calls == 1 && prepare_calls == 1 && lease_open && !inspect_mode); + assert(!schedule_calls && !spawn_calls && !cancel_calls); + schedule_calls++; + saved_date = CFRetain(date); + saved_owner = CFRetain(owner); + saved_type = CFRetain(type); + return 0; +} + +static kern_return_t fake_cancel(CFDateRef date, CFStringRef owner, CFStringRef type) +{ + assert(live_calls == 1 && prepare_calls == 1 && lease_open); + assert(schedule_calls == 1 && !cancel_calls); + assert(date == saved_date && owner == saved_owner && type == saved_type); + cancel_calls++; + return 0; +} + +static CFArrayRef fake_copy(void) +{ + assert(schedule_calls == 1 && !copy_calls && !spawn_calls && !cancel_calls); + copy_calls++; + const void *keys[] = {CFSTR("time"), CFSTR("scheduledby"), CFSTR("eventtype")}; + const void *values[] = {saved_date, saved_owner, saved_type}; + CFDictionaryRef event = CFDictionaryCreate(NULL, keys, values, 3, + &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + const void *events[] = {event}; + CFArrayRef result = CFArrayCreate(NULL, events, 1, &kCFTypeArrayCallBacks); + CFRelease(event); + return result; +} + +static void *fake_dlopen(const char *path, int flags) +{ + assert(!strcmp(path, "/System/Library/Frameworks/IOKit.framework/IOKit")); + assert(flags == (RTLD_NOW | RTLD_LOCAL)); + assert(live_calls == 1 && !api_calls && !schedule_calls && !spawn_calls); + assert(inspect_mode ? !prepare_calls && !lease_calls : prepare_calls == 1 && lease_open); + api_calls++; + return (void *)(uintptr_t)1; +} + +static void *fake_dlsym(void *handle, const char *name) +{ + assert(handle == (void *)(uintptr_t)1); + if (!strcmp(name, "IOPMSchedulePowerEvent")) return (void *)fake_schedule; + if (!strcmp(name, "IOPMCancelScheduledPowerEvent")) return (void *)fake_cancel; + if (!strcmp(name, "IOPMCopyScheduledPowerEvents")) return (void *)fake_copy; + assert(0); + return NULL; +} + +static int fake_dlclose(void *handle) { assert(handle == (void *)(uintptr_t)1); return 0; } + +static int fake_spawn(pid_t *child, const char *path, const posix_spawn_file_actions_t *actions, + const posix_spawnattr_t *attributes, char *const arguments[], char *const environment[]) +{ + (void)child; (void)environment; + assert(live_calls == 1 && prepare_calls == 1 && schedule_calls == 1 && copy_calls == 1); + assert(!spawn_calls && !cancel_calls && lease_open && !actions && !attributes); + assert(!strcmp(path, "/fixture/kloader") && !strcmp(arguments[0], path)); + assert(!strcmp(arguments[1], "/fixture/attempt-ibec.raw") && !arguments[2]); + spawn_calls++; + return EACCES; +} + +static pid_t fake_wait(pid_t child, int *status, int flags) +{ + (void)child; (void)status; (void)flags; + assert(0); + return -1; +} + +static int fake_close(int fd) { assert(fd == 99 && lease_open); lease_open = 0; return 0; } +static int fake_sysctl(const char *name, void *out, size_t *length, void *newp, size_t newlen) +{ + assert(!newp && !newlen); + const char *value = !strcmp(name, "hw.machine") ? "iPad2,5" : "13G36"; + assert(!strcmp(name, "hw.machine") || !strcmp(name, "kern.osversion")); + size_t count = strlen(value) + 1; + assert(count <= *length); + memcpy(out, value, count); + *length = count; + return 0; +} +static uid_t fake_uid(void) { return 0; } +static int fake_setgid(gid_t value) { assert(!value); return 0; } +static int fake_setuid(uid_t value) { assert(!value); return 0; } +static kern_return_t fake_task(mach_port_t task, int pid, mach_port_t *port) +{ (void)task; assert(!pid); *port = 1; return 0; } +static kern_return_t fake_deallocate(mach_port_t task, mach_port_t port) +{ (void)task; assert(port == 1); return 0; } +static CFAbsoluteTime fake_now(void) { return 1000.75; } +static pid_t fake_pid(void) { return 4242; } + +#define TARGET_MACHINE "iPad2,5" +#define TARGET_IOS_BUILD "13G36" +#define ROOT_BOOT_PATH "/fixture/template.bin" +#define IBEC_PATH "/fixture/ibec.raw" +#define KLOADER_PATH "/fixture/kloader" +#define ATTEMPT_IBEC_PATH "/fixture/attempt-ibec.raw" +#define ROOT_BOOT_BYTES 1 +#define ROOT_BOOT_SHA256 "stub" +#define IBEC_BYTES 1 +#define IBEC_SHA256 "stub" +#define KLOADER_BYTES 1 +#define KLOADER_SHA256 "stub" +#define IOS_IDENTITY_SLOT_OFFSET 1 +#define WAKE_OWNER "org.orchard.fixture" +#define WAKE_DELAY_SECONDS 60 +#define dlopen fake_dlopen +#define dlsym fake_dlsym +#define dlclose fake_dlclose +#define posix_spawn fake_spawn +#define waitpid fake_wait +#define close fake_close +#define sysctlbyname fake_sysctl +#define geteuid fake_uid +#define setgid fake_setgid +#define setuid fake_setuid +#define task_for_pid fake_task +#define mach_port_deallocate fake_deallocate +#define CFAbsoluteTimeGetCurrent fake_now +#define getpid fake_pid +#define main inspected_main +#include "ios-attempt-wake-source.inc" +#undef main + +static void reset(void) +{ + if (saved_date) CFRelease(saved_date); + if (saved_owner) CFRelease(saved_owner); + if (saved_type) CFRelease(saved_type); + saved_date = NULL; saved_owner = saved_type = NULL; + checked_calls = live_calls = lease_calls = prepare_calls = 0; + api_calls = schedule_calls = cancel_calls = copy_calls = spawn_calls = 0; + live_error = prepare_error = lease_error = hash_error = inspect_mode = lease_open = 0; +} + +static void expect(const char *name, int result, unsigned int live, unsigned int leases, + unsigned int prepared, unsigned int scheduled, unsigned int spawned) +{ + char *arguments[] = {"helper", "--check-installed", NULL}; + assert(inspected_main(inspect_mode ? 2 : 1, arguments) == result); + assert(live_calls == live && lease_calls == leases && prepare_calls == prepared); + assert(schedule_calls == scheduled && cancel_calls == scheduled && spawn_calls == spawned); + assert(!lease_open); + printf("{\"case\":\"%s\",\"live\":%u,\"lease\":%u,\"prepare\":%u," + "\"schedule\":%u,\"spawn\":%u}\n", name, live_calls, lease_calls, prepare_calls, + schedule_calls, spawn_calls); +} + +int main(void) +{ + reset(); live_error = 1; expect("failed-live", 16, 1, 0, 0, 0, 0); assert(!api_calls); + reset(); prepare_error = 1; expect("failed-prepare", 16, 1, 1, 1, 0, 0); assert(!api_calls); + reset(); lease_error = 1; expect("failed-lease", 13, 1, 1, 0, 0, 0); assert(!api_calls); + reset(); hash_error = 2; expect("failed-hash", 12, 0, 0, 0, 0, 0); assert(!api_calls); + reset(); inspect_mode = 1; expect("inspect", 0, 1, 0, 0, 0, 0); assert(api_calls == 1); + reset(); expect("prepared-start", 15, 1, 1, 1, 1, 1); assert(api_calls == 1); + reset(); + puts("native-attempt-wake-order-passed"); + return 0; +} diff --git a/tests/fixtures/ios-boot-lease.c b/tests/fixtures/ios-boot-lease.c new file mode 100644 index 00000000..5905aaca --- /dev/null +++ b/tests/fixtures/ios-boot-lease.c @@ -0,0 +1,228 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Exercise the extracted production iOS boot lease with local files only. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char *fixture_lease_path; +static const char *fixture_scenario; +static struct timeval fixture_boot = {1700000000, 123456}; + +static int fixture_sysctl(const char *name, void *output, size_t *length, + void *input, size_t input_length) +{ + assert(!strcmp(name, "kern.boottime")); + assert(output && length && *length == sizeof(fixture_boot)); + assert(!input && !input_length); + memcpy(output, &fixture_boot, sizeof(fixture_boot)); + *length = sizeof(fixture_boot); + return 0; +} + +static int fixture_fstat(int fd, struct stat *info) +{ + int result = fstat(fd, info); + if (!result) + info->st_uid = !strcmp(fixture_scenario, "wrong-owner") ? 501 : 0; + return result; +} + +static ssize_t fixture_pread(int fd, void *bytes, size_t length, off_t offset) +{ + ssize_t result = pread(fd, bytes, length, offset); + if (!strcmp(fixture_scenario, "short-read") && result > 0) + return result - 1; + return result; +} + +static ssize_t fixture_write(int fd, const void *bytes, size_t length) +{ + if (!strcmp(fixture_scenario, "zero-write")) + return 0; + if (!strcmp(fixture_scenario, "short-write") && length > 1) + return write(fd, bytes, length - 1); + return write(fd, bytes, length); +} + +static int fixture_fsync(int fd) +{ + if (!strcmp(fixture_scenario, "failed-fsync")) { + errno = EIO; + return -1; + } + return fsync(fd); +} + +#define LEASE_PATH fixture_lease_path +#define sysctlbyname fixture_sysctl +#define fstat fixture_fstat +#define pread fixture_pread +#define write fixture_write +#define fsync fixture_fsync +#include "ios-boot-lease-source.inc" +#undef sysctlbyname +#undef fstat +#undef pread +#undef write +#undef fsync + +static void clear_lease(void) +{ + unlink(fixture_lease_path); +} + +static void put_lease(const void *bytes, size_t length, mode_t mode) +{ + clear_lease(); + int fd = open(fixture_lease_path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, mode); + assert(fd >= 0); + assert(write(fd, bytes, length) == (ssize_t)length); + assert(!close(fd)); +} + +static void expect_text(const char *expected) +{ + char actual[64] = {0}; + int fd = open(fixture_lease_path, O_RDONLY | O_NOFOLLOW); + assert(fd >= 0); + ssize_t count = read(fd, actual, sizeof(actual)); + assert(count == (ssize_t)strlen(expected)); + assert(!memcmp(actual, expected, (size_t)count)); + assert(!close(fd)); +} + +static void refused_state(const char *name, const void *bytes, size_t length, mode_t mode) +{ + fixture_scenario = name; + put_lease(bytes, length, mode); + assert(boot_lease() == -1); + printf("{\"case\":\"%s\",\"result\":-1}\n", name); +} + +int main(int argc, char **argv) +{ + assert(argc == 2); + fixture_lease_path = argv[1]; + clear_lease(); + + fixture_scenario = "first-acquisition"; + int fd = boot_lease(); + assert(fd >= 0); + expect_text("v1:1700000000:123456\n"); + assert(!close(fd)); + puts("{\"case\":\"first-acquisition\",\"result\":0}"); + + fixture_scenario = "same-boot-refusal"; + assert(boot_lease() == -1); + puts("{\"case\":\"same-boot-refusal\",\"result\":-1}"); + + fixture_scenario = "changed-boot"; + fixture_boot.tv_sec++; + fd = boot_lease(); + assert(fd >= 0); + expect_text("v1:1700000001:123456\n"); + assert(!close(fd)); + puts("{\"case\":\"changed-boot\",\"result\":0}"); + + fixture_scenario = "concurrent-lock"; + fixture_boot.tv_sec++; + fd = boot_lease(); + assert(fd >= 0); + assert(boot_lease() == -1); + assert(!close(fd)); + puts("{\"case\":\"concurrent-lock\",\"result\":-1}"); + + fixture_scenario = "canonical-minimum"; + put_lease("v1:1:0\n", 7, 0600); + fd = boot_lease(); + assert(fd >= 0); + assert(!close(fd)); + puts("{\"case\":\"canonical-minimum\",\"result\":0}"); + + static const char malformed_prefix[] = "broken\n"; + static const char malformed_v1[] = "v1:garbage\n"; + static const char noncanonical_v1[] = "v1:01700000000:123456\n"; + static const char trailing_bytes[] = "v1:1:2\nx"; + static const char seconds_overflow[] = "v1:9223372036854775808:2\n"; + static const char microseconds_range[] = "v1:1:1000000\n"; + static const char zero_seconds[] = "v1:0:2\n"; + static const char embedded_nul[] = {'v', '1', ':', '1', '\0', ':', '2', '\n'}; + refused_state("malformed-prefix", malformed_prefix, sizeof(malformed_prefix) - 1, 0600); + refused_state("malformed-v1", malformed_v1, sizeof(malformed_v1) - 1, 0600); + refused_state("noncanonical-v1", noncanonical_v1, sizeof(noncanonical_v1) - 1, 0600); + refused_state("embedded-nul", embedded_nul, sizeof(embedded_nul), 0600); + refused_state("trailing-bytes", trailing_bytes, sizeof(trailing_bytes) - 1, 0600); + refused_state("seconds-overflow", seconds_overflow, sizeof(seconds_overflow) - 1, 0600); + refused_state("microseconds-range", microseconds_range, + sizeof(microseconds_range) - 1, 0600); + refused_state("zero-seconds", zero_seconds, sizeof(zero_seconds) - 1, 0600); + refused_state("wrong-owner", "v1:1:2\n", 7, 0600); + refused_state("wrong-mode", "v1:1:2\n", 7, 0644); + + char oversized[64]; + memset(oversized, 'x', sizeof(oversized)); + refused_state("oversized-state", oversized, sizeof(oversized), 0600); + refused_state("short-read", "v1:1:2\n", 7, 0600); + + fixture_scenario = "symlink"; + clear_lease(); + char target[1024]; + int count = snprintf(target, sizeof(target), "%s.target", fixture_lease_path); + assert(count > 0 && count < (int)sizeof(target)); + unlink(target); + int target_fd = open(target, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600); + assert(target_fd >= 0); + assert(!close(target_fd)); + assert(!symlink(target, fixture_lease_path)); + assert(boot_lease() == -1); + assert(!unlink(fixture_lease_path)); + assert(!unlink(target)); + puts("{\"case\":\"symlink\",\"result\":-1}"); + + fixture_scenario = "short-write"; + clear_lease(); + fixture_boot.tv_sec++; + assert(boot_lease() == -1); + fixture_scenario = "short-write-residue"; + assert(boot_lease() == -1); + puts("{\"case\":\"short-write-residue\",\"result\":-1}"); + + fixture_scenario = "zero-write"; + clear_lease(); + fixture_boot.tv_sec++; + assert(boot_lease() == -1); + fixture_scenario = "zero-write-residue"; + assert(boot_lease() == -1); + puts("{\"case\":\"zero-write-residue\",\"result\":-1}"); + + fixture_scenario = "failed-fsync"; + clear_lease(); + fixture_boot.tv_sec++; + assert(boot_lease() == -1); + fixture_scenario = "failed-fsync-residue"; + assert(boot_lease() == -1); + puts("{\"case\":\"failed-fsync-residue\",\"result\":-1}"); + + fixture_scenario = "prepare-failure-consumes"; + clear_lease(); + fixture_boot.tv_sec++; + fd = boot_lease(); + assert(fd >= 0); + assert(!close(fd)); + /* A caller that fails preparation closes the lease before returning. */ + assert(boot_lease() == -1); + puts("{\"case\":\"prepare-failure-consumes\",\"result\":-1}"); + + clear_lease(); + puts("native-ios-boot-lease-passed"); + return 0; +} diff --git a/tests/fixtures/ios-identity-bind.c b/tests/fixtures/ios-identity-bind.c new file mode 100644 index 00000000..537b0819 --- /dev/null +++ b/tests/fixtures/ios-identity-bind.c @@ -0,0 +1,149 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +#include +#include +#include +#include "ios-identity-bind.h" + +enum { IMAGE_BYTES = 1536, SLOT_OFFSET = 128, SLOT_BYTES = 1023 }; +static const char nonce[] = "0123456789abcdef0123456789abcdef"; +static const char sha[] = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +static void fresh(unsigned char image[IMAGE_BYTES]) +{ + memset(image, 0xa5, IMAGE_BYTES); + memset(image + SLOT_OFFSET, ' ', SLOT_BYTES); +} + +static int bind(unsigned char *image, size_t length, size_t offset, size_t bytes, + const char *profile, uint64_t ecid, uint32_t chip, uint32_t board, + const char *identifier, const char *hash) +{ + return orchard_ios_bind_handoff(image, length, offset, bytes, profile, ecid, + chip, board, identifier, hash); +} + +static void rejected(size_t length, size_t offset, size_t bytes, const char *profile, + uint64_t ecid, uint32_t chip, uint32_t board, + const char *identifier, const char *hash) +{ + unsigned char image[IMAGE_BYTES], before[IMAGE_BYTES]; + fresh(image); + memcpy(before, image, sizeof(image)); + assert(bind(image, length, offset, bytes, profile, ecid, chip, board, identifier, hash) == -1); + assert(!memcmp(before, image, sizeof(image))); +} + +static size_t encoded(const char *profile, uint64_t ecid, uint32_t chip, uint32_t board, + const char *identifier, const char *hash) +{ + unsigned char image[IMAGE_BYTES], before[IMAGE_BYTES]; + fresh(image); + memcpy(before, image, sizeof(image)); + assert(!bind(image, sizeof(image), SLOT_OFFSET, SLOT_BYTES, profile, ecid, chip, + board, identifier, hash)); + assert(!memcmp(before, image, SLOT_OFFSET)); + assert(!memcmp(before + SLOT_OFFSET + SLOT_BYTES, image + SLOT_OFFSET + SLOT_BYTES, + sizeof(image) - SLOT_OFFSET - SLOT_BYTES)); + size_t bytes = SLOT_BYTES; + while (bytes && image[SLOT_OFFSET + bytes - 1u] == ' ') + bytes--; + assert(bytes > 0 && bytes < SLOT_BYTES); + printf("%.*s\n", (int)bytes, image + SLOT_OFFSET); + return bytes; +} + +static void failures(size_t token_bytes) +{ + char long_profile[82], unterminated[81]; + memset(long_profile, 'a', sizeof(long_profile)); + long_profile[81] = 0; + memset(unterminated, 'a', sizeof(unterminated)); + const char *profiles[] = {NULL, "", "A", ".bad", "a/b", "a\"b", "a\xff", long_profile, unterminated}; + for (size_t i = 0; i < sizeof(profiles) / sizeof(profiles[0]); i++) + rejected(IMAGE_BYTES, SLOT_OFFSET, SLOT_BYTES, profiles[i], 123, 1, 0, nonce, sha); + const char *nonces[] = {NULL, "", "f", "00000000000000000000000000000000", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", "fffffffffffffffffffffffffffffff", + "fffffffffffffffffffffffffffffffff"}; + for (size_t i = 0; i < sizeof(nonces) / sizeof(nonces[0]); i++) + rejected(IMAGE_BYTES, SLOT_OFFSET, SLOT_BYTES, "p105", 123, 1, 0, nonces[i], sha); + const char *hashes[] = {NULL, "", "a", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}; + for (size_t i = 0; i < sizeof(hashes) / sizeof(hashes[0]); i++) + rejected(IMAGE_BYTES, SLOT_OFFSET, SLOT_BYTES, "p105", 123, 1, 0, nonce, hashes[i]); + rejected(IMAGE_BYTES, SLOT_OFFSET, SLOT_BYTES, "p105", 0, 1, 0, nonce, sha); + rejected(IMAGE_BYTES, SLOT_OFFSET, SLOT_BYTES, "p105", 1, 0, 0, nonce, sha); + rejected(0, SLOT_OFFSET, SLOT_BYTES, "p105", 1, 1, 0, nonce, sha); + rejected(ORCHARD_IOS_IMAGE_MAX + 1u, SLOT_OFFSET, SLOT_BYTES, "p105", 1, 1, 0, nonce, sha); + rejected(SIZE_MAX, SLOT_OFFSET, SLOT_BYTES, "p105", 1, 1, 0, nonce, sha); + rejected(IMAGE_BYTES, SIZE_MAX, SLOT_BYTES, "p105", 1, 1, 0, nonce, sha); + rejected(IMAGE_BYTES, IMAGE_BYTES, 1, "p105", 1, 1, 0, nonce, sha); + rejected(IMAGE_BYTES, SLOT_OFFSET, 0, "p105", 1, 1, 0, nonce, sha); + rejected(IMAGE_BYTES, SLOT_OFFSET, 1024, "p105", 1, 1, 0, nonce, sha); + rejected(IMAGE_BYTES, SLOT_OFFSET, SIZE_MAX, "p105", 1, 1, 0, nonce, sha); + rejected(IMAGE_BYTES, SLOT_OFFSET, token_bytes - 1u, "p105", 123, 0x8942, 10, nonce, sha); + assert(bind(NULL, IMAGE_BYTES, SLOT_OFFSET, SLOT_BYTES, "p105", 1, 1, 0, nonce, sha) == -1); + for (size_t at = 0; at < SLOT_BYTES; at += SLOT_BYTES / 2u) { + for (size_t i = 0; i < 3; i++) { + const unsigned char values[] = {0, '\t', 'x'}; + unsigned char image[IMAGE_BYTES], before[IMAGE_BYTES]; + fresh(image); + image[SLOT_OFFSET + at] = values[i]; + memcpy(before, image, sizeof(image)); + assert(bind(image, sizeof(image), SLOT_OFFSET, SLOT_BYTES, + "p105", 123, 0x8942, 10, nonce, sha) == -1); + assert(!memcmp(before, image, sizeof(image))); + } + } + unsigned char image[IMAGE_BYTES]; + fresh(image); + assert(!bind(image, sizeof(image), SLOT_OFFSET, token_bytes, + "p105", 123, 0x8942, 10, nonce, sha)); + assert(image[SLOT_OFFSET + token_bytes] == ' '); + assert(bind(image, sizeof(image), SLOT_OFFSET, SLOT_BYTES, + "p105", 123, 0x8942, 10, nonce, sha) == -1); +} + +static void crc_cases(void) +{ + uint32_t crc = 0x12345678; + assert(!orchard_ios_crc32((const unsigned char *)"123456789", 9, &crc)); + assert(crc == 0xcbf43926u); + assert(!orchard_ios_crc32(NULL, 0, &crc) && !crc); + crc = 0x12345678; + assert(orchard_ios_crc32(NULL, 1, &crc) == -1 && crc == 0x12345678); + assert(orchard_ios_crc32((const unsigned char *)"x", SIZE_MAX, &crc) == -1 && crc == 0x12345678); + assert(orchard_ios_crc32((const unsigned char *)"x", ORCHARD_IOS_IMAGE_MAX + 1u, &crc) == -1); + assert(orchard_ios_crc32((const unsigned char *)"x", 1, NULL) == -1); + unsigned char image[16] = {0}, before[16] = {0}; + image[3] = 0x78; image[4] = 0x56; image[5] = 0x34; image[6] = 0x12; + memcpy(before, image, sizeof(image)); + assert(orchard_ios_patch_ibec_crc(image, sizeof(image), 3, 1, 2) == -1); + assert(!memcmp(before, image, sizeof(image))); + assert(!orchard_ios_patch_ibec_crc(image, sizeof(image), 3, 0x12345678u, 0x89abcdefu)); + assert(image[3] == 0xef && image[4] == 0xcd && image[5] == 0xab && image[6] == 0x89); + assert(!memcmp(before, image, 3) && !memcmp(before + 7, image + 7, sizeof(image) - 7)); + memcpy(before, image, sizeof(image)); + assert(orchard_ios_patch_ibec_crc(image, sizeof(image), SIZE_MAX, 0, 0) == -1); + assert(orchard_ios_patch_ibec_crc(image, sizeof(image), 13, 0, 0) == -1); + assert(orchard_ios_patch_ibec_crc(image, 3, 0, 0, 0) == -1); + assert(orchard_ios_patch_ibec_crc(image, ORCHARD_IOS_IBEC_MAX + 1u, 0, 0, 0) == -1); + assert(orchard_ios_patch_ibec_crc(NULL, sizeof(image), 0, 0, 0) == -1); + assert(!memcmp(before, image, sizeof(image))); +} + +int main(void) +{ + size_t bytes = encoded("p105", 123, 0x8942, 10, nonce, sha); + encoded("a", 1, 1, 0, "00000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000000"); + char longest[81]; + memset(longest, 'a', sizeof(longest) - 1); + longest[80] = 0; + encoded(longest, UINT64_MAX, UINT32_MAX, UINT32_MAX, + "ffffffffffffffffffffffffffffffff", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + failures(bytes); + crc_cases(); + puts("native-boundary-checks-passed"); + return 0; +} diff --git a/tests/fixtures/ios-kloader-io.c b/tests/fixtures/ios-kloader-io.c new file mode 100644 index 00000000..a5632c3b --- /dev/null +++ b/tests/fixtures/ios-kloader-io.c @@ -0,0 +1,189 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +/* Compile the producer's actual patched helpers against fake Mach/power APIs. */ +#include +#include +#include +#include +#include + +typedef unsigned int task_t; +typedef unsigned int mach_port_t; +typedef unsigned int mach_msg_type_number_t; +typedef int kern_return_t; +typedef uintptr_t pointer_t; +typedef uintptr_t vm_address_t; +typedef uintptr_t vm_size_t; +typedef unsigned int *vm_region_info_t; +typedef struct { unsigned int unused; } vm_region_submap_info_data_64_t; +#define KERN_SUCCESS 0 +#define KERN_FAILURE 5 +#define MACH_PORT_NULL 0 +#define VM_REGION_SUBMAP_INFO_COUNT_64 1 + +static unsigned int reads, bad_read, short_read, releases; +static unsigned int region_calls, region_mode, task_failure; +static unsigned int sleeps, waits, waited_seconds, port_releases; +static mach_port_t power_port; +static kern_return_t sleep_result; +static _Alignas(uint32_t) uint8_t page[2048]; + +static mach_port_t mach_task_self(void) { return 1; } + +static kern_return_t task_for_pid(task_t task, int pid, task_t *result) +{ + (void)task; + (void)pid; + *result = 2; + return task_failure ? KERN_FAILURE : KERN_SUCCESS; +} + +static kern_return_t vm_read(task_t task, vm_address_t address, vm_size_t length, + pointer_t *buffer, mach_msg_type_number_t *size) +{ + (void)task; + (void)address; + assert(length <= sizeof(page)); + reads++; + if (reads == bad_read) + return KERN_FAILURE; + *buffer = (pointer_t)page; + *size = (mach_msg_type_number_t)length - (reads == short_read ? 1 : 0); + return KERN_SUCCESS; +} + +static kern_return_t vm_deallocate(task_t task, pointer_t buffer, vm_size_t size) +{ + (void)task; + assert(buffer == (pointer_t)page && size <= sizeof(page)); + releases++; + return KERN_SUCCESS; +} + +static kern_return_t mach_port_deallocate(task_t task, mach_port_t port) +{ + (void)task; + assert(port != MACH_PORT_NULL); + port_releases++; + return KERN_SUCCESS; +} + +static kern_return_t vm_region_recurse_64(task_t task, vm_address_t *address, + vm_size_t *size, unsigned int *depth, + vm_region_info_t info, + mach_msg_type_number_t *count) +{ + (void)task; + (void)depth; + (void)info; + assert(*count == VM_REGION_SUBMAP_INFO_COUNT_64); + region_calls++; + switch (region_mode) { + case 1: *size = 0; return KERN_SUCCESS; + case 2: *size = 1; return KERN_SUCCESS; + case 3: *address = UINT32_MAX - 4; *size = 8; return KERN_SUCCESS; + case 4: *size = (1u << 30) + 1; return KERN_SUCCESS; + default: return KERN_FAILURE; + } +} + +static mach_port_t IOPMFindPowerManagement(mach_port_t master) +{ + assert(master == MACH_PORT_NULL); + return power_port; +} + +static kern_return_t IOPMSleepSystem(mach_port_t port) +{ + assert(port == power_port && port != MACH_PORT_NULL); + sleeps++; + return sleep_result; +} + +static unsigned int sleep(unsigned int seconds) +{ + waits++; + waited_seconds += seconds; + return 0; +} + +#define bcopy(from, to, count) memcpy((to), (from), (count)) + +/* ACTUAL_PATCHED_HELPERS */ + +static void reset(void) +{ + reads = bad_read = short_read = releases = 0; + region_calls = region_mode = task_failure = 0; + sleeps = waits = waited_seconds = port_releases = 0; + power_port = MACH_PORT_NULL; + sleep_result = KERN_SUCCESS; + memset(page, 0xab, sizeof(page)); +} + +int main(void) +{ + uint8_t *destination = malloc(ORCHARD_KERNEL_DUMP_BYTES); + assert(destination); + reset(); + assert(orchard_dump_kernel(2, UINT32_MAX, destination) == KERN_FAILURE); + assert(reads == 0); + for (unsigned int failed = 1; failed <= 3; failed++) { + reset(); + bad_read = failed; + assert(orchard_dump_kernel(2, 0x81200000, destination) == KERN_FAILURE); + assert(reads == failed && releases == failed - 1); + } + reset(); + short_read = 1; + assert(orchard_dump_kernel(2, 0x81200000, destination) == KERN_FAILURE); + assert(reads == 1 && releases == 1); + reset(); + assert(orchard_dump_kernel(2, 0x81200000, destination) == KERN_SUCCESS); + assert(reads == (ORCHARD_KERNEL_DUMP_BYTES - ORCHARD_KERNEL_HEADER_BYTES) / + ORCHARD_KERNEL_READ_BYTES); + assert(releases == reads); + assert(destination[0] == 0xab); + assert(destination[ORCHARD_KERNEL_DUMP_BYTES - ORCHARD_KERNEL_HEADER_BYTES - 1] == 0xab); + free(destination); + + for (unsigned int mode = 0; mode <= 3; mode++) { + reset(); + region_mode = mode; + assert(get_kernel_base() == (vm_address_t)-1); + assert(region_calls == (mode == 2 ? ORCHARD_MAX_KERNEL_REGIONS : 1)); + assert(reads == 0 && port_releases == 1); + } + reset(); + task_failure = 1; + assert(get_kernel_base() == (vm_address_t)-1); + assert(region_calls == 0 && port_releases == 0); + reset(); + region_mode = 4; + bad_read = 1; + assert(get_kernel_base() == (vm_address_t)-1); + assert(reads == 1 && port_releases == 1); + reset(); + region_mode = 4; + assert(get_kernel_base() == (vm_address_t)-1); + assert(reads == 2 && releases == 2); + reset(); + region_mode = 4; + *(uint32_t *)page = 0xfeedface; + assert(get_kernel_base() == 0x81400000); + assert(reads == 1 && releases == 1 && port_releases == 1); + + reset(); + assert(orchard_handoff_once() == KERN_FAILURE); + assert(sleeps == 0 && waits == 0 && port_releases == 0); + reset(); + power_port = 3; + sleep_result = 8; + assert(orchard_handoff_once() == 8); + assert(sleeps == 1 && waits == 0 && port_releases == 1); + reset(); + power_port = 3; + assert(orchard_handoff_once() == KERN_FAILURE); + assert(sleeps == 1 && waits == 1 && waited_seconds == 90 && port_releases == 1); + puts("bounded reads, region exhaustion and one sleep request passed"); + return 0; +} diff --git a/tests/fixtures/ios-launcher-wake.c b/tests/fixtures/ios-launcher-wake.c new file mode 100644 index 00000000..b496e60e --- /dev/null +++ b/tests/fixtures/ios-launcher-wake.c @@ -0,0 +1,176 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Exercise the production wake functions and main with CoreFoundation values. + * Power scheduling, child execution and privileged identity/lease checks use + * local fakes. The fixture never loads IOKit or starts a kernel loader. + * test_p105_ios_wake.py extracts the production source into the included file. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static int schedule_calls,cancel_calls,copy_calls,spawn_calls,wait_calls,lease_calls,api_calls; +static int schedule_error,cancel_error,spawn_error,wait_error,eintr_left,queue_case,child_status; +static int hash_error,lease_error,open_error,missing_api,lease_open,owned,foreign; +static int wrong_model, wrong_build, bad_length; +static double clock_now; +static CFDateRef saved_date; +static CFStringRef saved_owner,saved_type; +static void reset(void) { + if(saved_date) CFRelease(saved_date); + if(saved_owner) CFRelease(saved_owner); + if(saved_type) CFRelease(saved_type); + saved_date=NULL;saved_owner=NULL;saved_type=NULL; + schedule_calls=cancel_calls=copy_calls=spawn_calls=wait_calls=lease_calls=api_calls=0; + schedule_error=cancel_error=spawn_error=wait_error=eintr_left=queue_case=child_status=0; + hash_error=lease_error=open_error=missing_api=lease_open=owned=0;foreign=1;clock_now=1000.75;wrong_model=wrong_build=bad_length=0; +} +static kern_return_t fake_schedule(CFDateRef date,CFStringRef owner,CFStringRef type) { + assert(lease_open && !schedule_calls && !cancel_calls); + assert(CFDateGetAbsoluteTime(date)==1060.0); + assert(CFEqual(owner,CFSTR("org.orchardlinux.p105.start.4242"))); + assert(CFEqual(type,CFSTR("wake"))); + saved_date=CFRetain(date);saved_owner=CFRetain(owner);saved_type=CFRetain(type); + owned=1;schedule_calls++;return schedule_error; +} +static kern_return_t fake_cancel(CFDateRef date,CFStringRef owner,CFStringRef type) { + assert(lease_open && schedule_calls==1 && !cancel_calls); + assert(date==saved_date && owner==saved_owner && type==saved_type); + assert(foreign==1);cancel_calls++; + if(!cancel_error) owned=0; + return cancel_error; +} +static CFArrayRef fake_copy(void) { + assert(lease_open && schedule_calls==1 && !cancel_calls);copy_calls++; + if(queue_case==5) return NULL; + if(queue_case==6) return (CFArrayRef)CFRetain(CFSTR("invalid queue")); + CFMutableArrayRef array=CFArrayCreateMutable(NULL,0,&kCFTypeArrayCallBacks); + const void *keys[]={CFSTR("time"),CFSTR("scheduledby"),CFSTR("eventtype")}; + CFDateRef other_date=CFDateCreate(NULL,1061.0); + const void *other[]={other_date,CFSTR("some.other.application"),CFSTR("wake")}; + CFDictionaryRef other_event=CFDictionaryCreate(NULL,keys,other,3,&kCFTypeDictionaryKeyCallBacks,&kCFTypeDictionaryValueCallBacks); + CFArrayAppendValue(array,other_event); + if(queue_case!=1) { + const void *values[]={queue_case==3?other_date:saved_date,queue_case==2?CFSTR("wrong owner"):saved_owner,queue_case==4?CFSTR("sleep"):saved_type}; + CFDictionaryRef event=CFDictionaryCreate(NULL,keys,values,3,&kCFTypeDictionaryKeyCallBacks,&kCFTypeDictionaryValueCallBacks); + CFArrayAppendValue(array,event);CFRelease(event); + } + if(queue_case==7) for(int i=0;i<4096;i++) CFArrayAppendValue(array,other_event); + CFRelease(other_event);CFRelease(other_date);return array; +} +static void *fake_dlopen(const char *path,int flags) { + assert(!strcmp(path,"/System/Library/Frameworks/IOKit.framework/IOKit")); + assert(flags==(RTLD_NOW|RTLD_LOCAL));api_calls++; + return open_error?NULL:(void *)(uintptr_t)1; +} +static void *fake_dlsym(void *handle,const char *name) { + assert(handle==(void *)(uintptr_t)1); + if(!strcmp(name,"IOPMSchedulePowerEvent")) return missing_api==1?NULL:(void *)fake_schedule; + if(!strcmp(name,"IOPMCancelScheduledPowerEvent")) return missing_api==2?NULL:(void *)fake_cancel; + if(!strcmp(name,"IOPMCopyScheduledPowerEvents")) return missing_api==3?NULL:(void *)fake_copy; + assert(0);return NULL; +} +static int fake_dlclose(void *handle) {assert(handle==(void *)(uintptr_t)1);return 0;} +static int fake_spawn(pid_t *pid,const char *path,const posix_spawn_file_actions_t *actions,const posix_spawnattr_t *attrs,char *const argv[],char *const env[]) { + assert(lease_open && owned && copy_calls==1 && !cancel_calls); + assert(!strcmp(path,"/usr/local/lib/orchard-p105/kloader")); + assert(!strcmp(argv[0],path) && !strcmp(argv[1],"/usr/local/lib/orchard-p105/ibec-local.raw") && !argv[2]); + assert(!actions && !attrs && !strcmp(env[1],"HOME=/var/root")); + spawn_calls++;*pid=567;return spawn_error; +} +static pid_t fake_wait(pid_t pid,int *status,int flags) { + assert(pid==567 && flags==0 && lease_open && owned && !cancel_calls);wait_calls++; + if(eintr_left){eintr_left--;errno=EINTR;return -1;} + if(wait_error){errno=ECHILD;return -1;} + *status=child_status;return pid; +} +static int fake_close(int fd) {assert(fd==99 && lease_open);lease_open=0;return 0;} +static int fake_sysctl(const char *name,void *value,size_t *length,void *new_value,size_t new_length) { + assert(!new_value && !new_length); + if(bad_length){*length=bad_length==1?0:33;return 0;} + const char *result; + if(!strcmp(name,"hw.machine")) result=wrong_model?"other":"iPad2,5"; + else {assert(!strcmp(name,"kern.osversion"));result=wrong_build?"other":"13G36";} + assert(*length>strlen(result));memcpy(value,result,strlen(result)+1);*length=strlen(result)+1;return 0; +} +static uid_t fake_uid(void){return 0;} +static int fake_setgid(gid_t value){assert(value==0);return 0;} +static int fake_setuid(uid_t value){assert(value==0);return 0;} +static kern_return_t fake_task(mach_port_t task,int pid,mach_port_t *port){(void)task;assert(pid==0);*port=1;return 0;} +static kern_return_t fake_deallocate(mach_port_t task,mach_port_t port){(void)task;assert(port==1);return 0;} +static int checked(const char *path,size_t length,const char *hash){(void)path;(void)length;(void)hash;assert(!lease_calls && !api_calls);return hash_error;} +static int boot_lease(void){assert(!api_calls && !schedule_calls);lease_calls++;if(lease_error)return -1;lease_open=1;return 99;} +static CFAbsoluteTime fake_now(void){return clock_now;} +static pid_t fake_pid(void){return 4242;} +#define TARGET_MACHINE "iPad2,5" +#define TARGET_IOS_BUILD "13G36" +#define ROOT_BOOT_PATH "/boot/omarchy-p105.bin" +#define IBEC_PATH "/usr/local/lib/orchard-p105/ibec-local.raw" +#define KLOADER_PATH "/usr/local/lib/orchard-p105/kloader" +#define WAKE_OWNER "org.orchardlinux.p105.start" +#define WAKE_DELAY_SECONDS 60 +#define ROOT_BOOT_BYTES 1 +#define ROOT_BOOT_SHA256 "stub" +#define IBEC_BYTES 1 +#define IBEC_SHA256 "stub" +#define KLOADER_BYTES 1 +#define KLOADER_SHA256 "stub" +#define dlopen fake_dlopen +#define dlsym fake_dlsym +#define dlclose fake_dlclose +#define posix_spawn fake_spawn +#define waitpid fake_wait +#define close fake_close +#define sysctlbyname fake_sysctl +#define geteuid fake_uid +#define setgid fake_setgid +#define setuid fake_setuid +#define task_for_pid fake_task +#define mach_port_deallocate fake_deallocate +#define CFAbsoluteTimeGetCurrent fake_now +#define getpid fake_pid +#define main inspected_main + +#include "p105-ios-wake-source.inc" + +#undef main +static int run(int inspect){char *argv[]={"helper","--check-installed",NULL};return inspected_main(inspect?2:1,argv);} +static unsigned cases; +static void expect(int inspect,int result,int schedules,int cancels,int spawns) { + assert(run(inspect)==result); + assert(schedule_calls==schedules && cancel_calls==cancels && spawn_calls==spawns); + assert(foreign==1 && !lease_open);cases++; +} +int main(void) { + reset();bad_length=1;expect(0,10,0,0,0);assert(!lease_calls && !api_calls); + reset();bad_length=2;expect(0,10,0,0,0);assert(!lease_calls && !api_calls); + reset();wrong_model=1;expect(0,10,0,0,0);assert(!lease_calls && !api_calls); + reset();wrong_build=1;expect(0,10,0,0,0);assert(!lease_calls && !api_calls); + reset();expect(1,0,0,0,0);assert(!lease_calls && api_calls==1); + for(int i=1;i<=3;i++){reset();missing_api=i;expect(1,14,0,0,0);assert(!lease_calls);} + reset();hash_error=1;expect(0,12,0,0,0);assert(!lease_calls && !api_calls); + reset();lease_error=1;expect(0,13,0,0,0);assert(!api_calls); + reset();open_error=1;expect(0,14,0,0,0); + for(int i=1;i<=3;i++){reset();missing_api=i;expect(0,14,0,0,0);} + reset();clock_now=0;expect(0,14,0,0,0); + reset();schedule_error=1;expect(0,14,1,1,0);assert(!owned); + reset();schedule_error=1;cancel_error=1;expect(0,16,1,1,0);assert(owned); + for(int i=1;i<=7;i++){reset();queue_case=i;expect(0,14,1,1,0);assert(!owned);} + reset();spawn_error=EACCES;expect(0,15,1,1,1);assert(!wait_calls && !owned); + reset();child_status=37<<8;expect(0,37,1,1,1);assert(!owned); + reset();expect(0,15,1,1,1);assert(!owned); + reset();child_status=SIGTERM;expect(0,128+SIGTERM,1,1,1);assert(!owned); + reset();eintr_left=3;child_status=7<<8;expect(0,7,1,1,1);assert(wait_calls==4 && !owned); + reset();wait_error=1;expect(0,15,1,1,1);assert(!owned); + reset();child_status=37<<8;cancel_error=1;expect(0,16,1,1,1);assert(owned); + reset();printf("owned_wake_cases=%u\n",cases);return 0; +} diff --git a/tests/fixtures/ios-live-identity.c b/tests/fixtures/ios-live-identity.c new file mode 100644 index 00000000..57fb6c55 --- /dev/null +++ b/tests/fixtures/ios-live-identity.c @@ -0,0 +1,177 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +#include +#include +#include +#include + +#define TARGET_MACHINE "iPad2,5" +#define TARGET_MODEL "P105AP" +#define TARGET_IOS_BUILD "13G36" +#define TARGET_CHIP 0x8942u +#define TARGET_BOARD 0x0au +#define TARGET_REGISTRY_COMPATIBLE "P105AP\0iPad2,5\0AppleARM\0" +#define TARGET_REGISTRY_COMPATIBLE_BYTES 24u +#include "ios-live-identity.h" + +static const char *scenario; +static unsigned int opened, closed, properties; + +static int is(const char *value) +{ + return !strcmp(scenario, value); +} + +int sysctlbyname(const char *name, void *oldp, size_t *oldlenp, void *newp, size_t newlen) +{ + assert(!newp && !newlen); + const char *value; + if (!strcmp(name, "hw.machine")) value = TARGET_MACHINE; + else if (!strcmp(name, "hw.model")) value = TARGET_MODEL; + else if (!strcmp(name, "kern.osversion")) value = TARGET_IOS_BUILD; + else abort(); + if (is("sysctl-failure")) return -1; + if (is("sysctl-oversize")) { + *oldlenp = 129; + return 0; + } + if (is("wrong-machine") && !strcmp(name, "hw.machine")) value = "iPad2,6"; + if (is("wrong-model") && !strcmp(name, "hw.model")) value = "P106AP"; + if (is("wrong-build") && !strcmp(name, "kern.osversion")) value = "13G37"; + size_t length = strlen(value) + 1u; + assert(length <= *oldlenp); + memcpy(oldp, value, length); + *oldlenp = length; + if (is("sysctl-short")) (*oldlenp)--; + if (is("sysctl-unclosed")) ((unsigned char *)oldp)[length - 1u] = 'x'; + return 0; +} + +orchard_ios_registry_entry_t IORegistryEntryFromPath(mach_port_t master, const char *path) +{ + assert(!master); + if (!strcmp(path, "IODeviceTree:/chosen")) { + if (is("missing-chosen")) return 0; + opened++; + return 1; + } + if (!strcmp(path, "IODeviceTree:/")) { + if (is("missing-root")) return 0; + opened++; + return 2; + } + abort(); +} + +CFTypeRef IORegistryEntryCreateCFProperty(orchard_ios_registry_entry_t entry, CFStringRef name, + CFAllocatorRef allocator, uint32_t options) +{ + assert(!allocator && !options); + properties++; + unsigned char bytes[128] = {0}; + size_t length; + const char *key; + if (entry == 1 && CFEqual(name, CFSTR("unique-chip-id"))) { + key = "ecid"; + const unsigned char unique[] = {8, 7, 6, 5, 4, 3, 2, 1}; + memcpy(bytes, unique, sizeof(unique)); + length = sizeof(unique); + if (is("ecid-zero")) memset(bytes, 0, length); + if (is("fresh-ecid")) bytes[0] = 9; + if (is("ecid-reversed")) { + for (unsigned int i = 0; i < 8; i++) bytes[i] = (unsigned char)(i + 1u); + } + } else if (entry == 1 && CFEqual(name, CFSTR("chip-id"))) { + key = "chip"; + bytes[0] = 0x42; bytes[1] = 0x89; + length = 4; + if (is("chip-wrong")) bytes[0] = 0x43; + if (is("chip-reversed")) { + bytes[0] = 0; bytes[1] = 0; bytes[2] = 0x89; bytes[3] = 0x42; + } + } else if (entry == 1 && CFEqual(name, CFSTR("board-id"))) { + key = "board"; + bytes[0] = 0x0a; + length = 4; + if (is("board-wrong")) bytes[0] = 0x0b; + if (is("board-reversed")) { + bytes[0] = 0; bytes[3] = 0x0a; + } + } else if (entry == 2 && CFEqual(name, CFSTR("model"))) { + key = "root-model"; + memcpy(bytes, TARGET_MACHINE, sizeof(TARGET_MACHINE)); + length = sizeof(TARGET_MACHINE); + if (is("root-model-alias")) bytes[6] = '6'; + } else if (entry == 2 && CFEqual(name, CFSTR("compatible"))) { + key = "compatible"; + memcpy(bytes, TARGET_REGISTRY_COMPATIBLE, TARGET_REGISTRY_COMPATIBLE_BYTES); + length = TARGET_REGISTRY_COMPATIBLE_BYTES; + if (is("compatible-alias")) bytes[3] = '6'; + if (is("compatible-reordered")) + memcpy(bytes, "iPad2,5\0P105AP\0AppleARM\0", length); + } else abort(); + char variant[64]; + snprintf(variant, sizeof(variant), "%s-missing", key); + if (is(variant)) return NULL; + snprintf(variant, sizeof(variant), "%s-type", key); + if (is(variant)) + return CFStringCreateWithCString(NULL, "wrong CF type", kCFStringEncodingUTF8); + snprintf(variant, sizeof(variant), "%s-short", key); + if (is(variant)) length--; + snprintf(variant, sizeof(variant), "%s-long", key); + if (is(variant)) length++; + return CFDataCreate(NULL, bytes, (CFIndex)length); +} + +kern_return_t IOObjectRelease(mach_port_t object) +{ + assert(object == 1 || object == 2); + closed++; + return KERN_SUCCESS; +} + +static void run_case(const char *name, int success, uint64_t expected) +{ + scenario = name; + opened = closed = properties = 0; + uint64_t value = UINT64_C(0xa5a5a5a5a5a5a5a5); + int result = orchard_ios_live_identity(&value); + assert(opened == closed); + if (success) { + assert(!result && value == expected); + assert(opened == 2 && properties == 5); + } else { + assert(result == -1 && value == UINT64_C(0xa5a5a5a5a5a5a5a5)); + } + printf("{\"case\":\"%s\",\"accepted\":%s,\"opened\":%u,\"closed\":%u," + "\"properties\":%u,\"ecid\":\"%016llx\"}\n", name, success ? "true" : "false", + opened, closed, properties, (unsigned long long)value); +} + +int main(void) +{ + run_case("valid", 1, UINT64_C(0x0102030405060708)); + run_case("fresh-ecid", 1, UINT64_C(0x0102030405060709)); + /* The byte-order contract is little-endian. An ECID has no independent + * expected value, so reversing its bytes yields that distinct value. + */ + run_case("ecid-reversed", 1, UINT64_C(0x0807060504030201)); + const char *failures[] = {"sysctl-failure", "sysctl-oversize", "sysctl-short", "sysctl-unclosed", + "wrong-machine", "wrong-model", "wrong-build", "missing-chosen", "missing-root", + "ecid-zero", "chip-wrong", "chip-reversed", "board-wrong", "board-reversed", + "root-model-alias", "compatible-alias", "compatible-reordered"}; + for (size_t i = 0; i < sizeof(failures) / sizeof(failures[0]); i++) + run_case(failures[i], 0, 0); + const char *keys[] = {"ecid", "chip", "board", "root-model", "compatible"}; + const char *variants[] = {"missing", "type", "short", "long"}; + for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { + for (size_t j = 0; j < sizeof(variants) / sizeof(variants[0]); j++) { + char name[64]; + snprintf(name, sizeof(name), "%s-%s", keys[i], variants[j]); + run_case(name, 0, 0); + } + } + opened = closed = properties = 0; + assert(orchard_ios_live_identity(NULL) == -1 && !opened && !properties); + puts("native-live-identity-checks-passed"); + return 0; +} diff --git a/tests/hardware_layout.py b/tests/hardware_layout.py index 754e6ce1..1b4c62b7 100644 --- a/tests/hardware_layout.py +++ b/tests/hardware_layout.py @@ -32,7 +32,7 @@ def patches(name): print(platform, dtb or '-') elif sys.argv[1] == '--platform': platform = resolve(sys.argv[2])[0] - print(json.dumps({'platform': platform, 'arch': 'arm64', 'image': 'Image.gz', + print(json.dumps({'platform': platform, 'arch': 'arm64', 'image': 'Image.gz', 'loader': 'm1n1', 'dtbs': 'arch/arm64/boot/dts/apple', 'upstream': 'kernel/upstream.lock', 'series': 'kernel/series/' + platform, 'config': 'kernel/configs/' + platform + '/config'})) diff --git a/tests/test_armv7_producers.py b/tests/test_armv7_producers.py new file mode 100644 index 00000000..0f502f6a --- /dev/null +++ b/tests/test_armv7_producers.py @@ -0,0 +1,263 @@ +"""ARMv7 producer boundaries use synthetic ELF and DTB inputs, with no device access.""" +import gzip +import hashlib +import io +import json +from pathlib import Path +import struct +import tarfile +import tempfile +import unittest +from unittest import mock + +from orchard import build, inputs, distro +from orchard.hardware import ROOT +from orchard.image import elf, platform, prepare_runtime + + +def executable(architecture='arm', dynamic=False): + width = 1 if architecture == 'arm' else 2 + data = bytearray(384) + data[:7] = b'\x7fELF' + bytes((width, 1, 1)) + struct.pack_into('I', cells), + 'compatible': b'example,test\0'}, + 'children': [{'name': 'chosen', 'properties': {'bootargs': b'old\0', 'stdout-path': b'serial0\0'}, + 'children': []}, + {'name': 'memory@80000000', 'properties': {'device_type': b'memory\0', + 'reg': b'\x80\0\0\0\x20\0\0\0'}, + 'children': []}]} + + +class CascadiaLoaderTests(unittest.TestCase): + @unittest.skipUnless(sys.platform == 'linux' and shutil.which('cc'), 'The C runtime fixture needs Linux mmap and a C compiler') + def test_actual_runtime_c_copy_and_rejection_paths(self): + with tempfile.TemporaryDirectory() as temporary: + folder = Path(temporary) + (folder / 'cascadia-generated.h').write_text(''.join( + f'#define {name.upper()} {hex(value)}\n' for name, value in PROFILE.items() if name != 'schema')) + executable = folder / 'runtime-check' + command = ['cc', '-std=c11', '-O2', '-Wall', '-Wextra', '-Werror', '-Wno-int-to-pointer-cast', + '-I', str(folder), '-I', str(ROOT / 'boot/cascadia'), + str(ROOT / 'tests/fixtures/cascadia-runtime.c'), '-o', str(executable)] + compiled = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('runtime checks passed', result.stdout) + + def test_elf_entry_and_raw_code_must_match(self): + binary = loader() + elf = executable(binary) + cascadia.verify_loader(binary, PROFILE, elf) + shifted = bytearray(elf) + struct.pack_into('I', 0x98000000), + 'linux,initrd-end': struct.pack('>I', 0x98000010)}) + old = cascadia.write_fdt(value, [(0x98000000, 16), (0x9f000000, 4096)]) + new = cascadia.chosen(old, 'new', 0x98000000, 32) + self.assertEqual(cascadia.read_fdt(new)[1], [(0x9f000000, 4096), (0x98000000, 32)]) + + def test_reserved_destination_refused(self): + old = cascadia.write_fdt(tree(), [(PROFILE['initrd_address'] + 4, 4096)]) + with self.assertRaisesRegex(ValueError, 'reserved memory'): + self.assemble(dtb=old) + + def test_fdt_corruption_is_rejected(self): + original = cascadia.write_fdt(tree()) + for offset, value in ((4, len(original) + 1), (8, 0xffffffff), (12, 0), (16, 41), (20, 16)): + with self.subTest(offset=offset): + bad = bytearray(original) + struct.pack_into('>I', bad, offset, value) + with self.assertRaises(ValueError): + cascadia.read_fdt(bad) + with self.assertRaisesRegex(ValueError, 'overlap'): + bad = bytearray(original) + struct.pack_into('>I', bad, 12, struct.unpack_from('>I', bad, 8)[0]) + cascadia.read_fdt(bad) + + def test_wrong_kernel_and_out_of_bounds_initramfs_refused(self): + with self.assertRaisesRegex(ValueError, 'ARM zImage'): + self.assemble(kernel=b'arm64 Image') + with self.assertRaisesRegex(ValueError, 'length'): + self.assemble(kernel=kernel() + b'appended old dtb') + smaller = dict(PROFILE, initrd_limit=PROFILE['initrd_address'] + 8) + with self.assertRaisesRegex(ValueError, 'destination'): + self.assemble(loader=loader(smaller), described=smaller, elf=None) + with self.assertRaisesRegex(ValueError, 'one line'): + self.assemble(bootargs='a\nb') + + def test_decompressor_workspace_cannot_reach_high_initramfs(self): + large = bytearray(kernel()) + struct.pack_into('